Compare commits

..
129 changed files with 6633 additions and 21165 deletions

View file

@ -1,5 +1,4 @@
# Builds Fry3DX for Linux (a portable .tar.gz and an AppImage), Windows (.zip), macOS (.zip with one # Builds Fry3DX for Linux (a portable .tar.gz and an AppImage), Windows (.zip) and Android (.apk).
# app for Apple silicon and Intel) and Android (.apk).
# A tag v* (git tag v0.4.0 && git push --tags) also makes a GitHub release with them; # 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). # otherwise they're the run's artifacts (Actions tab).
# Android signing: secrets FRY_KEYSTORE_B64 (the keystore, base64) and FRY_KEYSTORE_PASS. Keep # Android signing: secrets FRY_KEYSTORE_B64 (the keystore, base64) and FRY_KEYSTORE_PASS. Keep
@ -32,11 +31,6 @@ jobs:
run: | run: |
cmake --preset portable -DGLSLC=$ANDROID_NDK_HOME/shader-tools/linux-x86_64/glslc cmake --preset portable -DGLSLC=$ANDROID_NDK_HOME/shader-tools/linux-x86_64/glslc
cmake --build build/portable cmake --build build/portable
- name: Test
# The modelling core, the scripts, and the fuzz test (random shapes, edits, broken files).
run: |
cmake --build build/portable --target check
build/portable/bin/fuzz_test 2000
- name: Package - name: Package
run: APPIMAGETOOL=$PWD/appimagetool python3 tools/package.py linux appimage run: APPIMAGETOOL=$PWD/appimagetool python3 tools/package.py linux appimage
- uses: actions/upload-artifact@v4 - uses: actions/upload-artifact@v4
@ -59,31 +53,6 @@ jobs:
- uses: actions/upload-artifact@v4 - uses: actions/upload-artifact@v4
with: { name: windows, path: "dist/*" } with: { name: windows, path: "dist/*" }
macos:
# Built twice (Apple silicon, and Intel cross-built on the same Mac), then joined into one app.
runs-on: macos-15
steps:
- uses: actions/checkout@v4
- name: Tools
run: |
brew install ninja shaderc
# MoltenVK (Vulkan on Metal), for both kinds of Mac, carried inside the app.
curl -sfL https://github.com/KhronosGroup/MoltenVK/releases/download/v1.3.0/MoltenVK-macos.tar | tar -x -C "$RUNNER_TEMP"
MVK=$(find "$RUNNER_TEMP" -name libMoltenVK.dylib -path '*macOS*' | head -1)
lipo -info "$MVK"
echo "MVK=$MVK" >> "$GITHUB_ENV"
- name: Build
run: |
for arch in arm64 x64; do
cmake --preset macos-$arch -DFRY_MOLTENVK="$MVK"
cmake --build build/macos-$arch
done
cmake --build build/macos-arm64 --target check
- name: Package
run: python3 tools/package.py macos
- uses: actions/upload-artifact@v4
with: { name: macos, path: "dist/*" }
android: android:
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
steps: steps:
@ -93,7 +62,7 @@ jobs:
- name: SDK - name: SDK
run: | run: |
yes | "$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" "ndk;27.2.12479018" "build-tools;35.0.0" "platforms;android-35" > /dev/null 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 glslc sudo apt-get install -y ninja-build
- name: Build - name: Build
env: env:
ANDROID_NDK_ROOT: ${{ env.ANDROID_HOME }}/ndk/27.2.12479018 ANDROID_NDK_ROOT: ${{ env.ANDROID_HOME }}/ndk/27.2.12479018
@ -108,7 +77,7 @@ jobs:
release: release:
if: startsWith(github.ref, 'refs/tags/v') if: startsWith(github.ref, 'refs/tags/v')
needs: [linux, windows, macos, android] needs: [linux, windows, android]
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions: { contents: write } permissions: { contents: write }
steps: steps:

4
.gitignore vendored
View file

@ -4,7 +4,3 @@ build/
__pycache__/ __pycache__/
*.fry~ *.fry~
dist/ dist/
# crash dumps and stray test programs
core.[0-9]*
a.out

View file

@ -1,6 +1,5 @@
cmake_minimum_required(VERSION 3.25) cmake_minimum_required(VERSION 3.25)
include(cmake/Project.cmake) # project.json: the version, links, creators project(Fry3DX VERSION 0.5.0 LANGUAGES C CXX)
project(Fry3DX VERSION ${FRY_VERSION} LANGUAGES C CXX)
set(CMAKE_CXX_SCAN_FOR_MODULES OFF) # (no C++ modules; the scan can pick up a different clang than the NDK's) set(CMAKE_CXX_SCAN_FOR_MODULES OFF) # (no C++ modules; the scan can pick up a different clang than the NDK's)
if(UNIX AND NOT APPLE) if(UNIX AND NOT APPLE)
add_link_options(-rdynamic) # function names in crash reports add_link_options(-rdynamic) # function names in crash reports
@ -31,32 +30,21 @@ set(CMAKE_CXX_EXTENSIONS OFF)
set(CMAKE_EXPORT_COMPILE_COMMANDS ON) set(CMAKE_EXPORT_COMPILE_COMMANDS ON)
set(CMAKE_RUNTIME_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/bin) set(CMAKE_RUNTIME_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/bin)
# Where the content goes: next to the program; on a Mac, inside the app (Fry3DX.app/Contents/
# Resources, where SDL_GetBasePath points).
if(APPLE)
set(FRY_CONTENT_DIR ${CMAKE_RUNTIME_OUTPUT_DIRECTORY}/Fry3DX.app/Contents/Resources)
else()
set(FRY_CONTENT_DIR ${CMAKE_RUNTIME_OUTPUT_DIRECTORY})
endif()
# Libraries are linked in, not .so files beside the program (SDL says for itself). Set before
# any of them, so one that has its own BUILD_SHARED_LIBS option (Manifold) can't turn it on.
set(BUILD_SHARED_LIBS OFF CACHE BOOL "Build the libraries as shared ones" FORCE)
include(cmake/Dependencies.cmake) include(cmake/Dependencies.cmake)
include(cmake/Shaders.cmake) include(cmake/Shaders.cmake)
add_subdirectory(engine) add_subdirectory(engine)
add_subdirectory(src) add_subdirectory(src)
# Content next to the program: engine/ (the engine's: fonts, sky, config); Fry3DX/ (the app's own: scripts, branding). # 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 add_custom_target(content ALL
COMMAND ${CMAKE_COMMAND} -E copy_directory ${CMAKE_SOURCE_DIR}/content ${FRY_CONTENT_DIR} COMMAND ${CMAKE_COMMAND} -E copy_directory ${CMAKE_SOURCE_DIR}/content ${CMAKE_RUNTIME_OUTPUT_DIRECTORY}
COMMAND ${CMAKE_COMMAND} -E make_directory ${FRY_CONTENT_DIR}/engine/fonts ${FRY_CONTENT_DIR}/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 COMMAND ${CMAKE_COMMAND} -E copy_if_different ${dejavu_SOURCE_DIR}/ttf/DejaVuSans.ttf ${dejavu_SOURCE_DIR}/ttf/DejaVuSans-Bold.ttf
${dejavu_SOURCE_DIR}/LICENSE ${FRY_CONTENT_DIR}/engine/fonts/ ${dejavu_SOURCE_DIR}/LICENSE ${CMAKE_RUNTIME_OUTPUT_DIRECTORY}/engine/fonts/
COMMENT "Copying content" VERBATIM) COMMENT "Copying content" VERBATIM)
enable_testing() enable_testing()
add_subdirectory(tests EXCLUDE_FROM_ALL) add_subdirectory(tests EXCLUDE_FROM_ALL)
add_custom_target(check COMMAND ${CMAKE_CTEST_COMMAND} --test-dir ${CMAKE_BINARY_DIR} --output-on-failure add_custom_target(check COMMAND ${CMAKE_CTEST_COMMAND} --test-dir ${CMAKE_BINARY_DIR} --output-on-failure
DEPENDS mesh_test script_test fuzz_test USES_TERMINAL VERBATIM) DEPENDS mesh_test USES_TERMINAL VERBATIM)

View file

@ -7,10 +7,6 @@
{ "name": "portable", "inherits": "base", "cacheVariables": { "CMAKE_BUILD_TYPE": "Release", "FRY_PORTABLE": "ON" } }, { "name": "portable", "inherits": "base", "cacheVariables": { "CMAKE_BUILD_TYPE": "Release", "FRY_PORTABLE": "ON" } },
{ "name": "windows", "inherits": "base", "toolchainFile": "${sourceDir}/cmake/toolchains/windows-msvc.cmake", { "name": "windows", "inherits": "base", "toolchainFile": "${sourceDir}/cmake/toolchains/windows-msvc.cmake",
"cacheVariables": { "CMAKE_BUILD_TYPE": "Release", "CMAKE_MSVC_RUNTIME_LIBRARY": "MultiThreaded" } }, "cacheVariables": { "CMAKE_BUILD_TYPE": "Release", "CMAKE_MSVC_RUNTIME_LIBRARY": "MultiThreaded" } },
{ "name": "macos-arm64", "inherits": "base",
"cacheVariables": { "CMAKE_BUILD_TYPE": "Release", "FRY_PORTABLE": "ON", "CMAKE_OSX_ARCHITECTURES": "arm64", "CMAKE_OSX_DEPLOYMENT_TARGET": "13.3" } },
{ "name": "macos-x64", "inherits": "base",
"cacheVariables": { "CMAKE_BUILD_TYPE": "Release", "FRY_PORTABLE": "ON", "CMAKE_OSX_ARCHITECTURES": "x86_64", "CMAKE_OSX_DEPLOYMENT_TARGET": "13.3" } },
{ "name": "android", "inherits": "base", "toolchainFile": "$env{ANDROID_NDK_ROOT}/build/cmake/android.toolchain.cmake", { "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" } } "cacheVariables": { "CMAKE_BUILD_TYPE": "Release", "ANDROID_ABI": "arm64-v8a", "ANDROID_PLATFORM": "android-28", "ANDROID_STL": "c++_static" } }
], ],
@ -19,8 +15,6 @@
{ "name": "release", "configurePreset": "release" }, { "name": "release", "configurePreset": "release" },
{ "name": "portable", "configurePreset": "portable" }, { "name": "portable", "configurePreset": "portable" },
{ "name": "windows", "configurePreset": "windows" }, { "name": "windows", "configurePreset": "windows" },
{ "name": "macos-arm64", "configurePreset": "macos-arm64" },
{ "name": "macos-x64", "configurePreset": "macos-x64" },
{ "name": "android", "configurePreset": "android" } { "name": "android", "configurePreset": "android" }
] ]
} }

120
README.md
View file

@ -2,7 +2,7 @@
<h1 align="center">Fry3DX</h1> <h1 align="center">Fry3DX</h1>
<p align="center"><b>Frying 3D Extreme — a 3D modeller that does what you'd expect.</b><br> <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, macOS, Linux and your phone.</p> Pick a tool, click, drag. On Windows, Linux and your phone.</p>
<p align="center"> <p align="center">
<a href="https://nexoniarz.github.io/Fry3DX/">Website</a> · <a href="https://nexoniarz.github.io/Fry3DX/">Website</a> ·
<a href="https://github.com/Nexoniarz/Fry3DX/releases">Download</a> · <a href="https://github.com/Nexoniarz/Fry3DX/releases">Download</a> ·
@ -28,23 +28,15 @@ From [Releases](https://github.com/Nexoniarz/Fry3DX/releases):
| | | | | |
|---|---| |---|---|
| **Windows** (10 and 11, x64) | `Fry3DX-…-windows-x64.zip`: unpack, run `fry3dx.exe` | | **Windows** (10 and 11, x64) | `Fry3DX-…-windows-x64.zip`: unpack, run `fry3dx.exe` |
| **macOS** (13.3 and newer, Apple silicon and Intel) | `Fry3DX-…-macos.zip`: unzip, open `Fry3DX.app` (the first time: right-click it, Open) |
| **Linux** (any distro from 2022 on, x64) | `Fry3DX-…-linux-x64.AppImage`: make it executable, run it. Or the `.tar.gz` | | **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 | | **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. On a Mac, Vulkan Needs a graphics card (or phone) with Vulkan; on a PC, OpenGL 4.6 works too.
comes inside the app (MoltenVK, on Metal), and OpenGL 4.1 is the fallback.
## What it does ## What it does
Five tabs along the top: **Model**, **Paint**, **Layout**, **Render**, **Print** (Ctrl+1 to 5). Five tabs along the top: **Model**, **Paint**, **Layout**, **Render**, **Print** (Ctrl+1 to 5).
**Finding things**
- **Any command by name**: F3 or Ctrl+Space, type a few letters. **Round the mouse**: hold Space
for the tools, Shift+Space for the views. Give any command **your own key** (Help > Keys and
mouse). **Undo history** (Edit > Undo history): go back to any step. **Workspaces**: the panels as
you had them, kept under a name.
**Making shapes** **Making shapes**
- **Shapes that stay editable**: cube, rounded cube, plane, circle, cylinder, tube, cone, pyramid, - **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, sphere, capsule, torus, wedge, stairs, arch, and starter models (table, chair, shelf, barrel,
@ -52,126 +44,58 @@ Five tabs along the top: **Model**, **Paint**, **Layout**, **Render**, **Print**
- **Draw shape** (D): click an outline on the ground, on a side view's plane, or straight on any - **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. 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 - **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: to steps, or to points and the middles where it is; Shift scales both sides at once. Ctrl snaps.
of edges and faces.
- **Exact sizes**: type a number while dragging (move, turn, scale, extrude, inset, round edges)
and Enter for exactly that. Dimensions in Properties; measure anything (View > Measure). Drawing
a shape shows each line's length: type one for exactly that, Shift holds it to 15° steps.
- **3D text** from any font (Add > Text). **Spin**: an outline turned round an axis into a vase or
a wheel, or rising into a screw thread. **Import** STL, OBJ, 3MF, glTF and GLB (Ctrl+Shift+O).
- **Round, smooth, mirror** any object, changeable any time, baked in when you like. - **Round, smooth, mirror** any object, changeable any time, baked in when you like.
- **Materials**: gold, chrome, steel, wood, ceramic, rubber and more, or how metal and rough it
is; shown in the view (Solid too), exported with it. **Your own**: "Save its look as a
material" in the picker keeps one for every file.
**Editing the shape** (Tab, or double-click) **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), - 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), smooth points, push out / cut in four, smooth, merge (M), make a face, flip, separate (P).
pull in along the surface (Alt+S), **bridge** two faces with a tunnel, **edge slide** (Alt+G).
- **Crease** (Shift+E): picked edges stay sharp when the shape is smoothed. **Mirror editing**:
both sides change together. **Select similar** (Shift+G), loops and rings.
- **Soft selection** (Shift+O): points near what you move follow it, less the further they are;
the wheel while dragging sets how far (Blender's proportional editing, Maya's soft select).
- **Hole** (O): round (M2–M8 screw sizes ready), rectangle, triangle or hexagon (M2–M8 nut - **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. 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 - **Selecting**: click, box, brush or lasso. Shift+click a whole loop, Shift+Ctrl+click an
edge's ring, Ctrl+click adds or takes one. edge's ring, Ctrl+click adds or takes one.
**Whole objects** (the Object menu) **Whole objects** (the Object menu)
- **Add together, take one out of another, keep only where they overlap**: exact - **Add together, take one out of another, keep only where they overlap**: always closed, so
([Manifold](https://github.com/elalish/manifold)) and always closed, so always printable always printable (Ctrl+Shift+ and Ctrl+Shift−).
(Ctrl+Shift+ and Ctrl+Shift−). - **Copies** in a row or round a circle; **make it thick** (a surface gets a back, a solid goes
- **Cut out, editable**: the cutters stay objects. Move one, turn it, change its shape, and the
hole follows; they go with what they cut. Apply makes it for good.
- **Copies** in a row, round a circle, or **along a path** (turning as it turns); **make it thick** (a surface gets a back, a solid goes
hollow); join objects; join flat faces. hollow); join objects; join flat faces.
- **Layers** with folders inside folders, drag and drop, hide and lock whole groups, and a button - **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. **Show only the selected** (/ or Shift+H; again: on every row that brings the camera to it.
everything).
**Sculpting** (the Paint tab's other side: Tab)
- Raise, puff out, smooth, flatten, pinch and grab the surface (1 to 6); Ctrl goes the other way,
Shift smooths, X does the same on both sides. A shape with too few points is made finer first,
keeping its shape.
**Painting** **Painting**
- Paint on the model or on its flat picture, on **both sides** at once, or only inside a - Paint on the model or on its flat picture. **Paste any picture** (Ctrl+V): it goes on like a
**stencil** you drag round an area. **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 sticker that bends over edges and corners; Enter locks it as a layer, clicking it in the
Picture panel unlocks it again. Picture panel unlocks it again.
- **Layout tab**: move, turn and size the flat frames of the surface yourself; the paint follows. - **Layout tab**: move, turn and size the flat frames of the surface yourself; the paint follows.
**Seeing it** **Seeing it**
- **Getting round**, as in games, Unreal or Unity: right drag turns the camera where it stands,
and while it's held W A S D move where you look (Q / E: down, up). The **arrow keys** move any
time (Page Up / Down: down and up), the **wheel** goes forward and back, **Shift + wheel** sets
how fast it all goes. Alt + right drag orbits round what's picked; middle drag pans. Rather
orbit, as in Blender? **Shift+C** (or View > Camera) switches: right drag turns round what you
look at and the wheel zooms.
- **Solid** view for modelling, **Rendered** with a real sky, sun and soft shadows (Z). Wireframe - **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; (Shift+Z), X-ray (Alt+Z). The Render tab: the sun, sky, ground, brightness, shadows, glow;
F12 saves a picture of just the view. F12 saves a picture of just the view.
- **Reference pictures** for the front, side and top views, see-through, shown only in their own
view if you like.
**3D printing** (the Print tab) **3D printing** (the Print tab)
- Your printer's plate in the view (Prusa, Bambu, Creality, Elegoo, Anycubic, or your own), - 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 **one repair button**, overhangs sizes in millimetres, checks for holes and flipped faces with buttons that fix them, overhangs
in red, **walls too thin** in yellow, weight and filament, measuring, lay flat on a face, save in red, weight and filament, measuring, lay flat on a face, save 3MF or STL.
3MF or STL.
- **Size it**: by percent, ×½, ×2, inches or centimetres to millimetres, or as big as fits the
plate. **Slice preview**: one layer as the printer makes it, at any layer height.
**Scripts** (the Script panel, F5)
- Lua with a `fry` library: add shapes and change their settings, combine them, move points by a
formula, sculpt, set the materials and the sun, make your own materials (`fry.material{}`).
One Ctrl+Z takes back all a script did; an error takes it back itself. Examples in
Script > Examples; the **?** button lists every function. It can't reach anything outside the
scene (no files, no programs).
- **Kept in the file**: the Script panel lists the `.fry`'s scripts (new, examples, open,
duplicate, rename, remove); they're saved with the model.
**Plug-ins** (Script > Plug-ins)
- Scripts that stay loaded: put a `.lua` in the plug-ins folder and it can add **commands to the
Add, Object or Script menu** (`fry.command{name, menu, settings, run}`; the settings are asked
for in a small form first) and **materials** to the picker. Turn each on or off in the Plug-ins
window, look for one, or make your own copy of one to change. **Mouse tools** too
(`fry.tool{name, click}`): listed in the Toolbox, a click in the view calls it with the spot.
Four come with it: stairs (straight and spiral), scatter copies, stamp (a mouse tool), and more
materials.
```lua
fry.plugin{name = "Shelves", about = "A bookcase from the Add menu"}
fry.command{name = "Bookcase", menu = "add", settings = {{"shelves", 5}, {"width", 0.8}},
run = function(s)
for i = 0, s.shelves - 1 do
fry.add("cube", {width = s.width, depth = 0.3, height = 0.02}).position = {0, 0, i * 0.35}
end
end}
```
**The look**: light or dark, or following the desktop (View > Appearance), in
[Niveus](https://github.com/Nexoniarz/niveus): keys that look like keys, fields set into the panel.
**Files** **Files**
- `.fry`: Fry3DX's own file, everything in one, packed small. **Autosave** in the background, - `.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. Drop a `.fry`, a script or offered back after a crash; Save / Don't save / Cancel when closing.
a picture on the window to open it.
- **Export**: GLB, glTF, FBX (Blender, Unity, Unreal, Maya), OBJ, 3MF, STL, PLY. - **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 **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 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 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. It stays still is a right-click. Files open and save through the phone's own file picker.
sideways, and going to another app and back keeps your work.
F1 in the app lists every key. F1 in the app lists every key.
## Build it yourself ## Build it yourself
Everything it uses is downloaded while building; you need CMake 3.25+, Ninja, a C++20 compiler Everything it uses is downloaded while building; you need CMake 3.25+, Ninja, a C++20 compiler
(GCC 13+, Clang 17+, Xcode 15+ or clang-cl) and glslc (from shaderc or the Vulkan SDK). On NixOS, (GCC 13+, Clang 17+ or clang-cl) and glslc (from shaderc or the Vulkan SDK). On NixOS,
`shell.nix` has it all; on a Mac, `brew install cmake ninja shaderc molten-vk`. `shell.nix` has it all.
```sh ```sh
git clone https://github.com/Nexoniarz/Fry3DX.git && cd Fry3DX git clone https://github.com/Nexoniarz/Fry3DX.git && cd Fry3DX
@ -185,10 +109,9 @@ cmake --build build/debug --target check # the tests (after
| `debug`, `release` | for working on it | | `debug`, `release` | for working on it |
| `portable` | a Linux build to hand out: SDL and the C++ library inside the program | | `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)) | | `windows` | Windows, cross-built from Linux with clang-cl ([how](cmake/toolchains/windows-msvc.cmake)) |
| `macos-arm64`, `macos-x64` | `Fry3DX.app` for Apple silicon / Intel (both build on either Mac); `tools/package.py macos` joins them into one app |
| `android` | the Android library; `android/build_apk.py` makes the APK ([how](android/build_apk.py)) | | `android` | the Android library; `android/build_apk.py` makes the APK ([how](android/build_apk.py)) |
`tools/package.py` packs builds into the downloads (zip, tar.gz, AppImage, the Mac app). Pushing a tag `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`). `v*` makes GitHub build all of them and put them in a release (`.github/workflows/build.yml`).
## What's where ## What's where
@ -199,22 +122,19 @@ cmake --build build/debug --target check # the tests (after
| `src/mesh.*`, `tools.cpp`, `cut.cpp` | polygon meshes and the edits on them (tested without a window) | | `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/boolean.cpp`, `modify.cpp` | combining shapes, thickness, joining flat faces |
| `src/shapes.*` | the shape library and starter models | | `src/shapes.*` | the shape library and starter models |
| `src/scene.*`, `package.cpp` | objects, folders, materials, the `.fry` file | | `src/scene.*`, `package.cpp` | objects, folders, the `.fry` file |
| `src/sculpt.*` | the sculpting brushes |
| `src/script.*` | scripts: Lua and the `fry` library; examples in `content/Fry3DX/scripts/` |
| `src/export.cpp`, `print.cpp` | GLB/glTF/FBX/OBJ/3MF/STL/PLY, checks for printing | | `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 | | `src/uv.cpp`, `sticker.cpp`, `image.cpp` | flat layouts, stickers, pictures |
| `engine/` | window, Vulkan/OpenGL renderer, UI, files | | `engine/` | window, Vulkan/OpenGL renderer, UI, files |
| `android/`, `tools/`, `.github/` | builds and packages | | `android/`, `tools/`, `.github/` | builds and packages |
| `docs/` | the website (GitHub Pages); its words are all in `docs/content.js` | | `docs/` | the website (GitHub Pages); its words are all in `docs/content.js` |
| `tests/` | `mesh_test`, `script_test` (every example script runs) | | `tests/` | `mesh_test` |
## Coming ## Coming
Animation; textures for materials (wood grain, brushed metal); dynamic detail while sculpting. Sculpting, materials shown in the view, scripting; animation later.
## Licence ## Licence
[Apache License 2.0](LICENSE). Copyright 2026 Nexoniarz. [Apache License 2.0](LICENSE). Copyright 2026 Nexoniarz.
Sky pictures: Poly Haven, CC0. Fonts: Instrument Sans and JetBrains Mono (SIL Open Font Sky pictures: Poly Haven, CC0. DejaVu fonts: their own free licence (`engine/fonts/LICENSE` in builds).
License), DejaVu (its own free licence). Lua: MIT. MoltenVK (in the Mac app): Apache 2.0.

View file

@ -11,7 +11,6 @@ Signed with ~/.local/share/Nexoniarz/Fry3DX-signing/release.keystore (made the f
or the key in $FRY_KEYSTORE / $FRY_KEYSTORE_PASS. Keep it safe and keep using it: Android only 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. updates an app signed with the key it was installed with.
""" """
import json
import os import os
import secrets import secrets
import shutil import shutil
@ -31,8 +30,11 @@ def run(*cmd, **kw):
def app_version(): def app_version():
with open(os.path.join(ROOT, "project.json")) as f: # (the version and the rest live there) with open(os.path.join(ROOT, "CMakeLists.txt")) as f:
return json.load(f)["version"] for line in f:
if line.startswith("project(Fry3DX VERSION "):
return line.split()[2]
return "0"
def main(): def main():
@ -42,12 +44,8 @@ def main():
llvm = os.path.join(ndk, "toolchains", "llvm", "prebuilt", "linux-x86_64", "bin") llvm = os.path.join(ndk, "toolchains", "llvm", "prebuilt", "linux-x86_64", "bin")
if not os.path.isdir(BUILD): if not os.path.isdir(BUILD):
# A current glslc: $GLSLC, or the one on PATH. (Not the NDK's: its 2022 shaderc makes compute glslc = os.environ.get("GLSLC") or os.path.join(ndk, "shader-tools", "linux-x86_64", "glslc") # (the NDK's own, if none's given)
# shaders some phone drivers can't build, e.g. the Galaxy S22's.) run("cmake", "--preset", "android", *([f"-DGLSLC={glslc}"] if os.path.exists(glslc) else []), cwd=ROOT)
glslc = os.environ.get("GLSLC") or shutil.which("glslc")
if not glslc:
sys.exit("No glslc: install shaderc (or the Vulkan SDK), or set GLSLC")
run("cmake", "--preset", "android", f"-DGLSLC={glslc}", cwd=ROOT)
run("cmake", "--build", "--preset", "android", cwd=ROOT) run("cmake", "--build", "--preset", "android", cwd=ROOT)
stage = os.path.join(BUILD, "apk") stage = os.path.join(BUILD, "apk")

View file

@ -1,9 +1,5 @@
package io.github.nexoniarz.fry3dx; package io.github.nexoniarz.fry3dx;
import android.os.Build;
import android.view.WindowInsets;
import android.view.WindowInsetsController;
import org.libsdl.app.SDLActivity; import org.libsdl.app.SDLActivity;
// Fry3DX's activity: SDL's, loading SDL and the app (libmain.so, whose SDL_main it runs). // Fry3DX's activity: SDL's, loading SDL and the app (libmain.so, whose SDL_main it runs).
@ -12,40 +8,4 @@ public class FryActivity extends SDLActivity {
protected String[] getLibraries() { protected String[] getLibraries() {
return new String[] {"SDL3", "main"}; return new String[] {"SDL3", "main"};
} }
// The whole screen: the status and navigation bars hidden (a swipe from the edge shows them
// for a moment). SDL's own way (the old system-UI flags) doesn't do it on new Androids.
@Override
protected void onResume() {
super.onResume();
hideBars();
}
@Override
public void onWindowFocusChanged(boolean hasFocus) {
super.onWindowFocusChanged(hasFocus);
if (hasFocus) hideBars();
}
// SDL sets the bars again (its old way) whenever the window changes: hidden again after it.
@Override
protected boolean sendCommand(int command, Object data) {
final boolean sent = super.sendCommand(command, data);
if (command == COMMAND_CHANGE_WINDOW_STYLE) getWindow().getDecorView().post(new Runnable() { // (no lambdas: built against android.jar alone)
@Override
public void run() {
hideBars();
}
});
return sent;
}
private void hideBars() {
if (Build.VERSION.SDK_INT < 30) return;
getWindow().setDecorFitsSystemWindows(false);
WindowInsetsController c = getWindow().getInsetsController();
if (c == null) return;
c.hide(WindowInsets.Type.systemBars());
c.setSystemBarsBehavior(WindowInsetsController.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE);
}
} }

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.1 KiB

After

Width:  |  Height:  |  Size: 5.8 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB

After

Width:  |  Height:  |  Size: 3.6 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.1 KiB

After

Width:  |  Height:  |  Size: 8 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Before After
Before After

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 37 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

After

Width:  |  Height:  |  Size: 3.1 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Before After
Before After

View file

@ -1,144 +1,34 @@
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512"> <svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512">
<!-- Fry3DX: a cube frying in a cast-iron pan, on a key-like tile (Niveus: light from above,
a lit top edge, a shaded bottom one). -->
<defs> <defs>
<!-- the tile --> <linearGradient id="bg" x1="0" y1="0" x2="0" y2="1">
<linearGradient id="tile" x1="0" y1="0" x2="0" y2="1"> <stop offset="0" stop-color="#2a2a2f"/><stop offset="1" stop-color="#151518"/>
<stop offset="0" stop-color="#3d3b38"/><stop offset="0.55" stop-color="#262523"/><stop offset="1" stop-color="#181716"/>
</linearGradient> </linearGradient>
<linearGradient id="tileEdge" x1="0" y1="0" x2="0" y2="1"> <linearGradient id="pan" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#fff" stop-opacity="0.32"/><stop offset="0.12" stop-color="#fff" stop-opacity="0.06"/> <stop offset="0" stop-color="#3b3b42"/><stop offset="1" stop-color="#1d1d21"/>
<stop offset="0.85" stop-color="#000" stop-opacity="0"/><stop offset="1" stop-color="#000" stop-opacity="0.55"/>
</linearGradient> </linearGradient>
<radialGradient id="heat" cx="0.5" cy="0.62" r="0.5">
<stop offset="0" stop-color="#ff7a2a" stop-opacity="0.55"/><stop offset="0.6" stop-color="#ff5a1a" stop-opacity="0.12"/>
<stop offset="1" stop-color="#ff5a1a" stop-opacity="0"/>
</radialGradient>
<filter id="soft" x="-50%" y="-50%" width="200%" height="200%"><feGaussianBlur stdDeviation="6"/></filter>
<filter id="softer" x="-50%" y="-50%" width="200%" height="200%"><feGaussianBlur stdDeviation="12"/></filter>
<filter id="wisp" x="-50%" y="-50%" width="200%" height="200%"><feGaussianBlur stdDeviation="3.5"/></filter>
<!-- flames -->
<linearGradient id="flame" x1="0" y1="1" x2="0" y2="0"> <linearGradient id="flame" x1="0" y1="1" x2="0" y2="0">
<stop offset="0" stop-color="#ff3a0a" stop-opacity="0.2"/><stop offset="0.3" stop-color="#ff4d12"/><stop offset="0.7" stop-color="#ffa63a"/> <stop offset="0" stop-color="#ff4a1c"/><stop offset="1" stop-color="#ffc24a"/>
<stop offset="1" stop-color="#ffe7a6" stop-opacity="0.9"/>
</linearGradient> </linearGradient>
<!-- the pan: cast iron -->
<linearGradient id="panWall" x1="0" y1="0" x2="1" y2="0">
<stop offset="0" stop-color="#121212"/><stop offset="0.3" stop-color="#3a3a3d"/><stop offset="0.48" stop-color="#5a5a5e"/>
<stop offset="0.62" stop-color="#2c2c2f"/><stop offset="1" stop-color="#0e0e0f"/>
</linearGradient>
<linearGradient id="rim" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#8b8b90"/><stop offset="0.35" stop-color="#4a4a4f"/><stop offset="1" stop-color="#1c1c1e"/>
</linearGradient>
<radialGradient id="panInside" cx="0.5" cy="0.35" r="0.7">
<stop offset="0" stop-color="#3b3430"/><stop offset="0.55" stop-color="#211e1c"/><stop offset="1" stop-color="#0d0c0c"/>
</radialGradient>
<linearGradient id="oil" x1="0" y1="0" x2="1" y2="0">
<stop offset="0" stop-color="#ffcf7a" stop-opacity="0"/><stop offset="0.5" stop-color="#ffd890" stop-opacity="0.35"/>
<stop offset="1" stop-color="#ffcf7a" stop-opacity="0"/>
</linearGradient>
<!-- the handle: enamel grip on a steel neck -->
<linearGradient id="neck" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#b9b9be"/><stop offset="0.45" stop-color="#5b5b60"/><stop offset="1" stop-color="#232326"/>
</linearGradient>
<linearGradient id="grip" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#ffb070"/><stop offset="0.3" stop-color="#ff7a2a"/><stop offset="0.75" stop-color="#d4501a"/>
<stop offset="1" stop-color="#8f2f0c"/>
</linearGradient>
<!-- the cube: browning at the bottom, glowing hot -->
<linearGradient id="top" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#fff1d6"/><stop offset="1" stop-color="#ffc27e"/>
</linearGradient>
<linearGradient id="left" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#ffa055"/><stop offset="0.62" stop-color="#ff7c2c"/><stop offset="0.9" stop-color="#b8461a"/>
<stop offset="1" stop-color="#6b2a10"/>
</linearGradient>
<linearGradient id="right" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#e2672a"/><stop offset="0.62" stop-color="#c24c18"/><stop offset="0.9" stop-color="#7d3011"/>
<stop offset="1" stop-color="#45190a"/>
</linearGradient>
<radialGradient id="bubble" cx="0.35" cy="0.3" r="0.7">
<stop offset="0" stop-color="#ffffff"/><stop offset="0.3" stop-color="#ffe9c4" stop-opacity="0.85"/>
<stop offset="0.8" stop-color="#ffb35a" stop-opacity="0.25"/><stop offset="1" stop-color="#ffb35a" stop-opacity="0.6"/>
</radialGradient>
<clipPath id="tileClip"><rect x="24" y="24" width="464" height="464" rx="104"/></clipPath>
</defs> </defs>
<rect x="16" y="16" width="480" height="480" rx="96" fill="url(#bg)"/>
<!-- the tile -->
<rect x="28" y="36" width="456" height="460" rx="104" fill="#000" opacity="0.45" filter="url(#softer)"/>
<rect x="24" y="24" width="464" height="464" rx="104" fill="url(#tile)"/>
<g clip-path="url(#tileClip)">
<ellipse cx="248" cy="380" rx="230" ry="150" fill="url(#heat)"/>
<!-- flames under the pan --> <!-- flames under the pan -->
<g opacity="0.95"> <path d="M164 462 c -34 -14 -30 -60 0 -96 c 30 36 34 82 0 96 z" fill="url(#flame)"/>
<path d="M150 476 C 130 476, 126 444, 144 426 C 148 438, 154 427, 151 406 C 176 433, 172 476, 150 476 z" fill="url(#flame)"/> <path d="M236 462 c -34 -14 -30 -60 0 -96 c 30 36 34 82 0 96 z" fill="url(#flame)"/>
<path d="M150 472 C 140 472, 140 448, 151 433 C 161 448, 160 472, 150 472 z" fill="#ffe9b0" opacity="0.85"/> <path d="M308 462 c -34 -14 -30 -60 0 -96 c 30 36 34 82 0 96 z" fill="url(#flame)"/>
<path d="M214 476 C 190 476, 186 435, 207 410 C 212 425, 219 412, 215 384 C 244 419, 240 476, 214 476 z" fill="url(#flame)"/>
<path d="M214 472 C 202 472, 202 439, 215 419 C 227 439, 226 472, 214 472 z" fill="#ffe9b0" opacity="0.85"/>
<path d="M278 476 C 254 476, 250 435, 271 410 C 276 425, 283 412, 279 384 C 308 419, 304 476, 278 476 z" fill="url(#flame)"/>
<path d="M278 472 C 266 472, 266 439, 279 419 C 291 439, 290 472, 278 472 z" fill="#ffe9b0" opacity="0.85"/>
<path d="M342 476 C 322 476, 318 444, 336 426 C 340 438, 346 427, 343 406 C 368 433, 364 476, 342 476 z" fill="url(#flame)"/>
<path d="M342 472 C 332 472, 332 448, 343 433 C 353 448, 352 472, 342 472 z" fill="#ffe9b0" opacity="0.85"/>
</g>
</g>
<!-- the pan's shadow-side wall, then its rim, then the cooking surface -->
<path d="M60 312 a 182 80 0 0 0 364 0 l -6 30 a 176 76 0 0 1 -352 0 z" fill="#000" opacity="0.35" filter="url(#soft)"/>
<path d="M62 314 L 72 346 A 170 74 0 0 0 412 346 L 422 314 Z" fill="url(#panWall)"/>
<ellipse cx="242" cy="346" rx="170" ry="74" fill="url(#panWall)"/>
<!-- handle --> <!-- handle -->
<g transform="translate(392 302) rotate(-31)"> <path d="M372 300 L470 250" stroke="#ff7828" stroke-width="30" stroke-linecap="round"/>
<rect x="-6" y="-13" width="48" height="26" rx="6" fill="url(#neck)"/> <!-- the pan, seen a little from above -->
<circle cx="10" cy="0" r="4" fill="#d9d9de"/><circle cx="10" cy="0" r="4" fill="none" stroke="#1a1a1c" stroke-width="1.5"/> <ellipse cx="236" cy="318" rx="170" ry="72" fill="#ff7828"/>
<rect x="34" y="-17" width="66" height="34" rx="17" fill="url(#grip)"/> <ellipse cx="236" cy="306" rx="150" ry="58" fill="url(#pan)"/>
<rect x="40" y="-13" width="52" height="7" rx="3.5" fill="#fff" opacity="0.38"/> <!-- the cube frying in it -->
<rect x="34" y="-17" width="66" height="34" rx="17" fill="none" stroke="#5a1d07" stroke-opacity="0.6" stroke-width="2"/> <g stroke="#1a1a1d" stroke-width="8" stroke-linejoin="round">
<circle cx="85" cy="0" r="6" fill="#3b1406"/><circle cx="85" cy="1.5" r="5" fill="#1a0a04"/> <path d="M236 92 L330 140 L236 188 L142 140 Z" fill="#ffd2a0"/>
<path d="M142 140 L236 188 L236 300 L142 252 Z" fill="#ff8a3a"/>
<path d="M330 140 L236 188 L236 300 L330 252 Z" fill="#c9501a"/>
</g> </g>
<!-- sizzle -->
<ellipse cx="242" cy="314" rx="180" ry="78" fill="url(#rim)"/> <g stroke="#ffb070" stroke-width="10" stroke-linecap="round" fill="none" opacity="0.9">
<ellipse cx="242" cy="314" rx="180" ry="78" fill="none" stroke="#fff" stroke-opacity="0.22" stroke-width="2"/> <path d="M100 210 q-14 -18 0 -36 q14 -18 0 -36"/>
<ellipse cx="242" cy="318" rx="160" ry="64" fill="url(#panInside)"/> <path d="M378 200 q-14 -18 0 -36 q14 -18 0 -36"/>
<ellipse cx="242" cy="318" rx="160" ry="64" fill="none" stroke="#000" stroke-opacity="0.6" stroke-width="3"/>
<!-- oil sheen, and the cube's warm glow and shadow on the oil -->
<ellipse cx="242" cy="330" rx="118" ry="38" fill="#ff8a3a" opacity="0.28" filter="url(#softer)"/>
<ellipse cx="248" cy="330" rx="96" ry="26" fill="#000" opacity="0.55" filter="url(#soft)"/>
<path d="M110 300 q 120 -40 250 -10" stroke="url(#oil)" stroke-width="6" fill="none" stroke-linecap="round"/>
<!-- the cube -->
<g stroke-linejoin="round">
<path d="M152 186 L242 234 L242 334 L152 286 Z" fill="url(#left)"/>
<path d="M332 186 L242 234 L242 334 L332 286 Z" fill="url(#right)"/>
<path d="M242 138 L332 186 L242 234 L152 186 Z" fill="url(#top)"/>
<!-- lit edges, shaded edges -->
<path d="M152 186 L242 138 L332 186" fill="none" stroke="#fff" stroke-opacity="0.75" stroke-width="3" stroke-linecap="round"/>
<path d="M152 186 L242 234 L332 186" fill="none" stroke="#fff" stroke-opacity="0.45" stroke-width="2.5" stroke-linecap="round"/>
<path d="M242 234 L242 334" fill="none" stroke="#ffd2a0" stroke-opacity="0.55" stroke-width="2.5"/>
<path d="M152 186 L242 138 L332 186 L332 286 L242 334 L152 286 Z" fill="none" stroke="#3a1606" stroke-opacity="0.7" stroke-width="3"/>
</g> </g>
<!-- sizzle where it touches -->
<g>
<circle cx="150" cy="302" r="7" fill="url(#bubble)"/>
<circle cx="172" cy="318" r="5" fill="url(#bubble)"/>
<circle cx="134" cy="320" r="4" fill="url(#bubble)"/>
<circle cx="214" cy="346" r="6" fill="url(#bubble)"/>
<circle cx="276" cy="348" r="5" fill="url(#bubble)"/>
<circle cx="336" cy="302" r="7" fill="url(#bubble)"/>
<circle cx="352" cy="320" r="4.5" fill="url(#bubble)"/>
<circle cx="312" cy="326" r="3.5" fill="url(#bubble)"/>
</g>
<!-- steam -->
<g fill="none" stroke="#fff" stroke-linecap="round" filter="url(#wisp)">
<path d="M196 120 q -18 -22 0 -44 q 18 -22 0 -44" stroke-width="9" stroke-opacity="0.32"/>
<path d="M286 112 q -16 -20 0 -40 q 16 -20 0 -40" stroke-width="8" stroke-opacity="0.26"/>
</g>
<!-- the tile's bevel on top of everything -->
<rect x="25" y="25" width="462" height="462" rx="103" fill="none" stroke="url(#tileEdge)" stroke-width="2.5"/>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 9.1 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

Before After
Before After

View file

@ -37,10 +37,6 @@ FetchContent_Declare(opusfile GIT_REPOSITORY https://github.com/xiph/opusfile.gi
FetchContent_Declare(dejavu URL https://github.com/dejavu-fonts/dejavu-fonts/releases/download/version_2_37/dejavu-fonts-ttf-2.37.zip FetchContent_Declare(dejavu URL https://github.com/dejavu-fonts/dejavu-fonts/releases/download/version_2_37/dejavu-fonts-ttf-2.37.zip
URL_HASH SHA256=7576310b219e04159d35ff61dd4a4ec4cdba4f35c00e002a136f00e96a908b0a URL_HASH SHA256=7576310b219e04159d35ff61dd4a4ec4cdba4f35c00e002a136f00e96a908b0a
DOWNLOAD_EXTRACT_TIMESTAMP ON SOURCE_SUBDIR no-cmake) DOWNLOAD_EXTRACT_TIMESTAMP ON SOURCE_SUBDIR no-cmake)
# Lua: the scripting language (Script panel, scripts/*.lua). Small, plain C, nothing else needed.
FetchContent_Declare(lua URL https://www.lua.org/ftp/lua-5.4.7.tar.gz
URL_HASH SHA256=9fbf5e28ef86c69858f6d3d34eccc32e911c1a28b4120ff3e84aaa70cfbf1e30
DOWNLOAD_EXTRACT_TIMESTAMP ON SOURCE_SUBDIR no-cmake)
# KEEL_YAPF_DIR=/path/to/YAPF uses a local checkout instead. # KEEL_YAPF_DIR=/path/to/YAPF uses a local checkout instead.
FetchContent_Declare(yapf GIT_REPOSITORY https://github.com/Nexoniarz/yapf.git FetchContent_Declare(yapf GIT_REPOSITORY https://github.com/Nexoniarz/yapf.git
GIT_TAG 06a4e7db9e7e6d90132473e5606f0e6f5b1d6001 SYSTEM) # YAPF 2 (+ yapf_tex_to_rgba8: any surface as RGBA8) GIT_TAG 06a4e7db9e7e6d90132473e5606f0e6f5b1d6001 SYSTEM) # YAPF 2 (+ yapf_tex_to_rgba8: any surface as RGBA8)
@ -83,26 +79,13 @@ target_link_libraries(volk PUBLIC Vulkan::Headers)
# Dear ImGui and YAPF have no CMake of their own; build them here. # Dear ImGui and YAPF have no CMake of their own; build them here.
FetchContent_MakeAvailable(imgui yapf) FetchContent_MakeAvailable(imgui yapf)
# One hook in ImGui: RenderFrame (the frame behind buttons, fields, checkboxes, combos) asks the
# engine's UI first, which draws Niveus's keys and wells instead (engine/ui/ui.cpp).
file(READ ${imgui_SOURCE_DIR}/imgui.cpp _imgui_cpp)
if(NOT _imgui_cpp MATCHES "GImRenderFrameHook")
set(_from "void ImGui::RenderFrame(ImVec2 p_min, ImVec2 p_max, ImU32 fill_col, bool borders, float rounding)\n{\n ImGuiContext& g = *GImGui;\n ImGuiWindow* window = g.CurrentWindow;\n")
string(FIND "${_imgui_cpp}" "${_from}" _at)
if(_at EQUAL -1)
message(FATAL_ERROR "ImGui's RenderFrame has changed: update the hook in cmake/Dependencies.cmake")
endif()
string(REPLACE "${_from}" "bool (*GImRenderFrameHook)(ImDrawList*, ImVec2, ImVec2, ImU32, bool, float) = nullptr;\n${_from} if (GImRenderFrameHook && GImRenderFrameHook(window->DrawList, p_min, p_max, fill_col, borders, rounding))\n return;\n" _imgui_cpp "${_imgui_cpp}")
file(WRITE ${imgui_SOURCE_DIR}/imgui.cpp "${_imgui_cpp}")
endif()
add_library(imgui STATIC add_library(imgui STATIC
${imgui_SOURCE_DIR}/imgui.cpp ${imgui_SOURCE_DIR}/imgui_draw.cpp ${imgui_SOURCE_DIR}/imgui.cpp ${imgui_SOURCE_DIR}/imgui_draw.cpp
${imgui_SOURCE_DIR}/imgui_tables.cpp ${imgui_SOURCE_DIR}/imgui_widgets.cpp ${imgui_SOURCE_DIR}/imgui_tables.cpp ${imgui_SOURCE_DIR}/imgui_widgets.cpp
${imgui_SOURCE_DIR}/imgui_demo.cpp ${imgui_SOURCE_DIR}/imgui_demo.cpp
${imgui_SOURCE_DIR}/backends/imgui_impl_sdl3.cpp ${imgui_SOURCE_DIR}/backends/imgui_impl_sdl3.cpp
${imgui_SOURCE_DIR}/backends/imgui_impl_vulkan.cpp ${imgui_SOURCE_DIR}/backends/imgui_impl_vulkan.cpp
${imgui_SOURCE_DIR}/backends/imgui_impl_opengl3.cpp ${imgui_SOURCE_DIR}/backends/imgui_impl_opengl3.cpp)
${imgui_SOURCE_DIR}/backends/imgui_impl_sdlrenderer3.cpp) # (the crash screen)
target_include_directories(imgui SYSTEM PUBLIC ${imgui_SOURCE_DIR} ${imgui_SOURCE_DIR}/backends) target_include_directories(imgui SYSTEM PUBLIC ${imgui_SOURCE_DIR} ${imgui_SOURCE_DIR}/backends)
target_compile_definitions(imgui PUBLIC IMGUI_IMPL_VULKAN_USE_VOLK) target_compile_definitions(imgui PUBLIC IMGUI_IMPL_VULKAN_USE_VOLK)
target_link_libraries(imgui PUBLIC volk SDL3::SDL3) target_link_libraries(imgui PUBLIC volk SDL3::SDL3)
@ -118,24 +101,6 @@ if(NOT MSVC)
target_compile_options(yapf PRIVATE -O2) # its codecs and the BC7 encoder, even in debug builds target_compile_options(yapf PRIVATE -O2) # its codecs and the BC7 encoder, even in debug builds
endif() endif()
# Lua's core and the libraries scripts get (no io, os or loading modules: a script changes the
# scene, not the computer).
FetchContent_MakeAvailable(lua)
set(_lua_src lapi lcode lctype ldebug ldo ldump lfunc lgc llex lmem lobject lopcodes lparser lstate lstring ltable ltm lundump lvm lzio
lauxlib lbaselib lcorolib ldblib lmathlib lstrlib ltablib lutf8lib)
list(TRANSFORM _lua_src PREPEND ${lua_SOURCE_DIR}/src/)
list(TRANSFORM _lua_src APPEND .c)
# As C++: its errors are then exceptions, which unwind through the bindings' C++ properly.
set_source_files_properties(${_lua_src} PROPERTIES LANGUAGE CXX)
add_library(lua STATIC ${_lua_src})
if(NOT MSVC)
target_compile_options(lua PRIVATE -w)
endif()
target_include_directories(lua SYSTEM PUBLIC ${lua_SOURCE_DIR}/src)
if(NOT WIN32)
target_link_libraries(lua PRIVATE m)
endif()
# Audio: miniaudio for output; Ogg Opus decoding (opusfile). # Audio: miniaudio for output; Ogg Opus decoding (opusfile).
set(OPUS_BUILD_TESTING OFF) set(OPUS_BUILD_TESTING OFF)
set(OPUS_BUILD_PROGRAMS OFF) set(OPUS_BUILD_PROGRAMS OFF)

View file

@ -1,42 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<!-- Fry3DX.app's Info.plist (filled in by CMake: src/CMakeLists.txt). -->
<plist version="1.0">
<dict>
<key>CFBundleName</key><string>Fry3DX</string>
<key>CFBundleDisplayName</key><string>Fry3DX</string>
<key>CFBundleExecutable</key><string>Fry3DX</string>
<key>CFBundleIdentifier</key><string>io.github.nexoniarz.fry3dx</string>
<key>CFBundleIconFile</key><string>fry3dx</string>
<key>CFBundlePackageType</key><string>APPL</string>
<key>CFBundleInfoDictionaryVersion</key><string>6.0</string>
<key>CFBundleShortVersionString</key><string>${PROJECT_VERSION}</string>
<key>CFBundleVersion</key><string>${PROJECT_VERSION}</string>
<key>NSHumanReadableCopyright</key><string>Copyright 2026 Nexoniarz. Apache License 2.0.</string>
<key>LSMinimumSystemVersion</key><string>${CMAKE_OSX_DEPLOYMENT_TARGET}</string>
<key>LSApplicationCategoryType</key><string>public.app-category.graphics-design</string>
<key>NSHighResolutionCapable</key><true/>
<key>NSSupportsAutomaticGraphicsSwitching</key><true/>
<!-- .fry files open in it from the Finder (they arrive as a dropped file). -->
<key>UTExportedTypeDeclarations</key>
<array>
<dict>
<key>UTTypeIdentifier</key><string>io.github.nexoniarz.fry3dx.fry</string>
<key>UTTypeDescription</key><string>Fry3DX model</string>
<key>UTTypeConformsTo</key><array><string>public.data</string></array>
<key>UTTypeIconFile</key><string>fry3dx</string>
<key>UTTypeTagSpecification</key>
<dict><key>public.filename-extension</key><array><string>fry</string></array></dict>
</dict>
</array>
<key>CFBundleDocumentTypes</key>
<array>
<dict>
<key>CFBundleTypeName</key><string>Fry3DX model</string>
<key>CFBundleTypeRole</key><string>Editor</string>
<key>LSHandlerRank</key><string>Owner</string>
<key>LSItemContentTypes</key><array><string>io.github.nexoniarz.fry3dx.fry</string></array>
</dict>
</array>
</dict>
</plist>

View file

@ -1,81 +0,0 @@
# project.json, read before project(): FRY_VERSION and the rest as FRY_<KEY>; fry_project_header()
# writes them as C++ constants (fry_project.h) and Windows' file details (fry3dx.rc).
set(FRY_PROJECT_JSON ${CMAKE_CURRENT_LIST_DIR}/../project.json)
set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS ${FRY_PROJECT_JSON})
file(READ ${FRY_PROJECT_JSON} FRY_JSON)
foreach(key name long version tagline description website repo issues releases license copyright company look)
string(TOUPPER ${key} upper)
string(JSON FRY_${upper} GET "${FRY_JSON}" ${key})
endforeach()
# out: the header's path; rc: Windows' file details (the version as 0,8,1,0 too).
function(fry_project_header out rc)
set(h "// Made by CMake from project.json: change that, not this.\n#pragma once\n#include <span>\n\nnamespace fry::project {\n")
foreach(key name long version tagline description website repo issues releases license copyright company look)
string(TOUPPER ${key} upper)
set(ident ${key})
if(key STREQUAL "long")
set(ident long_) # (a C++ word)
endif()
string(APPEND h "inline constexpr const char* ${ident} = R\"fry(${FRY_${upper}})fry\";\n")
endforeach()
string(APPEND h "\nstruct Creator {\n const char* name;\n const char* role;\n const char* url;\n};\ninline constexpr Creator kCreators[] = {\n")
string(JSON n LENGTH "${FRY_JSON}" creators)
math(EXPR last "${n} - 1")
foreach(i RANGE ${last})
string(JSON who GET "${FRY_JSON}" creators ${i} name)
string(JSON role ERROR_VARIABLE none GET "${FRY_JSON}" creators ${i} role)
string(JSON url ERROR_VARIABLE none GET "${FRY_JSON}" creators ${i} url)
if(role MATCHES "NOTFOUND$")
set(role "")
endif()
if(url MATCHES "NOTFOUND$")
set(url "")
endif()
string(APPEND h " {R\"fry(${who})fry\", R\"fry(${role})fry\", R\"fry(${url})fry\"},\n")
endforeach()
string(APPEND h "};\ninline constexpr std::span<const Creator> creators{kCreators};\n\n// What it's built on (for the About box).\ninline constexpr const char* builtWith = R\"fry(")
string(JSON n LENGTH "${FRY_JSON}" builtWith)
math(EXPR last "${n} - 1")
foreach(i RANGE ${last})
string(JSON part GET "${FRY_JSON}" builtWith ${i})
if(i GREATER 0)
if(i EQUAL last)
string(APPEND h " and ")
else()
string(APPEND h ", ")
endif()
endif()
string(APPEND h "${part}")
endforeach()
string(APPEND h ")fry\";\n} // namespace fry::project\n")
file(CONFIGURE OUTPUT ${out} CONTENT "${h}" @ONLY)
string(REPLACE "." "," commas "${FRY_VERSION}")
file(CONFIGURE OUTPUT ${rc} CONTENT "// Made by CMake from project.json (Windows: the program's icon and details, Explorer's Properties).
1 ICON \"${PROJECT_SOURCE_DIR}/branding/fry3dx.ico\"
1 VERSIONINFO
FILEVERSION ${commas},0
PRODUCTVERSION ${commas},0
BEGIN
BLOCK \"StringFileInfo\"
BEGIN
BLOCK \"040904b0\"
BEGIN
VALUE \"CompanyName\", \"${FRY_COMPANY}\"
VALUE \"FileDescription\", \"${FRY_NAME} - ${FRY_LONG}\"
VALUE \"FileVersion\", \"${FRY_VERSION}\"
VALUE \"InternalName\", \"fry3dx\"
VALUE \"LegalCopyright\", \"${FRY_COPYRIGHT}, ${FRY_LICENSE}\"
VALUE \"OriginalFilename\", \"fry3dx.exe\"
VALUE \"ProductName\", \"${FRY_NAME}\"
VALUE \"ProductVersion\", \"${FRY_VERSION}\"
END
END
BLOCK \"VarFileInfo\"
BEGIN
VALUE \"Translation\", 0x409, 1200
END
END
" @ONLY)
endfunction()

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

After

Width:  |  Height:  |  Size: 3.1 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Before After
Before After

View file

@ -1,13 +0,0 @@
-- More materials for the picker (Properties > Material), and for scripts: o.material = "marble".
-- fry.material works in any script; in a plug-in they're there every time Fry3DX starts.
fry.plugin{name = "More materials", about = "Marble, rose gold, titanium, carbon, jade, walnut and others."}
local list = {
{"Marble", "#e8e4dc", 0, 0.15}, {"Black marble", "#1d1d20", 0, 0.12}, {"Rose gold", "#f2b6a0", 1, 0.2},
{"Silver", "#f2f2f0", 1, 0.12}, {"Titanium", "#a8a6a0", 1, 0.35}, {"Carbon", "#202224", 0, 0.25},
{"Jade", "#4f9e74", 0, 0.2}, {"Terracotta", "#c0603c", 0, 0.9}, {"Walnut", "#5b3a26", 0, 0.6},
{"Oak", "#c49a64", 0, 0.65}, {"Red car paint", "#b0101a", 0.3, 0.1}, {"Teal plastic", "#1f9a96", 0, 0.35},
}
for _, m in ipairs(list) do
fry.material{name = m[1], color = m[2], metal = m[3], rough = m[4]}
end

View file

@ -1,27 +0,0 @@
-- Scatter: copies of what's picked, strewn round it on the ground, each turned and sized a bit
-- differently (rocks, trees, crates). In the Object menu.
fry.plugin{name = "Scatter", about = "Copies of the picked object strewn round it, each a little different."}
fry.command{
name = "Scatter copies", menu = "object",
help = "Copies of the last picked object round it on the ground.",
settings = {{"copies", 12}, {"radius", 2}, {"size_change", 0.3}, {"seed", 1}},
run = function(s)
local picked = fry.selected()
local o = picked[#picked]
if not o then error("pick an object to scatter first") end
math.randomseed(math.floor(s.seed))
local base = o.position
local made = {}
for i = 1, math.max(1, math.floor(s.copies)) do
local c = o:copy()
local a, r = math.random() * 2 * math.pi, math.sqrt(math.random()) * s.radius
c.position = {base.x + math.cos(a) * r, base.y + math.sin(a) * r, base.z}
c:rotate(0, 0, math.random() * 360)
c:scale(1 + (math.random() * 2 - 1) * s.size_change)
made[#made + 1] = c
end
fry.group(o.name .. " scattered", made)
fry.select(made)
end,
}

View file

@ -1,40 +0,0 @@
-- Stairs, straight or round a post: two commands in the Add menu, each asking for its sizes.
-- A plug-in is a script that stays loaded: fry.command puts a command in a menu, and its run
-- function is called (with the settings asked for) each time it's picked.
fry.plugin{name = "Stairs", about = "Straight stairs and spiral stairs, from the Add menu."}
fry.command{
name = "Stairs", menu = "add",
help = "A flight of steps going up along Y.",
settings = {{"steps", 12}, {"rise", 0.18}, {"going", 0.28}, {"width", 0.9}},
run = function(s)
local made = {}
for i = 0, math.max(1, math.floor(s.steps)) - 1 do
local step = fry.add("cube", {width = s.width, depth = s.going, height = s.rise * (i + 1), color = "#b07a4a", name = "Step"})
step.position = {0, i * s.going, 0}
step:drop() -- (its bottom on the ground)
made[#made + 1] = step
end
fry.group("Stairs", made)
fry.select(made)
end,
}
fry.command{
name = "Spiral stairs", menu = "add",
help = "Steps turning round a post.",
settings = {{"steps", 18}, {"rise", 0.18}, {"turn", 22}, {"width", 0.9}},
run = function(s)
local steps = math.max(1, math.floor(s.steps))
local made = {fry.add("pillar", {radius = 0.12, height = steps * s.rise + 0.3, color = "#8a8a90", name = "Post"})}
for i = 0, steps - 1 do
local step = fry.add("cube", {width = s.width, depth = 0.32, height = 0.05, color = "#b07a4a", name = "Step"})
local a = math.rad(i * s.turn)
step.turn = {0, 0, i * s.turn}
step.position = {math.cos(a) * s.width / 2, math.sin(a) * s.width / 2, (i + 1) * s.rise}
made[#made + 1] = step
end
fry.group("Spiral stairs", made)
fry.select(made)
end,
}

View file

@ -1,22 +0,0 @@
-- Stamp: a mouse tool. Click on anything (or the ground) and a copy of the picked object is put
-- there, standing on the surface. Shows how a plug-in adds a tool to the Toolbox (fry.tool).
fry.plugin{name = "Stamp", about = "A tool: click to put copies of the picked object on what you click, standing on it."}
fry.tool{
name = "Stamp copies",
help = "Click to put a copy of the picked object there, standing on the surface (pick one first).",
click = function(at, normal, onto)
local picked = fry.selected()
local o = picked[#picked]
if not o or (onto and o.id == onto.id) then
-- nothing picked to copy (or clicking on it): a little ball, to show where
o = fry.add("sphere", {radius = 0.1})
o.position = at + normal * 0.1
return
end
local c = o:copy()
local lo, hi = o:bounds()
c.position = at + normal * ((hi.z - lo.z) / 2)
o:select()
end,
}

View file

@ -1,16 +0,0 @@
-- A box to print, with a lid and holes for M3 screws in its corners.
local w, d, h, wall = 0.08, 0.06, 0.04, 0.002
local box = fry.add("rounded_cube", {width = w, depth = d, height = h, name = "Box", color = "#d9d4c7"})
local inside = fry.add("cube", {width = w - 2 * wall, depth = d - 2 * wall, height = h})
inside.position = {0, 0, wall}
box:subtract(inside)
local lid = fry.add("cube", {width = w, depth = d, height = wall, name = "Lid", color = "#ff7828"})
lid.position = {w + 0.02, 0, 0}
for sx = -1, 1, 2 do
for sy = -1, 1, 2 do
local hole = fry.add("cylinder", {radius = 0.0016, height = 0.01, sides = 24})
hole.position = {w + 0.02 + sx * (w / 2 - 0.006), sy * (d / 2 - 0.006), -0.002}
lid:subtract(hole)
end
end
fry.frame()

View file

@ -1,14 +0,0 @@
-- A gear for 3D printing, with a hole for an M5 screw.
local teeth, radius, depth, thick = 16, 0.03, 0.004, 0.006 -- metres
local outline = {}
for i = 0, teeth * 4 - 1 do
local a = i / (teeth * 4) * 2 * math.pi
local r = (i % 4 < 2) and radius + depth or radius
outline[#outline + 1] = {math.cos(a) * r, math.sin(a) * r}
end
local gear = fry.extrude(outline, thick, "Gear")
gear.color = "#e0a030"
local hole = fry.add("cylinder", {radius = 0.0027, height = 0.02, sides = 32})
hole.position = {0, 0, -0.005}
gear:subtract(hole)
fry.frame()

View file

@ -1,21 +0,0 @@
-- A little town: random houses on a grid, each a folder of its own.
math.randomseed(7)
local roofs = {"#a8432f", "#5a6470", "#8c5a3c"}
for x = -2, 2 do
for y = -2, 2 do
local w, d, h = 0.6 + math.random() * 0.3, 0.6 + math.random() * 0.3, 0.5 + math.random() * 0.9
local walls = fry.add("cube", {width = w, depth = d, height = h, color = "#e8e0d0", name = "Walls"})
walls.position = {x * 1.4, y * 1.4, 0}
-- a wedge rises towards -X: two of them back to back make a ridge
local roof = fry.add("wedge", {width = d + 0.1, length = w / 2 + 0.05, height = 0.35, name = "Roof"})
local other = roof:copy()
roof.position = {x * 1.4 + w / 4 + 0.025, y * 1.4, h}
other.position = {x * 1.4 - w / 4 - 0.025, y * 1.4, h}
other.turn = {0, 0, 180}
local c = roofs[math.random(#roofs)]
roof.color, other.color = c, c
fry.group(string.format("House %d,%d", x, y), {walls, roof, other})
end
end
fry.look{sun_turn = 60, sun_height = 35}
fry.frame()

View file

@ -1,15 +0,0 @@
-- Spiral stairs round a pillar: change the numbers and run it again (F5).
local steps, rise, turn = 18, 0.18, 22 -- how many, how high each, degrees each
local width = 0.9
fry.add("pillar", {radius = 0.12, height = steps * rise + 0.3, color = "#8a8a90", name = "Post"})
local made = {}
for i = 0, steps - 1 do
local step = fry.add("cube", {width = width, depth = 0.32, height = 0.05, color = "#b07a4a", name = "Step"})
step.turn = {0, 0, i * turn}
local a = math.rad(i * turn)
step.position = {math.cos(a) * width / 2, math.sin(a) * width / 2, (i + 1) * rise}
made[#made + 1] = step
end
fry.group("Stairs", made)
fry.frame()

View file

@ -1,15 +0,0 @@
-- A vase turned on a lathe: the profile is {out, up} points from the bottom.
local profile = {}
local height, n = 0.4, 24
for i = 0, n do
local t = i / n
-- a belly low down, a neck, a lip
local r = 0.06 + 0.07 * math.sin(t * math.pi * 1.1) + 0.02 * t * t
profile[#profile + 1] = {r, t * height}
end
table.insert(profile, 1, {0, 0})
local vase = fry.lathe(profile, 64, "Vase")
vase.color = "#3c6e9f"
vase.rough = 0.25
vase:thicken(0.004) -- walls, so it prints hollow
fry.frame()

View file

@ -1,12 +0,0 @@
-- Points moved by a formula: a plane made into waves, then given a thickness.
local p = fry.add("plane", {width = 2, depth = 2, cuts = 40, name = "Waves"})
local pts = p:points()
for i, v in ipairs(pts) do
v.z = 0.08 * math.sin(v.x * 6) * math.cos(v.y * 5)
end
p:setpoints(pts)
p:thicken(0.03)
p.smooth = true
p.color = "#2f8f9d"
p:move(0, 0, 0.12)
fry.frame()

View file

@ -1,93 +0,0 @@
Copyright 2022 The Instrument Sans Project Authors (https://github.com/Instrument/instrument-sans)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.

View file

@ -7,7 +7,7 @@ window.FRY = {
authorUrl: "https://github.com/Nexoniarz", authorUrl: "https://github.com/Nexoniarz",
repo: "https://github.com/Nexoniarz/Fry3DX", repo: "https://github.com/Nexoniarz/Fry3DX",
tagline: "A 3D modeller that does what you'd expect.", 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. On Windows, macOS, Linux and your phone.", 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", releases: "https://github.com/Nexoniarz/Fry3DX/releases/latest",
why: { why: {
@ -18,80 +18,31 @@ window.FRY = {
], ],
}, },
// The feature tabs, in order (each feature names its group).
featureGroups: [
{ id: "make", title: "Make" },
{ id: "paint", title: "Paint & sculpt" },
{ id: "print", title: "Render & print" },
{ id: "work", title: "Work" },
],
features: [ features: [
{ group: "make", icon: "cube", title: "Shapes that stay editable", text: "Cube, cylinder, sphere, torus, stairs, arch and more, plus starter models like a table, chair or barrel. Change their sizes any time in Properties." }, { icon: "▣", title: "Shapes that stay editable", text: "Cube, cylinder, sphere, torus, stairs, arch and more, plus starter models like a table, chair or barrel. Change their sizes any time in Properties." },
{ group: "make", icon: "pen", title: "Draw a shape anywhere", text: "Click an outline on the ground or straight on the face of another object: it becomes a new part, gets added to it, or cuts into it." }, { icon: "✎", title: "Draw a shape anywhere", text: "Click an outline on the ground or straight on the face of another object: it becomes a new part, gets added to it, or cuts into it." },
{ group: "make", icon: "combine", title: "Add, subtract, overlap", text: "Combine objects: add them together, take one out of another, or keep only where they overlap. Exact, and always closed, so always ready to print." }, { icon: "⊕", title: "Add, subtract, overlap", text: "Combine objects: add them together, take one out of another, or keep only where they overlap. The result is always closed and ready to print." },
{ group: "make", icon: "hole", title: "Cuts you can still move", text: "Cut one object out of another and keep the cutter: move it, turn it, change its shape, and the hole follows. It goes with what it cuts. Apply when you're done." }, { icon: "◫", title: "Real shape editing", text: "Points, edges and faces. Extrude, inset, round edges, loop cut, knife, merge. Shift+click picks a whole loop, Shift+Ctrl+click a ring." },
{ group: "make", icon: "ruler", title: "Exact sizes", text: "Type a number while you drag for exactly that: extrude, inset, round edges, move, turn, scale, a drawn line's length. Dimensions in Properties, snapping to points and the middles of edges and faces, measuring." }, { icon: "◎", title: "Holes for screws and nuts", text: "Round, rectangle, triangle or hexagon. M2 to M8 screw and nut sizes ready, with a little room added so the print actually fits." },
{ group: "make", icon: "text", title: "Text, spin, import", text: "3D text from any font. An outline spun round an axis into a vase or a wheel, or up into a screw thread. STL, OBJ, 3MF and glTF brought in, the right way up." }, { icon: "▤", title: "Copies and thickness", text: "Copies in a row or round in a circle. Make a surface thick, or a solid hollow with walls exactly as thick as you need." },
{ group: "make", icon: "points", title: "Real shape editing", text: "Points, edges and faces. Extrude, inset, round edges, loop cut, knife, bridge, push and pull, edge slide, creases that stay sharp when smoothed, both sides at once. Soft selection as in Blender and Maya: near points follow (Shift+O)." }, { icon: "✐", title: "Paint and stickers", text: "Paint on the model or on its flat picture. Paste any picture (Ctrl+V) and it goes on like a sticker that bends over edges and corners." },
{ group: "make", icon: "hole", title: "Holes for screws and nuts", text: "Round, rectangle, triangle or hexagon. M2 to M8 screw and nut sizes ready, with a little room added so the print actually fits." }, { icon: "▦", title: "Layout tab", text: "Move, turn and size the flat frames of the surface by hand. The paint follows, so the model looks the same." },
{ group: "make", icon: "copies", title: "Copies and thickness", text: "Copies in a row, round in a circle, or along a path, turning as it turns. Make a surface thick, or a solid hollow with walls exactly as thick as you need." }, { 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." },
{ group: "paint", icon: "sculpt", title: "Sculpt it like clay", text: "Raise, puff out, smooth, flatten, pinch and grab the surface with a brush, the same on both sides if you like. A shape with too few points is made finer first, keeping its shape." }, { 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." },
{ group: "print", icon: "sphere", title: "Materials you can see", text: "Gold, chrome, brushed steel, wood, ceramic, rubber and more, or set how metal and rough it is yourself; save a look as your own material for every file. Shown in the view, and exported with it." }, { 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." },
{ group: "paint", icon: "brush", title: "Paint and stickers", text: "Paint on the model or on its flat picture, on both sides at once, or only inside a stencil. Paste any picture (Ctrl+V) and it goes on like a sticker that bends over edges and corners." }, { 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." },
{ group: "paint", icon: "layout", title: "Layout tab", text: "Move, turn and size the flat frames of the surface by hand. The paint follows, so the model looks the same." }, { 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." },
{ group: "print", icon: "print", title: "Made for 3D printing", text: "Your printer's plate in the view, sizes in millimetres and a scaler (percent, inches to mm, fit the plate), checks with one repair button, overhangs in red, walls too thin in yellow, a layer-by-layer slice preview, weight and filament." },
{ group: "print", icon: "sun", 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." },
{ group: "work", icon: "sun", title: "Get round your way", text: "Fly through it as in a game: right drag looks, W A S D or the arrows move, the wheel goes forward and back, Shift + wheel sets the speed. Or orbit round what you look at, Blender style: Shift+C switches." },
{ group: "work", icon: "search", title: "Find anything", text: "F3 or Ctrl+Space finds any command by name. Hold Space for the tools round the mouse, Shift+Space for the views. Your own keys for any command, the undo history, and workspaces: the panels as you had them." },
{ group: "work", icon: "folder", 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." },
{ group: "work", icon: "phone", 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." },
{ group: "work", icon: "code", title: "Scripts, if you want them", text: "Lua in a Script panel: build a town in a loop, turn a profile into a vase, move points by a formula. Kept in the .fry with the model; F5 runs one, Ctrl+Z takes it all back." },
{ group: "work", icon: "plug", title: "Plug-ins", text: "A script in the plug-ins folder stays loaded: it adds commands to the Add, Object or Script menu (with a little form for their settings) and materials to the picker. Or a mouse tool in the Toolbox. Stairs, scatter, stamp and more materials come with it." },
{ group: "work", icon: "save", 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." },
], ],
shots: [ shots: [
{ img: "img/model.webp", title: "Model", text: "Starter models with materials (wood, glossy glass, ceramic), a folder for what's on the table, rendered with sky and shadows while you work." }, { 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/sculpt.webp", title: "Sculpt", text: "A ball pushed into a head with the brushes: a nose raised, eyes dug in, a smile pinched, ears grabbed out. Tab in the Paint tab." }, { 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/materials.webp", title: "Materials", text: "Gold, chrome, wood, rubber, copper, ceramic, steel: picked in Properties, shown in the view, exported with the model." }, { img: "img/print.webp", title: "Print", text: "The printer's plate, sizes in millimetres, checks and overhangs before you slice." },
{ img: "img/paint.webp", title: "Paint", text: "The logo pasted onto a box as a sticker: drag its corners to fit, Enter to lock it on. The Picture panel shows where it lands." }, { 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/layout.webp", title: "Layout", text: "The box's surface laid out flat: move, turn and size the frames, and the paint follows." }, { 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/render.webp", title: "Render", text: "Sun, sky and ground, brightness, soft shadows and glow, and F12 for a picture of just the view." }, { img: "img/crash.webp", title: "Even the crash screen", text: "If something does get burnt, your work comes back from the last autosave." },
{ img: "img/print.webp", title: "Print", text: "Two gears and an arch on a Prusa's plate: sizes in millimetres, checked to be closed, the arch's overhang flagged, weight and filament." },
{ img: "img/combine.webp", title: "Combine", text: "A hole taken out of a box, pegs as copies in a folder, a plane made thick: everything stays closed." },
{ img: "img/scripts.webp", title: "Scripts", text: "A little town from a dozen lines of Lua: random houses, each a folder of walls and roof. F5 runs it, Ctrl+Z takes it back." },
{ img: "img/light.webp", title: "Light", text: "The light appearance, warm paper instead of graphite. Or let it follow your desktop (View > Appearance)." },
{ img: "img/phone.webp", title: "Phone", text: "The phone layout: big buttons along the bottom, Shift and Ctrl that stay down, panels when you want them." },
{ img: "img/crash.webp", title: "Even the crash screen", text: "If something does get burnt, it says so plainly, keeps a report for you, and your work comes back from the last autosave." },
], ],
scripting: {
title: "When clicking gets repetitive, write it down.",
text: "Fry3DX has Lua built in, and a Script panel to write it in. Everything you can click, a script can do: add shapes and change their settings, combine them, move every point by a formula, set the sun. It's optional, small, and can't touch anything outside the scene.",
points: [
"F5 runs it; one Ctrl+Z takes back everything it did",
"If it stops with an error, nothing it did is kept",
"Examples to start from: stairs, a vase, a gear, a town, a box with a lid",
"The ? button lists every function, right next to your script",
"Scripts are saved in the .fry, a list of them per file",
"Plug-ins: a script in the plug-ins folder adds its own menu commands and materials",
],
file: "spiral_stairs.lua",
code: `-- Spiral stairs round a pillar: change the numbers, press F5 again.
local steps, rise, turn = 18, 0.18, 22
fry.add("pillar", {radius = 0.12, height = steps * rise + 0.3})
for i = 0, steps - 1 do
local step = fry.add("cube", {width = 0.9, depth = 0.32, height = 0.05,
color = "#b07a4a"})
local a = math.rad(i * turn)
step.turn = {0, 0, i * turn}
step.position = {math.cos(a) * 0.45, math.sin(a) * 0.45, (i + 1) * rise}
end
fry.frame()`,
},
formats: { formats: {
open: [".fry"], open: [".fry"],
save: [".fry (packed, with pictures)"], save: [".fry (packed, with pictures)"],
@ -112,7 +63,6 @@ fry.frame()`,
downloads: [ downloads: [
{ os: "Windows", what: "10 and 11, 64-bit", file: "windows-x64.zip", how: "Unpack it, run fry3dx.exe." }, { os: "Windows", what: "10 and 11, 64-bit", file: "windows-x64.zip", how: "Unpack it, run fry3dx.exe." },
{ os: "macOS", what: "13.3 and newer, Apple silicon and Intel", file: "macos.zip", how: "Unzip it, open Fry3DX.app. The first time: right-click it and choose Open." },
{ 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: "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)." }, { 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)." },
], ],
@ -125,14 +75,10 @@ fry.frame()`,
"cmake --preset release && cmake --build build/release", "cmake --preset release && cmake --build build/release",
"build/release/bin/fry3dx", "build/release/bin/fry3dx",
], ],
needs: "Needs: CMake 3.25+, Ninja, a C++20 compiler (GCC 13+, Clang 17+, Xcode 15+) and glslc. Presets for macOS, Windows (cross-built) and Android are in the README.", 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: [ versions: [
{ v: "0.8.1", date: "2026-10-21", items: ["Fixes", "Close up to big or long flat shapes the view no longer breaks into blocks, jitters or loses the sky and grid", "The grid drawn as in Blender: every size of line everywhere it's far enough apart, smooth, nothing smeared or popping as you move", "Far from the middle of the scene: steadier, and the wheel doesn't leap", "Help menu: the website, reporting a problem, the newest version; the About box lists who made it"] },
{ v: "0.8", date: "2026-10-21", items: ["Quicker to use, exact to size", "Find any command by name (F3), the tools round the mouse (Space), your own keys, the undo history, workspaces", "Typed sizes while dragging, snapping, measuring, a line's length while drawing", "Cuts you can still move; combining that's exact", "3D text, spin and screw, copies along a path, crease, edge slide, mirror editing", "Import STL, OBJ, 3MF and glTF", "Print: a scaler, thin walls, a slice preview, repair all at once", "Painting both sides and inside a stencil; plug-ins can add mouse tools", "A fly camera as in games (right drag, WASD, the wheel forward and back, Shift + wheel for speed), or the orbiting one (Shift+C)", "Smoother: dragging settings in Properties, a grid that doesn't crawl or jump, clean close-ups in Rendered", "Far away things no longer vanish; steadier and smoother on phones, which stay sideways"] },
{ v: "0.7", date: "2026-10-21", items: ["Steadier and tidier", "Cutting a shape inside an earlier cut no longer crashes, and cuts come out closed", "Edges hidden behind the surface stay hidden on big or cut-up shapes", "Every panel has a least size, so it stays readable; the first layout fits small windows", "Eye and padlock buttons in Layers; the tabs fit narrow windows", "A failed tool keeps your redo"] },
{ v: "0.6", date: "2026-10-20", items: ["Sculpting, scripts and a Mac app", "Sculpting: raise, puff out, smooth, flatten, pinch and grab", "Materials shown in the view, sixteen to start from", "Scripts: Lua and a Script panel, with examples, kept in the .fry", "Plug-ins: menu commands and materials, three to start with", "Your own materials", "Soft selection, bridge, push / pull, smooth points, show only the selected", "macOS: one app for Apple silicon and Intel", "A new look (Niveus): light and dark, following the desktop", "A new logo", "Drop a file on the window to open it"] },
{ 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.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.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.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"] },

Binary file not shown.

Binary file not shown.

View file

@ -1,93 +0,0 @@
Copyright 2022 The Instrument Sans Project Authors (https://github.com/Instrument/instrument-sans)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.

View file

@ -1,9 +1,8 @@
Copyright 2020 The JetBrains Mono Project Authors (https://github.com/JetBrains/JetBrainsMono) Copyright (c) 2016 The Inter Project Authors (https://github.com/rsms/inter)
This Font Software is licensed under the SIL Open Font License, Version 1.1. This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
This license is copied below, and is also available with a FAQ at: https://scripts.sil.org/OFL http://scripts.sil.org/OFL
----------------------------------------------------------- -----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
@ -44,7 +43,7 @@ new environment.
"Author" refers to any designer, engineer, programmer, technical "Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software. writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS PERMISSION AND CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify, a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font redistribute, and sell modified and unmodified copies of the Font

Binary file not shown.

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 104 KiB

After

Width:  |  Height:  |  Size: 52 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 33 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

After

Width:  |  Height:  |  Size: 3.1 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 90 KiB

View file

@ -1,144 +1,34 @@
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512"> <svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512">
<!-- Fry3DX: a cube frying in a cast-iron pan, on a key-like tile (Niveus: light from above,
a lit top edge, a shaded bottom one). -->
<defs> <defs>
<!-- the tile --> <linearGradient id="bg" x1="0" y1="0" x2="0" y2="1">
<linearGradient id="tile" x1="0" y1="0" x2="0" y2="1"> <stop offset="0" stop-color="#2a2a2f"/><stop offset="1" stop-color="#151518"/>
<stop offset="0" stop-color="#3d3b38"/><stop offset="0.55" stop-color="#262523"/><stop offset="1" stop-color="#181716"/>
</linearGradient> </linearGradient>
<linearGradient id="tileEdge" x1="0" y1="0" x2="0" y2="1"> <linearGradient id="pan" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#fff" stop-opacity="0.32"/><stop offset="0.12" stop-color="#fff" stop-opacity="0.06"/> <stop offset="0" stop-color="#3b3b42"/><stop offset="1" stop-color="#1d1d21"/>
<stop offset="0.85" stop-color="#000" stop-opacity="0"/><stop offset="1" stop-color="#000" stop-opacity="0.55"/>
</linearGradient> </linearGradient>
<radialGradient id="heat" cx="0.5" cy="0.62" r="0.5">
<stop offset="0" stop-color="#ff7a2a" stop-opacity="0.55"/><stop offset="0.6" stop-color="#ff5a1a" stop-opacity="0.12"/>
<stop offset="1" stop-color="#ff5a1a" stop-opacity="0"/>
</radialGradient>
<filter id="soft" x="-50%" y="-50%" width="200%" height="200%"><feGaussianBlur stdDeviation="6"/></filter>
<filter id="softer" x="-50%" y="-50%" width="200%" height="200%"><feGaussianBlur stdDeviation="12"/></filter>
<filter id="wisp" x="-50%" y="-50%" width="200%" height="200%"><feGaussianBlur stdDeviation="3.5"/></filter>
<!-- flames -->
<linearGradient id="flame" x1="0" y1="1" x2="0" y2="0"> <linearGradient id="flame" x1="0" y1="1" x2="0" y2="0">
<stop offset="0" stop-color="#ff3a0a" stop-opacity="0.2"/><stop offset="0.3" stop-color="#ff4d12"/><stop offset="0.7" stop-color="#ffa63a"/> <stop offset="0" stop-color="#ff4a1c"/><stop offset="1" stop-color="#ffc24a"/>
<stop offset="1" stop-color="#ffe7a6" stop-opacity="0.9"/>
</linearGradient> </linearGradient>
<!-- the pan: cast iron -->
<linearGradient id="panWall" x1="0" y1="0" x2="1" y2="0">
<stop offset="0" stop-color="#121212"/><stop offset="0.3" stop-color="#3a3a3d"/><stop offset="0.48" stop-color="#5a5a5e"/>
<stop offset="0.62" stop-color="#2c2c2f"/><stop offset="1" stop-color="#0e0e0f"/>
</linearGradient>
<linearGradient id="rim" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#8b8b90"/><stop offset="0.35" stop-color="#4a4a4f"/><stop offset="1" stop-color="#1c1c1e"/>
</linearGradient>
<radialGradient id="panInside" cx="0.5" cy="0.35" r="0.7">
<stop offset="0" stop-color="#3b3430"/><stop offset="0.55" stop-color="#211e1c"/><stop offset="1" stop-color="#0d0c0c"/>
</radialGradient>
<linearGradient id="oil" x1="0" y1="0" x2="1" y2="0">
<stop offset="0" stop-color="#ffcf7a" stop-opacity="0"/><stop offset="0.5" stop-color="#ffd890" stop-opacity="0.35"/>
<stop offset="1" stop-color="#ffcf7a" stop-opacity="0"/>
</linearGradient>
<!-- the handle: enamel grip on a steel neck -->
<linearGradient id="neck" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#b9b9be"/><stop offset="0.45" stop-color="#5b5b60"/><stop offset="1" stop-color="#232326"/>
</linearGradient>
<linearGradient id="grip" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#ffb070"/><stop offset="0.3" stop-color="#ff7a2a"/><stop offset="0.75" stop-color="#d4501a"/>
<stop offset="1" stop-color="#8f2f0c"/>
</linearGradient>
<!-- the cube: browning at the bottom, glowing hot -->
<linearGradient id="top" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#fff1d6"/><stop offset="1" stop-color="#ffc27e"/>
</linearGradient>
<linearGradient id="left" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#ffa055"/><stop offset="0.62" stop-color="#ff7c2c"/><stop offset="0.9" stop-color="#b8461a"/>
<stop offset="1" stop-color="#6b2a10"/>
</linearGradient>
<linearGradient id="right" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#e2672a"/><stop offset="0.62" stop-color="#c24c18"/><stop offset="0.9" stop-color="#7d3011"/>
<stop offset="1" stop-color="#45190a"/>
</linearGradient>
<radialGradient id="bubble" cx="0.35" cy="0.3" r="0.7">
<stop offset="0" stop-color="#ffffff"/><stop offset="0.3" stop-color="#ffe9c4" stop-opacity="0.85"/>
<stop offset="0.8" stop-color="#ffb35a" stop-opacity="0.25"/><stop offset="1" stop-color="#ffb35a" stop-opacity="0.6"/>
</radialGradient>
<clipPath id="tileClip"><rect x="24" y="24" width="464" height="464" rx="104"/></clipPath>
</defs> </defs>
<rect x="16" y="16" width="480" height="480" rx="96" fill="url(#bg)"/>
<!-- the tile -->
<rect x="28" y="36" width="456" height="460" rx="104" fill="#000" opacity="0.45" filter="url(#softer)"/>
<rect x="24" y="24" width="464" height="464" rx="104" fill="url(#tile)"/>
<g clip-path="url(#tileClip)">
<ellipse cx="248" cy="380" rx="230" ry="150" fill="url(#heat)"/>
<!-- flames under the pan --> <!-- flames under the pan -->
<g opacity="0.95"> <path d="M164 462 c -34 -14 -30 -60 0 -96 c 30 36 34 82 0 96 z" fill="url(#flame)"/>
<path d="M150 476 C 130 476, 126 444, 144 426 C 148 438, 154 427, 151 406 C 176 433, 172 476, 150 476 z" fill="url(#flame)"/> <path d="M236 462 c -34 -14 -30 -60 0 -96 c 30 36 34 82 0 96 z" fill="url(#flame)"/>
<path d="M150 472 C 140 472, 140 448, 151 433 C 161 448, 160 472, 150 472 z" fill="#ffe9b0" opacity="0.85"/> <path d="M308 462 c -34 -14 -30 -60 0 -96 c 30 36 34 82 0 96 z" fill="url(#flame)"/>
<path d="M214 476 C 190 476, 186 435, 207 410 C 212 425, 219 412, 215 384 C 244 419, 240 476, 214 476 z" fill="url(#flame)"/>
<path d="M214 472 C 202 472, 202 439, 215 419 C 227 439, 226 472, 214 472 z" fill="#ffe9b0" opacity="0.85"/>
<path d="M278 476 C 254 476, 250 435, 271 410 C 276 425, 283 412, 279 384 C 308 419, 304 476, 278 476 z" fill="url(#flame)"/>
<path d="M278 472 C 266 472, 266 439, 279 419 C 291 439, 290 472, 278 472 z" fill="#ffe9b0" opacity="0.85"/>
<path d="M342 476 C 322 476, 318 444, 336 426 C 340 438, 346 427, 343 406 C 368 433, 364 476, 342 476 z" fill="url(#flame)"/>
<path d="M342 472 C 332 472, 332 448, 343 433 C 353 448, 352 472, 342 472 z" fill="#ffe9b0" opacity="0.85"/>
</g>
</g>
<!-- the pan's shadow-side wall, then its rim, then the cooking surface -->
<path d="M60 312 a 182 80 0 0 0 364 0 l -6 30 a 176 76 0 0 1 -352 0 z" fill="#000" opacity="0.35" filter="url(#soft)"/>
<path d="M62 314 L 72 346 A 170 74 0 0 0 412 346 L 422 314 Z" fill="url(#panWall)"/>
<ellipse cx="242" cy="346" rx="170" ry="74" fill="url(#panWall)"/>
<!-- handle --> <!-- handle -->
<g transform="translate(392 302) rotate(-31)"> <path d="M372 300 L470 250" stroke="#ff7828" stroke-width="30" stroke-linecap="round"/>
<rect x="-6" y="-13" width="48" height="26" rx="6" fill="url(#neck)"/> <!-- the pan, seen a little from above -->
<circle cx="10" cy="0" r="4" fill="#d9d9de"/><circle cx="10" cy="0" r="4" fill="none" stroke="#1a1a1c" stroke-width="1.5"/> <ellipse cx="236" cy="318" rx="170" ry="72" fill="#ff7828"/>
<rect x="34" y="-17" width="66" height="34" rx="17" fill="url(#grip)"/> <ellipse cx="236" cy="306" rx="150" ry="58" fill="url(#pan)"/>
<rect x="40" y="-13" width="52" height="7" rx="3.5" fill="#fff" opacity="0.38"/> <!-- the cube frying in it -->
<rect x="34" y="-17" width="66" height="34" rx="17" fill="none" stroke="#5a1d07" stroke-opacity="0.6" stroke-width="2"/> <g stroke="#1a1a1d" stroke-width="8" stroke-linejoin="round">
<circle cx="85" cy="0" r="6" fill="#3b1406"/><circle cx="85" cy="1.5" r="5" fill="#1a0a04"/> <path d="M236 92 L330 140 L236 188 L142 140 Z" fill="#ffd2a0"/>
<path d="M142 140 L236 188 L236 300 L142 252 Z" fill="#ff8a3a"/>
<path d="M330 140 L236 188 L236 300 L330 252 Z" fill="#c9501a"/>
</g> </g>
<!-- sizzle -->
<ellipse cx="242" cy="314" rx="180" ry="78" fill="url(#rim)"/> <g stroke="#ffb070" stroke-width="10" stroke-linecap="round" fill="none" opacity="0.9">
<ellipse cx="242" cy="314" rx="180" ry="78" fill="none" stroke="#fff" stroke-opacity="0.22" stroke-width="2"/> <path d="M100 210 q-14 -18 0 -36 q14 -18 0 -36"/>
<ellipse cx="242" cy="318" rx="160" ry="64" fill="url(#panInside)"/> <path d="M378 200 q-14 -18 0 -36 q14 -18 0 -36"/>
<ellipse cx="242" cy="318" rx="160" ry="64" fill="none" stroke="#000" stroke-opacity="0.6" stroke-width="3"/>
<!-- oil sheen, and the cube's warm glow and shadow on the oil -->
<ellipse cx="242" cy="330" rx="118" ry="38" fill="#ff8a3a" opacity="0.28" filter="url(#softer)"/>
<ellipse cx="248" cy="330" rx="96" ry="26" fill="#000" opacity="0.55" filter="url(#soft)"/>
<path d="M110 300 q 120 -40 250 -10" stroke="url(#oil)" stroke-width="6" fill="none" stroke-linecap="round"/>
<!-- the cube -->
<g stroke-linejoin="round">
<path d="M152 186 L242 234 L242 334 L152 286 Z" fill="url(#left)"/>
<path d="M332 186 L242 234 L242 334 L332 286 Z" fill="url(#right)"/>
<path d="M242 138 L332 186 L242 234 L152 186 Z" fill="url(#top)"/>
<!-- lit edges, shaded edges -->
<path d="M152 186 L242 138 L332 186" fill="none" stroke="#fff" stroke-opacity="0.75" stroke-width="3" stroke-linecap="round"/>
<path d="M152 186 L242 234 L332 186" fill="none" stroke="#fff" stroke-opacity="0.45" stroke-width="2.5" stroke-linecap="round"/>
<path d="M242 234 L242 334" fill="none" stroke="#ffd2a0" stroke-opacity="0.55" stroke-width="2.5"/>
<path d="M152 186 L242 138 L332 186 L332 286 L242 334 L152 286 Z" fill="none" stroke="#3a1606" stroke-opacity="0.7" stroke-width="3"/>
</g> </g>
<!-- sizzle where it touches -->
<g>
<circle cx="150" cy="302" r="7" fill="url(#bubble)"/>
<circle cx="172" cy="318" r="5" fill="url(#bubble)"/>
<circle cx="134" cy="320" r="4" fill="url(#bubble)"/>
<circle cx="214" cy="346" r="6" fill="url(#bubble)"/>
<circle cx="276" cy="348" r="5" fill="url(#bubble)"/>
<circle cx="336" cy="302" r="7" fill="url(#bubble)"/>
<circle cx="352" cy="320" r="4.5" fill="url(#bubble)"/>
<circle cx="312" cy="326" r="3.5" fill="url(#bubble)"/>
</g>
<!-- steam -->
<g fill="none" stroke="#fff" stroke-linecap="round" filter="url(#wisp)">
<path d="M196 120 q -18 -22 0 -44 q 18 -22 0 -44" stroke-width="9" stroke-opacity="0.32"/>
<path d="M286 112 q -16 -20 0 -40 q 16 -20 0 -40" stroke-width="8" stroke-opacity="0.26"/>
</g>
<!-- the tile's bevel on top of everything -->
<rect x="25" y="25" width="462" height="462" rx="103" fill="none" stroke="url(#tileEdge)" stroke-width="2.5"/>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 9.1 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 66 KiB

After

Width:  |  Height:  |  Size: 49 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

After

Width:  |  Height:  |  Size: 46 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 43 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 99 KiB

After

Width:  |  Height:  |  Size: 109 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

View file

@ -9,163 +9,76 @@
<meta property="og:description" content="A 3D modeller that does what you'd expect. Pick a tool, click, drag."> <meta property="og:description" content="A 3D modeller that does what you'd expect. Pick a tool, click, drag.">
<meta property="og:image" content="img/model.webp"> <meta property="og:image" content="img/model.webp">
<link rel="icon" href="img/icon.png"> <link rel="icon" href="img/icon.png">
<link rel="stylesheet" href="style.css">
<!-- The look is Niveus (github.com/Nexoniarz/niveus), the same as the app's. site.css is only
layout: no colours, shadows or type sizes of its own. -->
<link rel="stylesheet" href="niveus/niveus.css">
<script src="niveus/niveus.js" defer></script>
<link rel="stylesheet" href="site.css">
<script>
// The saved appearance before the first paint, so the page never flashes the wrong one.
try {
const t = localStorage.getItem("fry-theme");
if (t === "light" || t === "dark") document.documentElement.setAttribute("data-theme", t);
} catch (e) {}
</script>
<style>:root { --nv-brand: #ff7828; }</style>
</head> </head>
<body> <body>
<!-- The page is built from content.js by site.js: edit content.js to update it. --> <!-- The page is built from content.js by site.js: edit content.js to update it. -->
<nav class="nv-nav" id="top-nav" aria-label="Main"> <header class="top">
<a class="nv-nav__brand" href="#top"><img class="brand-logo" src="img/logo.svg" alt=""> Fry3DX</a> <a class="brand" href="#"><img src="img/logo.svg" alt=""><span>Fry3DX</span></a>
<ul class="nv-nav__list nav-links" id="nav-list" style="--nv-selection-name: nv-nav-selection"> <nav>
<li><a class="nv-nav__item" href="#features">Features</a></li> <a href="#features">Features</a>
<li><a class="nv-nav__item" href="#look">Look</a></li> <a href="#look">Look</a>
<li><a class="nv-nav__item" href="#scripting">Scripting</a></li> <a href="#get">Get it</a>
<li><a class="nv-nav__item" href="#get">Get it</a></li> <a href="#versions">Versions</a>
<li><a class="nv-nav__item" href="#versions">Versions</a></li> <a class="gh" id="gh-top" href="#">GitHub</a>
</ul>
<span class="nv-nav__spacer"></span>
<div class="nv-button-group theme-switch" role="group" aria-label="Appearance">
<button class="nv-button nv-button--sm" data-nv-theme="light" aria-pressed="false">Light</button>
<button class="nv-button nv-button--sm" data-nv-theme="dark" aria-pressed="false">Dark</button>
<button class="nv-button nv-button--sm" data-nv-theme="system" aria-pressed="true">Auto</button>
</div>
<a class="nv-button nv-button--sm nv-button--ghost" id="gh-top" href="#">GitHub</a>
<a class="nv-button nv-button--sm nv-button--primary" href="#get">Download</a>
</nav> </nav>
<main id="top">
<header class="shell hero">
<div class="hero__copy">
<span class="nv-badge nv-badge--accent"><span class="nv-badge__dot"></span><span id="latest"></span></span>
<p class="nv-overline" id="long"></p>
<h1 class="nv-display hero__title" id="tagline"></h1>
<p class="nv-prose nv-text-secondary" id="intro"></p>
<div class="nv-cluster">
<a class="nv-button nv-button--primary nv-button--lg" href="#get">Get Fry3DX</a>
<a class="nv-button nv-button--lg" id="gh-hero" href="#">Source on GitHub</a>
</div>
<div class="nv-cluster nv-cluster--tight" id="platforms"></div>
</div>
<div class="nv-bed hero__bed">
<div class="nv-window app-window">
<div class="nv-window__titlebar">
<div class="nv-window__controls">
<span class="nv-window__control nv-window__control--close" aria-hidden="true"></span>
<span class="nv-window__control nv-window__control--minimize" aria-hidden="true"></span>
<span class="nv-window__control nv-window__control--zoom" aria-hidden="true"></span>
</div>
<span class="nv-window__title">Fry3DX — kitchen.fry</span>
</div>
<img class="app-window__shot" src="img/model.webp" alt="Fry3DX with a table, chair, barrel and crate">
</div>
</div>
</header> </header>
<section class="shell section"> <main>
<div class="nv-card nv-card--inset why"> <section class="hero">
<div class="nv-card__body nv-stack"> <div class="hero-text">
<h2 class="nv-heading" id="why-title"></h2> <p class="kicker" id="long"></p>
<div class="nv-prose nv-stack" id="why-text"></div> <h1 id="tagline"></h1>
<p class="lead" id="intro"></p>
<div class="buttons">
<a class="btn hot" href="#get">Get Fry3DX</a>
<a class="btn" id="gh-hero" href="#">Source on GitHub</a>
</div> </div>
</div> </div>
<figure class="hero-shot"><img src="img/model.webp" alt="Fry3DX with a table, chair, barrel and crate"></figure>
</section>
<section class="why" id="why"></section>
<section id="features">
<h2>What it does</h2>
<div class="grid" id="feature-grid"></div>
</section>
<section id="look">
<h2>How it looks</h2>
<div class="tabs" id="shot-tabs" role="tablist"></div>
<figure class="shot"><img id="shot-img" alt=""><figcaption id="shot-text"></figcaption></figure>
</section>
<section class="two">
<div>
<h2>Files</h2>
<div id="formats"></div>
</div>
<div>
<h2>Keys that make sense</h2>
<table class="keys" id="keys"></table>
</div>
</section> </section>
<section class="shell section" id="features"> <section id="get">
<div class="section__head"> <h2>Get it</h2>
<span class="nv-overline">What it does</span>
<h2 class="nv-heading">The useful parts of the big modellers, without the hundred menus.</h2>
</div>
<div class="nv-nav-tabs" role="tablist" aria-label="What it does" id="feature-tabs" data-nv-tabs style="--nv-selection-name: nv-feature-tabs"></div>
<div class="tab-box" id="feature-panels"></div>
</section>
<section class="shell section" id="look">
<div class="section__head">
<span class="nv-overline">How it looks</span>
<h2 class="nv-heading">What you're doing, not where it's hidden.</h2>
</div>
<div class="nv-nav-tabs" role="tablist" aria-label="Screenshots" id="shot-tabs" data-nv-tabs style="--nv-selection-name: nv-shot-tabs"></div>
<div id="shot-panels" class="tab-box shots"></div>
</section>
<section class="shell section" id="scripting" hidden>
<div class="split">
<div class="section__head">
<span class="nv-overline">Scripting</span>
<h2 class="nv-heading" id="script-title"></h2>
<p class="nv-prose nv-text-secondary" id="script-text"></p>
<ul class="ticks" id="script-points"></ul>
</div>
<div class="nv-card nv-card--elevated code-card">
<div class="nv-card__header code-card__head">
<span class="nv-overline" id="script-file"></span>
<button class="nv-button nv-button--sm nv-button--ghost" data-copy="script-code">Copy</button>
</div>
<pre class="code" id="script-code"></pre>
</div>
</div>
</section>
<section class="shell section split">
<div class="nv-card">
<div class="nv-card__header"><h2 class="nv-card__title">Files</h2></div>
<div class="nv-card__body nv-stack" id="formats"></div>
</div>
<div class="nv-card">
<div class="nv-card__header"><h2 class="nv-card__title">Keys that make sense</h2></div>
<div class="nv-table-frame"><table class="nv-table nv-table--compact"><tbody id="keys"></tbody></table></div>
</div>
</section>
<section class="shell section" id="get">
<div class="section__head">
<span class="nv-overline">Get it</span>
<h2 class="nv-heading">Free, open source, for the computer you've got.</h2>
</div>
<div class="downloads" id="downloads"></div> <div class="downloads" id="downloads"></div>
<div class="nv-card nv-card--inset build"> <p id="build-text"></p>
<div class="nv-card__body nv-stack"> <div class="code"><button class="copy" id="copy">Copy</button><pre id="build-steps"></pre></div>
<p class="nv-body" id="build-text"></p> <p class="muted" id="build-needs"></p>
<div class="code-card">
<div class="code-card__head">
<span class="nv-overline">Terminal</span>
<button class="nv-button nv-button--sm nv-button--ghost" data-copy="build-steps">Copy</button>
</div>
<pre class="code" id="build-steps"></pre>
</div>
<p class="nv-caption nv-text-tertiary" id="build-needs"></p>
</div>
</div>
</section> </section>
<section class="shell section" id="versions"> <section id="versions">
<div class="section__head"> <h2>Versions</h2>
<span class="nv-overline">Versions</span> <div id="version-list"></div>
<h2 class="nv-heading">What's new</h2>
</div>
<div class="nv-disclosure-group" id="version-list"></div>
</section> </section>
</main> </main>
<footer class="shell footer"> <footer>
<img class="footer__logo" src="img/logo.svg" alt=""> <p>Fry3DX, made by <a id="author" href="#"></a>.</p>
<div class="nv-stack nv-stack--tight"> <p class="muted">Open source under the Apache License 2.0 · Sky pictures from Poly Haven (CC0) · Fonts: Inter, JetBrains Mono (OFL).</p>
<p class="nv-body">Fry3DX, made by <a id="author" href="#"></a>.</p>
<p class="nv-caption nv-text-tertiary">Open source under the Apache License 2.0 · Sky pictures from Poly Haven (CC0) ·
Fonts: Instrument Sans, JetBrains Mono (OFL) · Look: <a href="https://github.com/Nexoniarz/niveus">Niveus</a>.</p>
</div>
</footer> </footer>
<script src="content.js"></script> <script src="content.js"></script>

File diff suppressed because it is too large Load diff

View file

@ -1,353 +0,0 @@
/* ==========================================================================
Niveus — continuity helpers
--------------------------------------------------------------------------
Optional. Nothing in Niveus needs this file to render; every component works
without it. What it adds is continuity for the changes CSS cannot animate on
its own — a selection moving to a different element, a panel replacing
another, the whole surface changing appearance. All three are the same
problem: two states that exist at different moments, with no single element
transitioning between them. A view transition is the browser's answer, and
this file is the small amount of wiring it needs.
Everything here is a thin wrapper. An application that already owns its
state (a router, a framework) should call nv.transition() around its own
update and ignore the rest.
nv.transition(update, { direction, kind, scope }) run an update continuously
nv.setTheme("dark" | "light" | "system") cross-fade the appearance
nv.openDialog(dialog, trigger) open it out of its trigger
nv.selectTab(tablist, tab) move through the same surface
Markup wiring, applied automatically on load:
[data-nv-theme="dark"] button that sets the appearance
[data-nv-dialog="id"] button that opens a dialog from itself
[data-nv-tabs] tablist whose [role=tab] children switch
their aria-controls panels
[data-nv-close] button that closes its dialog or popover
[data-nv-scroller] frame whose [data-nv-scroll] controls move
its .nv-scroller a page at a time
Only what changes is lifted. A transition names the selection marker and
the items of the groups it is given as `scope`, and only for as long as it
runs; everything else stays in the one page snapshot. Naming every item on
the page permanently made every transition — a theme change included —
tear the page into dozens of layers, which is both slow and what pulled
items out from under frosted chrome.
Reduced motion is honoured by skipping the view transition entirely: the
update still runs, it simply arrives instead of travelling.
========================================================================== */
(function (global) {
"use strict";
var root = document.documentElement;
function prefersReducedMotion() {
return global.matchMedia("(prefers-reduced-motion: reduce)").matches;
}
function canTransition() {
return typeof document.startViewTransition === "function" && !prefersReducedMotion();
}
/**
* Run `update` so the user can follow the change.
* `direction` ("forward" | "backward") tells content which way to travel;
* `kind` ("theme") marks a change the whole surface takes part in;
* `scope` (an element or a list of them) names the selection groups whose
* marker should travel. Without a scope, nothing is lifted and the change
* cross-fades in place.
*/
function transition(update, options) {
options = options || {};
if (!canTransition()) {
return Promise.resolve(update());
}
if (options.direction) root.dataset.nvDirection = options.direction;
if (options.kind) root.dataset.nvTransition = options.kind;
var groups = [].concat(options.scope || []).filter(Boolean);
var lifted = [];
groups.forEach(function (group) {
group.setAttribute("data-nv-travelling", "");
lifted = lifted.concat(liftLabels(group));
});
var view = document.startViewTransition(update);
function clear() {
delete root.dataset.nvDirection;
delete root.dataset.nvTransition;
groups.forEach(function (group) { group.removeAttribute("data-nv-travelling"); });
lifted.forEach(function (item) { item.style.viewTransitionName = ""; });
}
view.finished.then(clear, clear);
return view.finished;
}
/** "light" | "dark" | "system" — system means: follow the environment. */
function setTheme(theme) {
var done = transition(function () {
if (theme === "system") {
root.removeAttribute("data-theme");
} else {
root.setAttribute("data-theme", theme);
}
syncThemeControls(theme);
}, { kind: "theme" });
root.dispatchEvent(new CustomEvent("nv:theme", { detail: { theme: theme } }));
return done;
}
function syncThemeControls(theme) {
var controls = document.querySelectorAll("[data-nv-theme]");
for (var i = 0; i < controls.length; i++) {
controls[i].setAttribute("aria-pressed", String(controls[i].dataset.nvTheme === theme));
}
}
/**
* Open a <dialog> so it appears to come from `trigger`: the entrance scales
* away from the trigger's centre instead of from the middle of the screen.
*/
function openDialog(dialog, trigger) {
if (typeof dialog === "string") dialog = document.getElementById(dialog);
if (!dialog) return null;
dialog.showModal();
if (trigger) {
// showModal() has already laid the dialog out, so both boxes are final.
var from = trigger.getBoundingClientRect();
var to = dialog.getBoundingClientRect();
// A trigger far outside the viewport — one the page has scrolled past —
// would otherwise put the origin hundreds of pixels away and turn a
// 3% scale into a lunge across the screen. One dialog-length in any
// direction is enough to say where it came from.
var clamp = function (value, size) {
return Math.max(-size, Math.min(value, size * 2));
};
dialog.style.setProperty(
"--nv-origin",
clamp(from.left + from.width / 2 - to.left, to.width).toFixed(1) + "px " +
clamp(from.top + from.height / 2 - to.top, to.height).toFixed(1) + "px"
);
} else {
dialog.style.removeProperty("--nv-origin");
}
return dialog;
}
function tabsOf(tablist) {
return Array.prototype.slice.call(tablist.querySelectorAll('[role="tab"]'));
}
/** Move the selection, and the panel, in the direction the user is going. */
function selectTab(tablist, tab) {
var tabs = tabsOf(tablist);
var next = tabs.indexOf(tab);
var current = -1;
for (var i = 0; i < tabs.length; i++) {
if (tabs[i].getAttribute("aria-selected") === "true") current = i;
}
if (next < 0 || next === current) return Promise.resolve();
// The outgoing and incoming panels carry one name between them — the old
// one in the old state, the new one in the new — so the browser treats
// them as one surface whose content changes. Named only for the length of
// the change, so any number of tab sets can live on one page.
var panelOf = function (candidate) {
return candidate ? document.getElementById(candidate.getAttribute("aria-controls")) : null;
};
var outgoing = panelOf(tabs[current]);
var incoming = panelOf(tab);
if (outgoing && canTransition()) outgoing.style.viewTransitionName = "nv-panel";
var done = transition(function () {
tabs.forEach(function (candidate, index) {
var selected = index === next;
candidate.setAttribute("aria-selected", String(selected));
candidate.tabIndex = selected ? 0 : -1;
var panel = panelOf(candidate);
if (panel) panel.hidden = !selected;
});
if (outgoing) outgoing.style.viewTransitionName = "";
if (incoming && canTransition()) incoming.style.viewTransitionName = "nv-panel";
}, { direction: next > current ? "forward" : "backward", scope: tablist });
var settle = function () { if (incoming) incoming.style.viewTransitionName = ""; };
done.then(settle, settle);
tab.focus();
return done;
}
function initTabs(tablist) {
tablist.addEventListener("click", function (event) {
var tab = event.target.closest('[role="tab"]');
if (tab && tablist.contains(tab)) {
event.preventDefault();
selectTab(tablist, tab);
}
});
// The ARIA tabs pattern: arrows move selection, Home and End jump to the
// ends, and only the selected tab is in the tab order.
tablist.addEventListener("keydown", function (event) {
var tabs = tabsOf(tablist);
var index = tabs.indexOf(document.activeElement);
if (index < 0) return;
var vertical = tablist.getAttribute("aria-orientation") === "vertical";
var back = vertical ? "ArrowUp" : "ArrowLeft";
var forward = vertical ? "ArrowDown" : "ArrowRight";
var target = null;
if (event.key === back) target = tabs[(index - 1 + tabs.length) % tabs.length];
else if (event.key === forward) target = tabs[(index + 1) % tabs.length];
else if (event.key === "Home") target = tabs[0];
else if (event.key === "End") target = tabs[tabs.length - 1];
if (target) {
event.preventDefault();
selectTab(tablist, target);
}
});
}
/**
* Wire a scroller's prev/next controls. Scrolling itself is the browser's;
* this only gives pointers without a horizontal axis somewhere to click, and
* hides a control once there is nothing left in that direction.
*/
function initScroller(frame) {
var track = frame.querySelector(".nv-scroller");
if (!track) return;
var controls = frame.querySelectorAll("[data-nv-scroll]");
function step() {
var item = track.firstElementChild;
if (!item) return track.clientWidth * 0.8;
var gap = parseFloat(getComputedStyle(track).columnGap) || 0;
return item.getBoundingClientRect().width + gap;
}
function update() {
// A sub-pixel slack, or a fractional scrollWidth leaves a control
// enabled at an end it cannot move past.
var atStart = track.scrollLeft <= 1;
var atEnd = track.scrollLeft >= track.scrollWidth - track.clientWidth - 1;
controls.forEach(function (control) {
control.disabled = control.dataset.nvScroll === "prev" ? atStart : atEnd;
});
// The same two facts decide which edge is allowed to fade.
frame.toggleAttribute("data-nv-at-start", atStart);
frame.toggleAttribute("data-nv-at-end", atEnd);
}
controls.forEach(function (control) {
control.addEventListener("click", function () {
var direction = control.dataset.nvScroll === "next" ? 1 : -1;
track.scrollBy({ left: direction * step(), behavior: "smooth" });
});
});
track.addEventListener("scroll", update, { passive: true });
if (typeof ResizeObserver === "function") new ResizeObserver(update).observe(track);
update();
}
/**
* Give every item in `scope` a view-transition name, so it is captured as
* its own layer and rides *above* the travelling marker instead of being
* painted over by it. Returns the items it named, so the caller can take
* the names back off; transition() does this for its `scope` and clears
* them when the change is over.
*
* This cannot live in CSS: a view-transition-name must be unique across the
* whole document, and a stylesheet cannot count items. Items already
* carrying a name are left alone, so an application can assign its own.
*/
var carried = 0;
function liftLabels(scope) {
var items = scope.querySelectorAll(".nv-nav__item");
var named = [];
for (var j = 0; j < items.length; j++) {
if (items[j].style.viewTransitionName) continue;
carried += 1;
items[j].style.viewTransitionName = "nv-carried-" + carried;
named.push(items[j]);
}
return named;
}
function wire(scope) {
scope = scope || document;
scope.querySelectorAll("[data-nv-theme]").forEach(function (control) {
control.addEventListener("click", function () {
setTheme(control.dataset.nvTheme);
});
});
scope.querySelectorAll("[data-nv-dialog]").forEach(function (trigger) {
trigger.addEventListener("click", function () {
openDialog(trigger.dataset.nvDialog, trigger);
});
});
scope.querySelectorAll("[data-nv-tabs]").forEach(initTabs);
scope.querySelectorAll("[data-nv-scroller]").forEach(initScroller);
scope.querySelectorAll("[data-nv-close]").forEach(function (control) {
control.addEventListener("click", function () {
var owner = control.closest("dialog, [popover]");
if (!owner) return;
if (owner.tagName === "DIALOG") owner.close();
else owner.hidePopover();
});
});
// A dialog closes when the scrim is clicked, but not when a drag merely
// ends there.
scope.querySelectorAll("dialog.nv-dialog").forEach(function (dialog) {
dialog.addEventListener("mousedown", function (event) {
if (event.target === dialog) dialog.close();
});
});
}
global.nv = {
transition: transition,
setTheme: setTheme,
syncThemeControls: syncThemeControls,
openDialog: openDialog,
selectTab: selectTab,
initScroller: initScroller,
liftLabels: liftLabels,
wire: wire,
prefersReducedMotion: prefersReducedMotion,
};
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", function () { wire(); });
} else {
wire();
}
})(window);

View file

@ -1,148 +0,0 @@
/* Fry3DX's site: layout only. Every colour, shadow, radius, type size and duration comes from
Niveus (niveus/niveus.css); if a hex value turns up here, Niveus is missing a token. */
html { scroll-padding-top: 5rem; }
body { background: var(--nv-color-background); }
.shell {
max-inline-size: var(--nv-layout-max-width);
margin-inline: auto;
padding-inline: var(--nv-layout-gutter);
}
.section { padding-block: var(--nv-space-16); }
.section__head {
max-inline-size: var(--nv-measure);
margin-block-end: var(--nv-space-8);
display: flex;
flex-direction: column;
gap: var(--nv-space-3);
}
/* --- Bar ------------------------------------------------------------------- */
.brand-logo { inline-size: 1.75rem; block-size: 1.75rem; }
@media (max-width: 60rem) { .nav-links { display: none; } }
@media (max-width: 34rem) { .theme-switch, #gh-top { display: none; } }
/* --- Hero ------------------------------------------------------------------ */
.hero {
display: grid;
gap: var(--nv-space-12);
padding-block: var(--nv-space-16) var(--nv-space-8);
align-items: center;
}
@media (min-width: 64rem) {
.hero { grid-template-columns: 5fr 7fr; gap: var(--nv-space-12); }
}
.hero > * { min-inline-size: 0; } /* (a grid's items may shrink below their content) */
.hero__copy {
display: flex;
flex-direction: column;
gap: var(--nv-space-5);
align-items: flex-start;
}
.hero__copy > .nv-badge { white-space: normal; max-inline-size: 100%; } /* (the newest version's line wraps on a phone) */
/* Tab rows wider than a phone scroll sideways instead of widening the page. */
.nv-nav-tabs { max-inline-size: 100%; overflow-x: auto; scrollbar-width: none; }
.hero__title { font-size: clamp(var(--nv-font-size-4xl), 5.5vw, var(--nv-font-size-5xl)); }
.hero__bed { padding: var(--nv-space-6); min-inline-size: 0; }
.app-window { max-inline-size: 100%; }
.app-window__shot, .shot__img {
display: block;
inline-size: 100%;
block-size: auto;
}
/* --- Why ------------------------------------------------------------------- */
.why { max-inline-size: 52rem; margin-inline: auto; }
/* --- Features -------------------------------------------------------------- */
.features {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(17rem, 100%), 1fr));
gap: var(--nv-space-5);
}
.feature__icon {
inline-size: var(--nv-space-10);
block-size: var(--nv-space-10);
display: grid;
place-items: center;
margin-block-end: var(--nv-space-4);
border-radius: var(--nv-radius-md);
background-color: var(--nv-color-accent-subtle);
color: var(--nv-color-accent-text);
}
.feature__icon svg { inline-size: var(--nv-space-5); block-size: var(--nv-space-5); }
.feature .nv-card__title { margin-block-end: var(--nv-space-2); }
/* --- Tabs: the panel box they hand over in (Niveus Continuity) ---------------- */
.tab-box { margin-block-start: var(--nv-space-6); display: grid; }
.tab-box > [hidden] { display: none; }
.features[hidden] { display: none; }
.shot { display: grid; gap: var(--nv-space-4); }
.shot[hidden] { display: none; }
/* --- Two side by side -------------------------------------------------------- */
.split {
display: grid;
gap: var(--nv-space-6);
align-items: start;
}
@media (min-width: 56rem) { .split { grid-template-columns: 1fr 1fr; } }
.ticks { display: grid; gap: var(--nv-space-2); padding-inline-start: var(--nv-space-5); }
/* --- Code -------------------------------------------------------------------- */
.code-card { overflow: hidden; }
.code-card__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--nv-space-3);
}
.code {
margin: 0;
padding: var(--nv-space-4);
overflow-x: auto;
border-radius: var(--nv-radius-md);
background: var(--nv-color-surface-inset);
box-shadow: var(--nv-elevation-inset-sm);
font: var(--nv-font-code);
color: var(--nv-color-text-primary);
}
.code-card > .code { margin: 0 var(--nv-space-4) var(--nv-space-4); }
.code .k { color: var(--nv-color-accent-text); }
.code .c { color: var(--nv-color-text-tertiary); }
.code .s { color: var(--nv-color-success-text); }
/* --- Files and keys ------------------------------------------------------------ */
.fmt { display: grid; gap: var(--nv-space-2); }
kbd {
display: inline-block;
padding: var(--nv-space-0-5) var(--nv-space-2);
border-radius: var(--nv-radius-sm);
background: var(--nv-color-surface-elevated);
background-image: var(--nv-sheen-raised);
box-shadow: var(--nv-bevel), var(--nv-outline), var(--nv-elevation-1);
font: var(--nv-font-code);
white-space: nowrap;
}
/* --- Downloads ----------------------------------------------------------------- */
.downloads {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(15rem, 100%), 1fr));
gap: var(--nv-space-5);
margin-block-end: var(--nv-space-8);
}
.download .nv-card__body { display: grid; gap: var(--nv-space-2); }
.download__file { overflow-wrap: anywhere; }
/* --- Footer -------------------------------------------------------------------- */
.footer {
display: flex;
gap: var(--nv-space-4);
align-items: center;
padding-block: var(--nv-space-12);
border-block-start: var(--nv-border-width) solid var(--nv-color-border-subtle);
}
.footer__logo { inline-size: var(--nv-space-12); block-size: var(--nv-space-12); }

View file

@ -9,220 +9,91 @@
return e; return e;
}; };
// Feature icons: 24px line drawings in the text's colour.
const ICONS = {
cube: '<path d="M12 3 20 7.5v9L12 21l-8-4.5v-9z"/><path d="m4 7.5 8 4.5 8-4.5M12 12v9"/>',
pen: '<path d="M4 20h4L19 9l-4-4L4 16z"/><path d="m13.5 6.5 4 4"/>',
combine: '<circle cx="9" cy="12" r="5.5"/><circle cx="15" cy="12" r="5.5"/>',
points: '<rect x="5" y="5" width="14" height="14" rx="1"/><circle cx="5" cy="5" r="1.6" fill="currentColor"/><circle cx="19" cy="5" r="1.6" fill="currentColor"/><circle cx="5" cy="19" r="1.6" fill="currentColor"/><circle cx="19" cy="19" r="1.6" fill="currentColor"/>',
hole: '<rect x="3.5" y="3.5" width="17" height="17" rx="2"/><path d="m12 7 4.3 2.5v5L12 17l-4.3-2.5v-5z"/><circle cx="12" cy="12" r="1.8"/>',
copies: '<rect x="3" y="9" width="6" height="6" rx="1"/><rect x="10" y="9" width="6" height="6" rx="1"/><rect x="17" y="9" width="4" height="6" rx="1"/>',
brush: '<path d="M14.5 4.5 19.5 9.5 11 18l-5-5z"/><path d="M6 13c-2 0-3 1.5-3 3.5S2 20 2 20s3.5.5 5.5-.5S9 17 9 16"/>',
layout: '<rect x="3.5" y="3.5" width="17" height="17" rx="2"/><path d="M3.5 12h17M12 3.5v17"/>',
print: '<path d="M7 9V4h10v5"/><rect x="3.5" y="9" width="17" height="8" rx="2"/><path d="M7 14h10v6H7z"/>',
sun: '<circle cx="12" cy="12" r="4"/><path d="M12 2.5v2.5M12 19v2.5M2.5 12H5M19 12h2.5M5.3 5.3l1.8 1.8M16.9 16.9l1.8 1.8M5.3 18.7l1.8-1.8M16.9 7.1l1.8-1.8"/>',
folder: '<path d="M3.5 6.5a1 1 0 0 1 1-1h5l2 2h8a1 1 0 0 1 1 1v9a1 1 0 0 1-1 1h-15a1 1 0 0 1-1-1z"/>',
phone: '<rect x="7" y="2.5" width="10" height="19" rx="2.5"/><path d="M11 18.5h2"/>',
save: '<path d="M4 12a8 8 0 1 0 2.4-5.7"/><path d="M4 4v4h4M12 8v4l3 2"/>',
code: '<path d="m8 7-5 5 5 5M16 7l5 5-5 5M13.5 4l-3 16"/>',
search: '<circle cx="10.5" cy="10.5" r="6"/><path d="m15 15 5.5 5.5"/>',
ruler: '<rect x="2.5" y="8" width="19" height="8" rx="1"/><path d="M6.5 8v3M10.5 8v4M14.5 8v3M18.5 8v4"/>',
text: '<path d="M5 6.5V4.5h14v2M12 4.5v15M9 19.5h6"/>',
sculpt: '<path d="M4 18c3-1 4-6 8-6s5 5 8 6"/><circle cx="12" cy="7" r="3"/>',
plug: '<path d="M9 3v5M15 3v5M6.5 8h11v3a5.5 5.5 0 0 1-11 0zM12 16.5V21"/>',
sphere: '<circle cx="12" cy="12" r="8.5"/><path d="M3.5 12c2.5 2 14.5 2 17 0"/><circle cx="9" cy="8.5" r="1.5" fill="currentColor" stroke="none"/>',
};
const icon = (name) => {
const s = el("span", "feature__icon");
s.innerHTML = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${ICONS[name] || ICONS.cube}</svg>`;
return s;
};
// ── Hero ──
$("latest").textContent = `Version ${F.versions[0].v}: ${F.versions[0].items[0]}`;
$("long").textContent = F.long; $("long").textContent = F.long;
$("tagline").textContent = F.tagline; $("tagline").textContent = F.tagline;
$("intro").textContent = F.intro; $("intro").textContent = F.intro;
for (const id of ["gh-top", "gh-hero"]) $(id).href = F.repo; for (const id of ["gh-top", "gh-hero"]) $(id).href = F.repo;
for (const d of F.downloads) $("platforms").append(el("span", "nv-badge", d.os));
$("author").textContent = F.author; $("author").textContent = F.author;
$("author").href = F.authorUrl; $("author").href = F.authorUrl;
$("why-title").textContent = F.why.title; const why = $("why");
for (const p of F.why.text) $("why-text").append(el("p", "nv-body", p)); why.append(el("h2", null, F.why.title));
for (const p of F.why.text) why.append(el("p", null, p));
// ── Features: a tab each group, the cards in its panel ── const grid = $("feature-grid");
F.featureGroups.forEach((g, i) => { for (const f of F.features) {
const tab = el("button", "nv-nav__item", g.title); const card = el("article", "card");
tab.id = `feature-tab-${g.id}`; card.append(el("div", "icon", f.icon), el("h3", null, f.title), el("p", null, f.text));
tab.tabIndex = i ? -1 : 0; grid.append(card);
tab.setAttribute("role", "tab");
tab.setAttribute("aria-controls", `features-${g.id}`);
tab.setAttribute("aria-selected", String(i === 0));
$("feature-tabs").append(tab);
const panel = el("div", "nv-tab-panel features");
Object.assign(panel, { id: `features-${g.id}`, hidden: i !== 0 });
panel.setAttribute("role", "tabpanel");
panel.setAttribute("aria-labelledby", tab.id);
for (const f of F.features.filter((f) => f.group === g.id)) {
const card = el("article", "nv-card feature");
const body = el("div", "nv-card__body");
body.append(icon(f.icon), el("h3", "nv-card__title", f.title), el("p", "nv-body nv-text-secondary", f.text));
card.append(body);
panel.append(card);
} }
$("feature-panels").append(panel);
});
// ── Screenshots: a tab each, the picture in a window ── // Screenshots: a tab each.
const tabs = $("shot-tabs");
const show = (i) => {
const s = F.shots[i];
$("shot-img").src = s.img;
$("shot-img").alt = s.title;
$("shot-text").textContent = s.text;
[...tabs.children].forEach((b, j) => b.setAttribute("aria-selected", i === j));
};
F.shots.forEach((s, i) => { F.shots.forEach((s, i) => {
const tab = el("button", "nv-nav__item", s.title); const b = el("button", "tab", s.title);
Object.assign(tab, { id: `shot-tab-${i}`, role: "tab", tabIndex: i ? -1 : 0 }); b.setAttribute("role", "tab");
tab.setAttribute("role", "tab"); b.onclick = () => show(i);
tab.setAttribute("aria-controls", `shot-${i}`); tabs.append(b);
tab.setAttribute("aria-selected", String(i === 0)); new Image().src = s.img; // (loaded ahead, so switching is instant)
$("shot-tabs").append(tab);
const panel = el("div", "nv-tab-panel shot");
Object.assign(panel, { id: `shot-${i}`, hidden: i !== 0 });
panel.setAttribute("role", "tabpanel");
panel.setAttribute("aria-labelledby", tab.id);
const win = el("div", "nv-window");
const bar = el("div", "nv-window__titlebar");
const lights = el("div", "nv-window__controls");
for (const k of ["close", "minimize", "zoom"]) {
const l = el("span", `nv-window__control nv-window__control--${k}`);
l.setAttribute("aria-hidden", "true");
lights.append(l);
}
bar.append(lights, el("span", "nv-window__title", `Fry3DX — ${s.title}`));
const img = el("img", "shot__img");
Object.assign(img, { src: s.img, alt: s.title, loading: i ? "lazy" : "eager" });
win.append(bar, img);
panel.append(win, el("p", "nv-body nv-text-secondary", s.text));
$("shot-panels").append(panel);
}); });
show(0);
// ── Scripting ── const fm = $("formats");
if (F.scripting) {
const S = F.scripting;
$("scripting").hidden = false;
$("script-title").textContent = S.title;
$("script-text").textContent = S.text;
for (const p of S.points) $("script-points").append(el("li", "nv-body", p));
$("script-file").textContent = S.file;
$("script-code").innerHTML = highlightLua(S.code);
}
// ── Files and keys ──
const row = (label, items) => { const row = (label, items) => {
const d = el("div", "fmt"); const d = el("div", "fmt");
d.append(el("span", "nv-overline", label)); d.append(el("span", "fmt-label", label));
const c = el("div", "nv-cluster nv-cluster--tight"); for (const x of items) d.append(el("span", "chip", x));
for (const x of items) c.append(el("span", "nv-badge", x)); fm.append(d);
d.append(c);
$("formats").append(d);
}; };
row("Save", F.formats.save); row("Save", F.formats.save);
row("Export", F.formats.export); row("Export", F.formats.export);
$("formats").append(el("p", "nv-caption nv-text-tertiary", F.formats.note)); fm.append(el("p", "muted", F.formats.note));
const keys = $("keys");
for (const [k, what] of F.keys) { for (const [k, what] of F.keys) {
const tr = el("tr"); const tr = el("tr");
const td = el("td"); const td = el("td");
td.append(el("kbd", null, k)); td.append(el("kbd", null, k));
tr.append(td, el("td", "nv-body", what)); tr.append(td, el("td", null, what));
$("keys").append(tr); keys.append(tr);
} }
// ── Downloads and building ── const dl = $("downloads");
for (const d of F.downloads) { for (const d of F.downloads) {
const a = el("a", "nv-card nv-card--interactive download"); const a = el("a", "download");
a.href = F.releases; a.href = F.releases;
const b = el("div", "nv-card__body"); a.append(el("strong", null, d.os), el("span", "muted", d.what), el("span", "file", "Fry3DX-…-" + d.file), el("span", null, d.how));
b.append(el("h3", "nv-card__title", d.os), el("p", "nv-caption nv-text-tertiary", d.what), dl.append(a);
el("code", "nv-code download__file", "Fry3DX-…-" + d.file), el("p", "nv-body nv-text-secondary", d.how));
a.append(b);
$("downloads").append(a);
} }
$("build-text").textContent = F.build.text; $("build-text").textContent = F.build.text;
$("build-steps").textContent = F.build.steps.join("\n"); $("build-steps").textContent = F.build.steps.join("\n");
$("build-needs").textContent = F.build.needs; $("build-needs").textContent = F.build.needs;
for (const btn of document.querySelectorAll("[data-copy]")) { $("copy").onclick = async () => {
btn.onclick = async () => {
try { try {
await navigator.clipboard.writeText($(btn.dataset.copy).textContent); await navigator.clipboard.writeText(F.build.steps.join("\n"));
btn.textContent = "Copied"; $("copy").textContent = "Copied";
} catch { } catch {
btn.textContent = "Select and copy"; $("copy").textContent = "Select and copy";
} }
setTimeout(() => (btn.textContent = "Copy"), 1800); setTimeout(() => ($("copy").textContent = "Copy"), 1800);
}; };
}
// ── Versions ── const vl = $("version-list");
F.versions.forEach((v, i) => { for (const v of F.versions) {
const d = el("details", "nv-disclosure"); const d = el("div", "version");
d.open = i === 0; const h = el("h3");
const s = el("summary", "nv-disclosure__summary"); h.append(el("span", "v", "v" + v.v), el("span", "muted", " " + v.date));
s.append(el("span", "nv-title", "v" + v.v), el("span", "nv-caption nv-text-tertiary", " " + v.date)); const ul = el("ul");
const ch = el("span", "nv-disclosure__chevron"); for (const it of v.items) ul.append(el("li", null, it));
ch.setAttribute("aria-hidden", "true"); d.append(h, ul);
s.append(ch); vl.append(d);
const body = el("div", "nv-disclosure__body");
const ul = el("ul", "ticks");
for (const it of v.items) ul.append(el("li", "nv-body", it));
body.append(ul);
d.append(s, body);
$("version-list").append(d);
});
// ── The appearance, remembered; the bar's shadow once scrolled ──
document.documentElement.addEventListener("nv:theme", (e) => {
try { localStorage.setItem("fry-theme", e.detail?.theme ?? "system"); } catch (_) {}
});
const saved = (() => { try { return localStorage.getItem("fry-theme"); } catch (_) { return null; } })();
if (saved) addEventListener("DOMContentLoaded", () => window.nv?.syncThemeControls(saved));
const nav = $("top-nav");
const onScroll = () => nav.toggleAttribute("data-scrolled", scrollY > 4);
addEventListener("scroll", onScroll, { passive: true });
onScroll();
// The bar's marker travels to the section on screen (Niveus Continuity: it moves there, it
// doesn't jump), forward or back by which way you went.
const links = [...document.querySelectorAll("#nav-list .nv-nav__item")];
const sections = links.map((a) => document.querySelector(a.getAttribute("href")));
let current = -1;
const mark = (i) => {
if (i === current) return;
const forward = i > current;
const update = () => links.forEach((a, j) => (j === i ? a.setAttribute("aria-current", "location") : a.removeAttribute("aria-current")));
current = i;
// (Not over another change that's still moving — a tab handing over: starting one skips it.)
const busy = document.documentElement.hasAttribute("data-nv-direction");
if (window.nv && !busy) nv.transition(update, { direction: forward ? "forward" : "backward", scope: $("nav-list") });
else update();
};
let queued = false;
const spy = () => {
queued = false;
const line = innerHeight * 0.35;
let at = -1;
sections.forEach((sec, i) => { if (sec && !sec.hidden && sec.getBoundingClientRect().top <= line) at = i; });
mark(at);
};
addEventListener("scroll", () => { if (!queued) queued = true, requestAnimationFrame(spy); }, { passive: true });
spy();
// A little Lua colouring for the scripting sample.
function highlightLua(src) {
const esc = (t) => t.replace(/&/g, "&amp;").replace(/</g, "&lt;");
const words = /\b(local|function|end|for|in|do|if|then|else|elseif|return|while|and|or|not|nil|true|false)\b/g;
return src.split("\n").map((line) => {
const at = line.indexOf("--");
const code = at >= 0 ? line.slice(0, at) : line, comment = at >= 0 ? line.slice(at) : "";
const parts = code.split(/("[^"]*")/);
const out = parts.map((p) => (p.startsWith('"') ? `<span class="s">${esc(p)}</span>` : esc(p).replace(words, '<span class="k">$1</span>'))).join("");
return out + (comment ? `<span class="c">${esc(comment)}</span>` : "");
}).join("\n");
} }
})(); })();

111
docs/style.css Normal file
View file

@ -0,0 +1,111 @@
/* The site's own fonts (SIL Open Font License, fonts/OFL-*.txt), so it looks the same everywhere. */
@font-face { font-family: "Inter"; src: url("fonts/inter-latin-wght-normal.woff2") format("woff2"); font-weight: 100 900; font-display: swap; }
@font-face { font-family: "JetBrains Mono"; src: url("fonts/jetbrains-mono-latin-wght-normal.woff2") format("woff2"); font-weight: 100 800; font-display: swap; }
:root {
--bg: #161618;
--panel: #1f1f23;
--panel2: #26262b;
--line: #34343b;
--ink: #ecebe6;
--dim: #9c9a94;
--hot: #ff7828;
--hot2: #ffc24a;
--radius: 10px;
color-scheme: dark;
}
* { box-sizing: border-box; }
html { scroll-behavior: smooth; }
body {
margin: 0;
background: var(--bg);
color: var(--ink);
font: 16px/1.6 "Inter", system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
}
a { color: var(--hot); text-decoration: none; }
a:hover { text-decoration: underline; }
img { max-width: 100%; display: block; }
h1, h2, h3 { line-height: 1.2; margin: 0 0 .6em; }
h2 { font-size: 1.7rem; }
h2::after { content: ""; display: block; width: 48px; height: 4px; margin-top: .4em; border-radius: 2px; background: linear-gradient(90deg, var(--hot), var(--hot2)); }
.muted { color: var(--dim); }
.top {
position: sticky; top: 0; z-index: 10;
display: flex; align-items: center; justify-content: space-between; gap: 16px;
padding: 10px max(16px, calc((100% - 1120px) / 2));
background: rgba(22, 22, 24, .9); backdrop-filter: blur(8px);
border-bottom: 1px solid var(--line);
}
.brand { display: flex; align-items: center; gap: 10px; color: var(--ink); font-weight: 700; font-size: 1.15rem; }
.brand img { width: 32px; height: 32px; }
.brand:hover { text-decoration: none; }
nav { display: flex; gap: 18px; flex-wrap: wrap; justify-content: flex-end; }
nav a { color: var(--dim); font-size: .95rem; }
nav a:hover { color: var(--ink); text-decoration: none; }
nav .gh { color: var(--hot); }
main { max-width: 1120px; margin: 0 auto; padding: 0 16px; }
section { padding: 56px 0; border-bottom: 1px solid var(--line); }
section:last-child { border-bottom: 0; }
.hero { display: grid; grid-template-columns: 1fr 1.25fr; gap: 40px; align-items: center; padding-top: 64px; }
.kicker { color: var(--hot); font-weight: 600; letter-spacing: .08em; text-transform: uppercase; font-size: .85rem; margin: 0 0 .6em; }
.hero h1 { font-size: clamp(2rem, 4.5vw, 3.2rem); }
.lead { color: var(--dim); font-size: 1.1rem; }
.buttons { display: flex; gap: 12px; flex-wrap: wrap; margin-top: 24px; }
.btn { display: inline-block; padding: 12px 20px; border-radius: var(--radius); background: var(--panel2); color: var(--ink); font-weight: 600; border: 1px solid var(--line); }
.btn:hover { text-decoration: none; border-color: var(--hot); }
.btn.hot { background: linear-gradient(135deg, var(--hot), #ff4a1c); border-color: transparent; color: #fff; }
.hero-shot { margin: 0; }
.hero-shot img, .shot img { border-radius: var(--radius); border: 1px solid var(--line); box-shadow: 0 20px 60px rgba(0, 0, 0, .45); }
.why { max-width: 760px; }
.why p { font-size: 1.08rem; color: #cfcdc7; }
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); gap: 16px; }
.card { background: var(--panel); border: 1px solid var(--line); border-radius: var(--radius); padding: 20px; transition: border-color .15s, transform .15s; }
.card:hover { border-color: var(--hot); transform: translateY(-2px); }
.card .icon { font-size: 1.5rem; color: var(--hot); margin-bottom: 6px; }
.card h3 { font-size: 1.05rem; }
.card p { margin: 0; color: var(--dim); font-size: .95rem; }
.tabs { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 16px; }
.tab { font: inherit; color: var(--dim); background: var(--panel); border: 1px solid var(--line); border-radius: 999px; padding: 6px 16px; cursor: pointer; }
.tab:hover { color: var(--ink); }
.tab[aria-selected="true"] { color: #fff; background: var(--hot); border-color: var(--hot); }
.shot { margin: 0; }
.shot figcaption { color: var(--dim); margin-top: 12px; }
.two { display: grid; grid-template-columns: 1fr 1fr; gap: 40px; }
.fmt { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; margin-bottom: 12px; }
.fmt-label { width: 64px; color: var(--dim); }
.chip { background: var(--panel2); border: 1px solid var(--line); border-radius: 6px; padding: 2px 10px; font-size: .9rem; }
.keys { border-collapse: collapse; width: 100%; }
.keys td { padding: 7px 0; border-bottom: 1px solid var(--line); }
.keys td:first-child { width: 45%; }
kbd { font: .85rem "JetBrains Mono", ui-monospace, Consolas, 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 "JetBrains Mono", ui-monospace, Consolas, 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 "JetBrains Mono", ui-monospace, Consolas, 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; }
.copy:hover { border-color: var(--hot); }
.version { background: var(--panel); border: 1px solid var(--line); border-radius: var(--radius); padding: 18px 22px; margin-bottom: 12px; }
.version h3 { margin-bottom: .3em; }
.version .v { color: var(--hot); }
.version ul { margin: 0; padding-left: 20px; color: #cfcdc7; }
footer { text-align: center; padding: 40px 16px; border-top: 1px solid var(--line); color: var(--dim); }
footer p { margin: 4px 0; }
@media (max-width: 820px) {
.hero, .two { grid-template-columns: 1fr; }
.hero { padding-top: 32px; gap: 28px; }
nav a:not(.gh) { display: none; }
section { padding: 40px 0; }
}

View file

@ -31,9 +31,7 @@ Cvar::~Cvar() { std::erase(cvars(), this); }
void Cvar::set(std::string_view value) { void Cvar::set(std::string_view value) {
value_ = value; value_ = value;
// (The NDK's library, and Apple's before macOS 26, have no float from_chars. strtof reads the #if defined(__ANDROID__) // (the NDK's library has no float from_chars; bionic's locale is always "C")
// "C" locale's way: the program never sets another.)
#if defined(__ANDROID__) || defined(__APPLE__)
const std::string text(value); const std::string text(value);
char* end = nullptr; char* end = nullptr;
const float parsed = std::strtof(text.c_str(), &end); const float parsed = std::strtof(text.c_str(), &end);

View file

@ -2,7 +2,6 @@
#include <cstdio> #include <cstdio>
#include <cstring> #include <cstring>
#include <filesystem>
#include <fstream> #include <fstream>
#include <thread> #include <thread>
@ -16,11 +15,6 @@
#include <sys/utsname.h> #include <sys/utsname.h>
#include <unistd.h> #include <unistd.h>
#endif #endif
#if defined(__APPLE__)
#include <mach-o/dyld.h>
#include <mach/mach.h>
#include <sys/sysctl.h>
#endif
#if defined(_MSC_VER) #if defined(_MSC_VER)
#include <intrin.h> #include <intrin.h>
#elif defined(__x86_64__) || defined(__i386__) #elif defined(__x86_64__) || defined(__i386__)
@ -36,14 +30,6 @@ std::string trim(std::string s) {
return s.substr(i); return s.substr(i);
} }
#if defined(__APPLE__)
std::string sysctlText(const char* name) {
char buf[256] = {};
size_t size = sizeof buf - 1;
return sysctlbyname(name, buf, &size, nullptr, 0) == 0 ? trim(buf) : std::string();
}
#endif
// The CPU's own name for itself (x86: cpuid's brand string; elsewhere /proc/cpuinfo). // The CPU's own name for itself (x86: cpuid's brand string; elsewhere /proc/cpuinfo).
std::string cpuName() { std::string cpuName() {
#if defined(_MSC_VER) || defined(__x86_64__) || defined(__i386__) #if defined(_MSC_VER) || defined(__x86_64__) || defined(__i386__)
@ -60,9 +46,6 @@ std::string cpuName() {
char brand[49] = {}; char brand[49] = {};
std::memcpy(brand, regs, 48); std::memcpy(brand, regs, 48);
if (brand[0]) return trim(brand); if (brand[0]) return trim(brand);
#endif
#if defined(__APPLE__)
if (std::string s = sysctlText("machdep.cpu.brand_string"); !s.empty()) return s; // ("Apple M2 Pro" too)
#endif #endif
std::ifstream in("/proc/cpuinfo"); std::ifstream in("/proc/cpuinfo");
for (std::string line; std::getline(in, line);) for (std::string line; std::getline(in, line);)
@ -81,11 +64,6 @@ std::string osName() {
fn(&v); fn(&v);
const char* name = v.dwMajorVersion == 10 ? (v.dwBuildNumber >= 22000 ? "Windows 11" : "Windows 10") : "Windows"; const char* name = v.dwMajorVersion == 10 ? (v.dwBuildNumber >= 22000 ? "Windows 11" : "Windows 10") : "Windows";
return std::string(name) + " (build " + std::to_string(v.dwBuildNumber) + ")"; return std::string(name) + " (build " + std::to_string(v.dwBuildNumber) + ")";
#elif defined(__APPLE__)
utsname u{};
uname(&u);
const std::string v = sysctlText("kern.osproductversion");
return "macOS " + (v.empty() ? std::string("?") : v) + " (" + u.machine + ")";
#else #else
utsname u{}; utsname u{};
uname(&u); uname(&u);
@ -117,19 +95,6 @@ Memory memory() {
if (GlobalMemoryStatusEx(&s)) m.total = s.ullTotalPhys, m.used = s.ullTotalPhys - s.ullAvailPhys; if (GlobalMemoryStatusEx(&s)) m.total = s.ullTotalPhys, m.used = s.ullTotalPhys - s.ullAvailPhys;
PROCESS_MEMORY_COUNTERS pmc{sizeof pmc}; PROCESS_MEMORY_COUNTERS pmc{sizeof pmc};
if (K32GetProcessMemoryInfo(GetCurrentProcess(), &pmc, sizeof pmc)) m.ours = pmc.WorkingSetSize; if (K32GetProcessMemoryInfo(GetCurrentProcess(), &pmc, sizeof pmc)) m.ours = pmc.WorkingSetSize;
#elif defined(__APPLE__)
uint64_t total = 0;
size_t size = sizeof total;
if (sysctlbyname("hw.memsize", &total, &size, nullptr, 0) == 0) m.total = total;
vm_statistics64_data_t vm{};
mach_msg_type_number_t count = HOST_VM_INFO64_COUNT;
if (host_statistics64(mach_host_self(), HOST_VM_INFO64, reinterpret_cast<host_info64_t>(&vm), &count) == KERN_SUCCESS) {
const uint64_t page = uint64_t(sysconf(_SC_PAGESIZE)); // (used as Activity Monitor counts it: in use by apps, wired, compressed)
m.used = (uint64_t(vm.internal_page_count) - vm.purgeable_count + vm.wire_count + vm.compressor_page_count) * page;
}
mach_task_basic_info_data_t task{};
mach_msg_type_number_t taskCount = MACH_TASK_BASIC_INFO_COUNT;
if (task_info(mach_task_self(), MACH_TASK_BASIC_INFO, reinterpret_cast<task_info_t>(&task), &taskCount) == KERN_SUCCESS) m.ours = task.resident_size;
#else #else
uint64_t available = 0; uint64_t available = 0;
std::ifstream in("/proc/meminfo"); std::ifstream in("/proc/meminfo");
@ -149,39 +114,4 @@ Memory memory() {
return m; return m;
} }
std::string executablePath() {
#if defined(_WIN32)
char exe[1024] = {};
GetModuleFileNameA(nullptr, exe, sizeof exe);
return exe;
#elif defined(__APPLE__)
char exe[4096] = {};
uint32_t size = sizeof exe;
if (_NSGetExecutablePath(exe, &size) != 0) return {};
std::error_code ec;
const auto real = std::filesystem::canonical(exe, ec);
return ec ? std::string(exe) : real.string();
#else
std::error_code ec;
const auto p = std::filesystem::read_symlink("/proc/self/exe", ec);
return ec ? std::string() : p.string();
#endif
}
bool debuggerAttached() {
#if defined(_WIN32)
return IsDebuggerPresent();
#elif defined(__APPLE__)
kinfo_proc info{};
size_t size = sizeof info;
int mib[4] = {CTL_KERN, KERN_PROC, KERN_PROC_PID, getpid()};
return sysctl(mib, 4, &info, &size, nullptr, 0) == 0 && (info.kp_proc.p_flag & P_TRACED) != 0;
#else
std::ifstream f("/proc/self/status");
for (std::string line; std::getline(f, line);)
if (line.rfind("TracerPid:", 0) == 0) return std::atoi(line.c_str() + 10) != 0;
return false;
#endif
}
} // namespace keel::sys } // namespace keel::sys

View file

@ -18,9 +18,4 @@ struct Memory {
}; };
Memory memory(); Memory memory();
// This program's file (for starting it again: crash reports, restarts). Empty if unknown.
std::string executablePath();
// Is a debugger watching (the freeze watchdog waits then)?
bool debuggerAttached();
} // namespace keel::sys } // namespace keel::sys

View file

@ -1,14 +1,6 @@
#include "crash.h" #include "crash.h"
#include <core/log.h> #include <core/log.h>
#include <core/sysinfo.h>
#include <fs/vfs.h>
#include <ui/ui.h>
#include <imgui.h>
#include <imgui_impl_sdl3.h>
#include <imgui_impl_sdlrenderer3.h>
#include <stb_image.h>
#include <SDL3/SDL.h> #include <SDL3/SDL.h>
@ -130,6 +122,17 @@ void onSignal(int sig) {
} }
#endif #endif
bool debuggerAttached() {
#ifdef _WIN32
return IsDebuggerPresent();
#else
std::ifstream f("/proc/self/status");
for (std::string line; std::getline(f, line);)
if (line.rfind("TracerPid:", 0) == 0) return std::atoi(line.c_str() + 10) != 0;
return false;
#endif
}
void watchdog() { void watchdog() {
constexpr double kTimeout = 45.0; constexpr double kTimeout = 45.0;
for (;;) { for (;;) {
@ -138,7 +141,7 @@ void watchdog() {
g_beat = seconds(); g_beat = seconds();
continue; continue;
} }
if (seconds() - g_beat < kTimeout || sys::debuggerAttached()) continue; if (seconds() - g_beat < kTimeout || debuggerAttached()) continue;
#ifndef _WIN32 #ifndef _WIN32
pthread_kill(g_main, SIGUSR2); // its handler reports from the stuck thread (its backtrace) pthread_kill(g_main, SIGUSR2); // its handler reports from the stuck thread (its backtrace)
std::this_thread::sleep_for(std::chrono::seconds(3)); std::this_thread::sleep_for(std::chrono::seconds(3));
@ -150,8 +153,12 @@ void watchdog() {
void install(const std::string& prefPath, const std::vector<std::string>& args, bool dog) { void install(const std::string& prefPath, const std::vector<std::string>& args, bool dog) {
g_report = prefPath + "crash.txt"; g_report = prefPath + "crash.txt";
g_exe = sys::executablePath(); #ifdef _WIN32
#ifndef _WIN32 char exe[1024] = {};
GetModuleFileNameA(nullptr, exe, sizeof exe);
g_exe = exe;
#else
g_exe = std::filesystem::read_symlink("/proc/self/exe").string();
g_main = pthread_self(); g_main = pthread_self();
#endif #endif
g_args = {g_exe, "-crashreport", g_report, "--"}; g_args = {g_exe, "-crashreport", g_report, "--"};
@ -197,9 +204,6 @@ Busy::~Busy() {
} }
// ── The crash screen: SDL's 2D renderer and its built-in 8×8 font, scaled up ── // ── The crash screen: SDL's 2D renderer and its built-in 8×8 font, scaled up ──
// The crash screen: its own small program (this one, started again), drawn with SDL's own
// renderer rather than Vulkan or OpenGL (what crashed may have been the graphics driver), in the
// app's look: the same fonts, colours and keys.
int runReporter(int argc, char** argv) { int runReporter(int argc, char** argv) {
if (argc < 3 || std::strcmp(argv[1], "-crashreport") != 0) return -1; if (argc < 3 || std::strcmp(argv[1], "-crashreport") != 0) return -1;
std::string report; std::string report;
@ -218,200 +222,173 @@ int runReporter(int argc, char** argv) {
for (std::string l; std::getline(ss, l);) lines.push_back(l); for (std::string l; std::getline(ss, l);) lines.push_back(l);
} }
const std::string headline = lines.empty() ? "Fry3DX crashed." : lines[0]; const std::string headline = lines.empty() ? "Fry3DX crashed." : lines[0];
std::string details; // (the report without its headline)
for (size_t i = 1; i < lines.size(); ++i) details += lines[i] + "\n";
while (!details.empty() && details.front() == '\n') details.erase(0, 1);
if (!SDL_Init(SDL_INIT_VIDEO)) return 1; if (!SDL_Init(SDL_INIT_VIDEO)) return 1;
SDL_Window* win = nullptr; SDL_Window* win = nullptr;
SDL_Renderer* ren = nullptr; SDL_Renderer* ren = nullptr;
if (!SDL_CreateWindowAndRenderer("Fry3DX - something got burnt", 940, 640, SDL_WINDOW_RESIZABLE | SDL_WINDOW_HIGH_PIXEL_DENSITY, &win, &ren)) return 1; if (!SDL_CreateWindowAndRenderer("Fry3DX - something got burnt", 1000, 620, SDL_WINDOW_RESIZABLE | SDL_WINDOW_HIGH_PIXEL_DENSITY, &win, &ren)) return 1;
SDL_SetRenderVSync(ren, 1); SDL_SetRenderVSync(ren, 1);
const char* base = SDL_GetBasePath(); SDL_SetRenderDrawBlendMode(ren, SDL_BLENDMODE_BLEND);
const std::string exeDir = base ? base : "./"; auto text = [&](float x, float y, float scale, const std::string& s, SDL_Color c) {
fs::mountGameDir(exeDir + "engine"); // (the fonts) SDL_SetRenderScale(ren, scale, scale);
fs::mountGameDir(exeDir + "Fry3DX"); // (the logo) SDL_SetRenderDrawColor(ren, c.r, c.g, c.b, c.a);
SDL_RenderDebugText(ren, x / scale, y / scale, s.c_str());
IMGUI_CHECKVERSION(); SDL_SetRenderScale(ren, 1, 1);
ImGui::CreateContext(); };
ImGui::GetIO().IniFilename = nullptr; // Shapes for the picture: an ellipse (filled), a box.
ImGui_ImplSDL3_InitForSDLRenderer(win, ren); auto ellipse = [&](float cx, float cy, float rx, float ry, SDL_FColor c) {
ImGui_ImplSDLRenderer3_Init(ren); std::vector<SDL_Vertex> v;
ui::init(); const int n = 48;
const float density = SDL_GetWindowDisplayScale(win); for (int i = 0; i < n; ++i) {
ImGui::GetStyle().ScaleAllSizes(density); const float a0 = 6.2831853f * i / n, a1 = 6.2831853f * (i + 1) / n;
ImGui::GetStyle().FontScaleDpi = density; v.push_back({{cx, cy}, c, {}});
v.push_back({{cx + std::cos(a0) * rx, cy + std::sin(a0) * ry}, c, {}});
SDL_Texture* logo = nullptr; v.push_back({{cx + std::cos(a1) * rx, cy + std::sin(a1) * ry}, c, {}});
if (auto png = fs::read("branding/logo.png")) {
int w = 0, h = 0, n = 0;
if (stbi_uc* px = stbi_load_from_memory(png->data(), int(png->size()), &w, &h, &n, 4)) {
if ((logo = SDL_CreateTexture(ren, SDL_PIXELFORMAT_ABGR8888, SDL_TEXTUREACCESS_STATIC, w, h))) {
SDL_UpdateTexture(logo, nullptr, px, w * 4);
SDL_SetTextureBlendMode(logo, SDL_BLENDMODE_BLEND);
} }
stbi_image_free(px); SDL_RenderGeometry(ren, nullptr, v.data(), int(v.size()), nullptr, 0);
};
auto quad = [&](SDL_FPoint a, SDL_FPoint b, SDL_FPoint c, SDL_FPoint d, SDL_FColor col) {
const SDL_Vertex v[6] = {{a, col, {}}, {b, col, {}}, {c, col, {}}, {a, col, {}}, {c, col, {}}, {d, col, {}}};
SDL_RenderGeometry(ren, nullptr, v, 6, nullptr, 0);
};
auto rgb = [](int r, int g, int b, float a = 1.0f) { return SDL_FColor{r / 255.0f, g / 255.0f, b / 255.0f, a}; };
// Word wrap for the 8-pixel font.
auto wrap = [](const std::string& s, size_t cols) {
std::vector<std::string> out;
std::string line;
std::stringstream ss(s);
for (std::string w; ss >> w;) {
if (!line.empty() && line.size() + 1 + w.size() > cols) out.push_back(line), line.clear();
line += (line.empty() ? "" : " ") + w;
} }
} if (!line.empty()) out.push_back(line);
return out;
using ui::Role; };
auto col = [](Role r, float a = 1.0f) { return ImGui::ColorConvertU32ToFloat4(ui::color(r, a)); }; const SDL_Color ink{236, 232, 226, 255}, dim{150, 146, 140, 255}, orange{255, 140, 60, 255};
int scroll = 0, result = 0;
std::string toast; std::string toast;
double toastUntil = 0; double toastUntil = 0;
int frame = 0, choice = -1;
for (bool running = true; running;) { for (bool running = true; running;) {
for (SDL_Event e; SDL_PollEvent(&e);) { int w = 0, h = 0;
ImGui_ImplSDL3_ProcessEvent(&e); SDL_GetRenderOutputSize(ren, &w, &h);
if (e.type == SDL_EVENT_QUIT) running = false; const float s = std::min(h / 620.0f, w / 1000.0f);
}
ImGui_ImplSDLRenderer3_NewFrame();
ImGui_ImplSDL3_NewFrame();
ui::beginFrame(1080.0f); // (sizes as they are: the window's density is in FontScaleDpi)
ImGui::NewFrame();
const double t = SDL_GetTicks() / 1000.0; const double t = SDL_GetTicks() / 1000.0;
const float u = density; // (one point) struct Button { const char* label; float x; } buttons[] = {{"OPEN IT AGAIN", 60 * s}, {"COPY REPORT", 290 * s}, {"REPORT ON GITHUB", 520 * s}, {"CLOSE", 750 * s}};
const float by = h - 90 * s, bw = 210 * s, bh = 50 * s;
const ImGuiViewport* vp = ImGui::GetMainViewport(); float mx = 0, my = 0;
ImGui::SetNextWindowPos(vp->Pos); SDL_GetMouseState(&mx, &my);
ImGui::SetNextWindowSize(vp->Size); float px = 1, py = 1; // window → pixels
ImGui::PushStyleColor(ImGuiCol_WindowBg, col(Role::Background));
ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(36 * u, 44 * u));
ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 0);
ImGui::Begin("crash", nullptr, ImGuiWindowFlags_NoDecoration | ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoSavedSettings);
ImDrawList* dl = ImGui::GetWindowDrawList();
// The logo, gone dark at the edges, with smoke curling up off it.
const float size = 92 * u;
const ImVec2 at = ImGui::GetCursorScreenPos();
if (logo) dl->AddImage(ImTextureRef(ImTextureID(logo)), at, ImVec2(at.x + size, at.y + size));
for (int i = 0; i < 7; ++i) {
const double life = std::fmod(t * 0.35 + i / 7.0, 1.0);
const float x = at.x + size * (0.45f + 0.18f * float(std::sin(life * 5.0 + i * 1.7))), y = at.y + size * 0.3f - float(life) * 52 * u;
dl->AddCircleFilled(ImVec2(x, y), (4 + float(life) * 12) * u, ui::color(Role::TextTertiary, 0.28f * float(1.0 - life)), 24);
}
ImGui::Dummy(ImVec2(size, size));
ImGui::SameLine(0, 24 * u);
ImGui::BeginGroup();
ImGui::Dummy(ImVec2(0, 4 * u));
ImGui::PushStyleColor(ImGuiCol_Text, col(Role::AccentText));
ImGui::PushFont(ui::font(ui::Font::Bold), 13.0f);
ImGui::TextUnformatted("FRY3DX");
ImGui::PopFont();
ImGui::PopStyleColor();
ImGui::PushFont(ui::font(ui::Font::Title), 34.0f);
ImGui::TextUnformatted("Something got burnt.");
ImGui::PopFont();
ImGui::PushStyleColor(ImGuiCol_Text, col(Role::TextSecondary));
ImGui::PushTextWrapPos(ImGui::GetContentRegionMax().x);
ImGui::TextUnformatted(headline.c_str());
ImGui::PopTextWrapPos();
ImGui::PopStyleColor();
ImGui::EndGroup();
ImGui::Dummy(ImVec2(0, 14 * u));
// The good news, on a card of its own.
{ {
const ImVec2 a = ImGui::GetCursorScreenPos(); int ww, wh;
const float w = ImGui::GetContentRegionAvail().x, h = ImGui::GetTextLineHeight() + 26 * u; SDL_GetWindowSize(win, &ww, &wh);
dl->AddRectFilled(a, ImVec2(a.x + w, a.y + h), ui::color(Role::Success, 0.14f), 8 * u); px = float(w) / ww, py = float(h) / wh;
dl->AddRect(a, ImVec2(a.x + w, a.y + h), ui::color(Role::Success, 0.4f), 8 * u);
dl->AddCircleFilled(ImVec2(a.x + 18 * u, a.y + h / 2), 4 * u, ui::color(Role::Success));
ImGui::SetCursorScreenPos(ImVec2(a.x + 32 * u, a.y + 13 * u));
ImGui::PushStyleColor(ImGuiCol_Text, col(Role::SuccessText));
ImGui::TextUnformatted("Your work isn't lost: open it again and Fry3DX offers back the last autosave.");
ImGui::PopStyleColor();
ImGui::SetCursorScreenPos(ImVec2(a.x, a.y + h + 18 * u));
} }
mx *= px, my *= py;
// The report: what was going on when it happened, for a bug report. int clicked = -1;
ImGui::PushStyleColor(ImGuiCol_Text, col(Role::TextTertiary)); for (SDL_Event e; SDL_PollEvent(&e);) {
ImGui::PushTextWrapPos(0.0f); if (e.type == SDL_EVENT_QUIT) running = false;
ImGui::Text("The report, saved to %s", argv[2]); if (e.type == SDL_EVENT_KEY_DOWN) {
ImGui::PopTextWrapPos(); if (e.key.key == SDLK_ESCAPE || e.key.key == SDLK_Q) running = false;
ImGui::PopStyleColor(); if (e.key.key == SDLK_R) clicked = 0;
const float buttonsH = ImGui::GetFrameHeight() + 26 * u + ImGui::GetTextLineHeight(); if (e.key.key == SDLK_C) clicked = 1;
ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(14 * u, 12 * u));
ImGui::BeginChild("report", ImVec2(0, std::max(80 * u, ImGui::GetContentRegionAvail().y - buttonsH)), ImGuiChildFlags_FrameStyle | ImGuiChildFlags_AlwaysUseWindowPadding,
ImGuiWindowFlags_HorizontalScrollbar);
ImGui::PushFont(ui::font(ui::Font::Mono), 13.0f);
ImGui::PushStyleColor(ImGuiCol_Text, col(Role::TextSecondary));
ImGui::TextUnformatted(details.c_str());
ImGui::PopStyleColor();
ImGui::PopFont();
if (frame == 1) ImGui::SetScrollHereY(1.0f); // (the newest lines: the end)
ImGui::EndChild();
ImGui::PopStyleVar();
ImGui::Dummy(ImVec2(0, 10 * u));
const ImVec2 button(170 * u, ImGui::GetFrameHeight() + 10 * u);
ImGui::PushStyleColor(ImGuiCol_Button, col(Role::Accent));
ImGui::PushStyleColor(ImGuiCol_ButtonHovered, col(Role::AccentHover));
ImGui::PushStyleColor(ImGuiCol_ButtonActive, col(Role::AccentActive));
ImGui::PushStyleColor(ImGuiCol_Text, col(Role::OnAccent));
if (ImGui::Button("Open it again", button)) choice = 0;
ImGui::PopStyleColor(4);
ImGui::SameLine(0, 10 * u);
if (ImGui::Button("Copy the report", button)) choice = 1;
ImGui::SameLine(0, 10 * u);
if (ImGui::Button("Report it on GitHub", button)) choice = 2;
ImGui::SameLine(0, 10 * u);
if (ImGui::Button("Close", ImVec2(110 * u, button.y))) running = false;
if (!ImGui::GetIO().WantTextInput) {
if (ImGui::IsKeyPressed(ImGuiKey_R, false)) choice = 0;
if (ImGui::IsKeyPressed(ImGuiKey_C, false)) choice = 1;
if (ImGui::IsKeyPressed(ImGuiKey_Escape, false) || ImGui::IsKeyPressed(ImGuiKey_Q, false)) running = false;
} }
ImGui::PushStyleColor(ImGuiCol_Text, col(t < toastUntil ? Role::AccentText : Role::TextTertiary)); if (e.type == SDL_EVENT_MOUSE_WHEEL) scroll = std::max(0, scroll - int(e.wheel.y * 3));
ImGui::TextUnformatted(t < toastUntil ? toast.c_str() : "R open it again C copy Esc close"); if (e.type == SDL_EVENT_MOUSE_BUTTON_DOWN && e.button.button == SDL_BUTTON_LEFT)
ImGui::PopStyleColor(); for (int i = 0; i < 4; ++i)
ImGui::End(); if (mx >= buttons[i].x && mx < buttons[i].x + bw && my >= by && my < by + bh) clicked = i;
ImGui::PopStyleVar(2);
ImGui::PopStyleColor();
if (choice == 1 || choice == 2) {
SDL_SetClipboardText(report.c_str());
toast = choice == 1 ? "Copied: paste it into a bug report." : "Copied: paste it into the new issue.";
toastUntil = t + 3.0;
if (choice == 2) SDL_OpenURL("https://github.com/Nexoniarz/Fry3DX/issues/new");
choice = -1;
} }
if (clicked == 0) { // opened again as it was started (it offers the autosave back)
ImGui::Render(); std::vector<char*> a;
const ImVec4 bg = col(Role::Background); std::string exe = argv[0];
SDL_SetRenderDrawColorFloat(ren, bg.x, bg.y, bg.z, 1.0f); a.push_back(exe.data());
SDL_RenderClear(ren); for (std::string& r : restart) a.push_back(r.data());
ImGui_ImplSDLRenderer3_RenderDrawData(ImGui::GetDrawData(), ren); a.push_back(nullptr);
if (const char* shot = std::getenv("KEEL_CRASHSHOT"); shot && frame == 4) { // for checking the screen: one frame to a file, then quit
if (SDL_Surface* px = SDL_RenderReadPixels(ren, nullptr)) SDL_SaveBMP(px, shot), SDL_DestroySurface(px);
running = false;
}
SDL_RenderPresent(ren);
++frame;
if (choice == 0) break;
}
ImGui_ImplSDLRenderer3_Shutdown();
ImGui_ImplSDL3_Shutdown();
ImGui::DestroyContext();
if (logo) SDL_DestroyTexture(logo);
SDL_DestroyRenderer(ren); SDL_DestroyRenderer(ren);
SDL_DestroyWindow(win); SDL_DestroyWindow(win);
SDL_Quit(); SDL_Quit();
if (choice == 0) { // opened again as it was started (it offers the autosave back)
const std::string exe = sys::executablePath();
std::vector<char*> a;
std::string self = argv[0];
a.push_back(self.data());
for (std::string& r : restart) a.push_back(r.data());
a.push_back(nullptr);
#ifdef _WIN32 #ifdef _WIN32
_spawnv(_P_NOWAIT, exe.c_str(), a.data()); _spawnv(_P_NOWAIT, exe.c_str(), a.data());
return 0; return 0;
#else #else
execv(exe.c_str(), a.data()); execv(std::filesystem::read_symlink("/proc/self/exe").c_str(), a.data());
return 1; return 1;
#endif #endif
} }
return 0; if (clicked == 1 || clicked == 2) {
SDL_SetClipboardText(report.c_str());
toast = clicked == 1 ? "Copied: paste it into a bug report." : "Copied: paste it into the new issue.";
toastUntil = t + 3.0;
if (clicked == 2) SDL_OpenURL("https://github.com/Nexoniarz/Fry3DX/issues/new");
}
if (clicked == 3) running = false;
SDL_SetRenderDrawColor(ren, 22, 22, 25, 255);
SDL_RenderClear(ren);
// The picture, top right: a pan, a cube on it burnt black, smoke going up.
{
const float cx = w - 230 * s, cy = 170 * s;
quad({cx + 80 * s, cy - 6 * s}, {cx + 200 * s, cy - 30 * s}, {cx + 204 * s, cy - 16 * s}, {cx + 84 * s, cy + 8 * s}, rgb(58, 58, 64)); // handle
ellipse(cx, cy + 10 * s, 105 * s, 34 * s, rgb(34, 34, 38));
ellipse(cx, cy, 105 * s, 34 * s, rgb(70, 70, 78));
ellipse(cx, cy + 2 * s, 88 * s, 26 * s, rgb(28, 28, 32));
for (int i = 0; i < 3; ++i) { // flames under it
const float f = float(std::sin(t * 7 + i * 2.1) * 0.5 + 0.5);
const float fx = cx + (i - 1) * 50 * s;
ellipse(fx, cy + 46 * s, (14 + 4 * f) * s, (12 + 6 * f) * s, rgb(255, 74, 28, 0.85f));
ellipse(fx, cy + 48 * s, 7 * s, (6 + 4 * f) * s, rgb(255, 194, 74, 0.9f));
}
const float k = 26 * s; // the cube (seen from a corner), charred
quad({cx, cy - 2 * k}, {cx + k, cy - 1.5f * k}, {cx, cy - k}, {cx - k, cy - 1.5f * k}, rgb(64, 52, 46));
quad({cx - k, cy - 1.5f * k}, {cx, cy - k}, {cx, cy}, {cx - k, cy - 0.5f * k}, rgb(40, 32, 28));
quad({cx, cy - k}, {cx + k, cy - 1.5f * k}, {cx + k, cy - 0.5f * k}, {cx, cy}, rgb(26, 21, 19));
for (int i = 0; i < 6; ++i) { // smoke, rising and fading
const double life = std::fmod(t * 0.45 + i / 6.0, 1.0);
const float sx = cx + float(std::sin(life * 6 + i) * 18 * s), sy = cy - 2.2f * k - float(life * 80 * s);
ellipse(sx, sy, float(10 + life * 22) * s, float(8 + life * 16) * s, rgb(150, 150, 158, float(0.35 * (1 - life))));
}
}
text(60 * s, 60 * s, 4 * s, "Fry3DX", orange);
text(60 * s, 110 * s, 2.5f * s, "Something got burnt.", ink);
float y = 160 * s;
for (const std::string& l : wrap(headline, size_t((w - 400 * s) / (16 * s))))
text(60 * s, y, 2 * s, l, ink), y += 24 * s;
y += 6 * s;
for (const std::string& l : wrap("Your work isn't lost: Open it again and Fry3DX offers back the last autosave.", size_t((w - 400 * s) / (12 * s))))
text(60 * s, y, 1.5f * s, l, {130, 210, 120, 255}), y += 18 * s;
text(60 * s, y + 4 * s, 1.25f * s, "Report saved to " + std::string(argv[2]), dim);
y += 30 * s;
// The report's tail, in a panel, scrollable.
const SDL_FRect panel{50 * s, y - 8 * s, w - 100 * s, by - 20 * s - (y - 8 * s)};
SDL_SetRenderDrawColor(ren, 15, 15, 17, 255);
SDL_RenderFillRect(ren, &panel);
SDL_SetRenderDrawColor(ren, 255, 120, 40, 255);
const SDL_FRect edge{panel.x, panel.y, 3 * s, panel.h};
SDL_RenderFillRect(ren, &edge);
const int visible = std::max(1, int((panel.h - 10 * s) / (13 * s)));
const int start = std::clamp(int(lines.size()) - visible - scroll, 1, std::max(1, int(lines.size()) - 1));
for (int i = start; i < int(lines.size()) && y < by - 30 * s; ++i, y += 13 * s)
text(64 * s, y, 1.25f * s, lines[size_t(i)].substr(0, size_t((w - 140 * s) / (10 * s))), {165, 160, 152, 255});
for (int i = 0; i < 4; ++i) {
const bool hover = mx >= buttons[i].x && mx < buttons[i].x + bw && my >= by && my < by + bh;
SDL_FRect r{buttons[i].x, by, bw, bh};
if (i == 0 || hover) SDL_SetRenderDrawColor(ren, hover ? 255 : 220, hover ? 140 : 100, hover ? 60 : 30, 255);
else SDL_SetRenderDrawColor(ren, 46, 46, 52, 255);
SDL_RenderFillRect(ren, &r);
const float n = float(std::strlen(buttons[i].label)), ts = std::min(1.75f * s, (bw - 24 * s) / (n * 8)); // (fits its button)
text(buttons[i].x + (bw - n * 8 * ts) / 2, by + bh / 2 - 4 * ts, ts, buttons[i].label, ink);
}
text(60 * s, by + bh + 12 * s, 1.25f * s, "R open again C copy Esc close", dim);
if (t < toastUntil) text(60 * s, by - 16 * s, 1.5f * s, toast, orange);
if (const char* shot = std::getenv("KEEL_CRASHSHOT")) { // for checking the screen: one frame to a file, then quit
if (SDL_Surface* px = SDL_RenderReadPixels(ren, nullptr)) SDL_SaveBMP(px, shot), SDL_DestroySurface(px);
running = false;
}
SDL_RenderPresent(ren);
}
SDL_DestroyRenderer(ren);
SDL_DestroyWindow(win);
SDL_Quit();
return result;
} }
} // namespace keel::crash } // namespace keel::crash

View file

@ -4,7 +4,6 @@
#include "core/console.h" #include "core/console.h"
#include "core/cvar.h" #include "core/cvar.h"
#include "core/log.h" #include "core/log.h"
#include "core/sysinfo.h"
#include "core/time.h" #include "core/time.h"
#include "fs/vfs.h" #include "fs/vfs.h"
#include "platform/input.h" #include "platform/input.h"
@ -290,10 +289,6 @@ int runClient(Game& game) {
#if defined(__ANDROID__) #if defined(__ANDROID__)
if (first != RenderApi::Vulkan) first = RenderApi::GLES30; if (first != RenderApi::Vulkan) first = RenderApi::GLES30;
const std::initializer_list<RenderApi> all{RenderApi::Vulkan, RenderApi::GLES30}; const std::initializer_list<RenderApi> all{RenderApi::Vulkan, RenderApi::GLES30};
#elif defined(__APPLE__)
// (A Mac: Vulkan through MoltenVK, else OpenGL as far as macOS has it: 4.1.)
if (first != RenderApi::Vulkan) first = RenderApi::OpenGL40;
const std::initializer_list<RenderApi> all{RenderApi::Vulkan, RenderApi::OpenGL40};
#else #else
const std::initializer_list<RenderApi> all{RenderApi::Vulkan, RenderApi::OpenGL, RenderApi::OpenGL40, RenderApi::OpenGL31, RenderApi::GLES30}; const std::initializer_list<RenderApi> all{RenderApi::Vulkan, RenderApi::OpenGL, RenderApi::OpenGL40, RenderApi::OpenGL31, RenderApi::GLES30};
#endif #endif
@ -361,26 +356,16 @@ int runClient(Game& game) {
else game.onEscape(); else game.onEscape();
continue; continue;
} }
if (ev.type == SDL_EVENT_DROP_FILE && ev.drop.data) {
game.onFileDropped(ev.drop.data);
continue;
}
// Don't let the toggle key's character land in the console input. // Don't let the toggle key's character land in the console input.
if (ev.type == SDL_EVENT_TEXT_INPUT && ev.text.text[0] == '`') continue; if (ev.type == SDL_EVENT_TEXT_INPUT && ev.text.text[0] == '`') continue;
input::handleEvent(ev); input::handleEvent(ev);
if (!input::captured()) ImGui_ImplSDL3_ProcessEvent(&ev); if (!input::captured()) ImGui_ImplSDL3_ProcessEvent(&ev);
if (ev.type == SDL_EVENT_QUIT && game.allowQuit()) g_quit = true; if (ev.type == SDL_EVENT_QUIT && game.allowQuit()) g_quit = true;
if (ev.type == SDL_EVENT_WINDOW_PIXEL_SIZE_CHANGED) renderer.onResize(); if (ev.type == SDL_EVENT_WINDOW_PIXEL_SIZE_CHANGED) renderer.onResize();
// (a phone: SDL waits once these are seen, until the app's back; the surface is new then)
if (ev.type == SDL_EVENT_WILL_ENTER_BACKGROUND || ev.type == SDL_EVENT_LOW_MEMORY || ev.type == SDL_EVENT_TERMINATING) game.onBackground();
#if defined(__ANDROID__)
if (ev.type == SDL_EVENT_DID_ENTER_FOREGROUND) renderer.onSurfaceLost();
#endif
} }
const bool focused = SDL_GetWindowFlags(window.handle()) & SDL_WINDOW_INPUT_FOCUS; const bool focused = SDL_GetWindowFlags(window.handle()) & SDL_WINDOW_INPUT_FOCUS;
audio::setFocused(focused); audio::setFocused(focused);
const bool mode = vid_mode.consumeModified(), width = vid_width.consumeModified(), height = vid_height.consumeModified(); // (each taken, none skipped) if (vid_mode.consumeModified() | vid_width.consumeModified() | vid_height.consumeModified())
if (mode || width || height)
window.setMode(vid_mode.i(), std::max(vid_width.i(), 640), std::max(vid_height.i(), 480)); window.setMode(vid_mode.i(), std::max(vid_width.i(), 640), std::max(vid_height.i(), 480));
// Menus need no more than fps_max_menu (like Source's): high frame rates there only heat // Menus need no more than fps_max_menu (like Source's): high frame rates there only heat
// the GPU and make coil whine (often heard in headphones on the onboard jack). // the GPU and make coil whine (often heard in headphones on the onboard jack).
@ -458,17 +443,20 @@ const char* buildVersion() { return KEEL_COMMIT ", " KEEL_COMMIT_DATE; }
// Replaces this process with a fresh one (Windows: starts it, then this one exits). // Replaces this process with a fresh one (Windows: starts it, then this one exits).
void relaunch(const std::vector<std::string>& args) { void relaunch(const std::vector<std::string>& args) {
std::vector<std::string> a = args; std::vector<std::string> a = args;
const std::string exe = sys::executablePath();
#ifdef _WIN32 #ifdef _WIN32
char exe[1024] = {};
GetModuleFileNameA(nullptr, exe, sizeof exe);
for (std::string& x : a) for (std::string& x : a)
if (x.find(' ') != std::string::npos) x = "\"" + x + "\""; if (x.find(' ') != std::string::npos) x = "\"" + x + "\"";
#else
const std::string exe = std::filesystem::read_symlink("/proc/self/exe").string();
#endif #endif
std::vector<char*> argv; std::vector<char*> argv;
for (std::string& x : a) argv.push_back(x.data()); for (std::string& x : a) argv.push_back(x.data());
argv.push_back(nullptr); argv.push_back(nullptr);
log::info("Restarting"); log::info("Restarting");
#ifdef _WIN32 #ifdef _WIN32
_spawnv(_P_NOWAIT, exe.c_str(), argv.data()); _spawnv(_P_NOWAIT, exe, argv.data());
#else #else
execv(exe.c_str(), argv.data()); execv(exe.c_str(), argv.data());
log::error("Restart failed"); log::error("Restart failed");

View file

@ -33,12 +33,6 @@ public:
// The window's close button (or the OS asking it to close): false keeps it open (to ask // The window's close button (or the OS asking it to close): false keeps it open (to ask
// about saving first; the game quits later itself). The quit command always quits. // about saving first; the game quits later itself). The quit command always quits.
virtual bool allowQuit() { return true; } virtual bool allowQuit() { return true; }
// A file dropped on the window, or opened with the program from the desktop (a Mac's Finder
// hands it over this way, not on the command line).
virtual void onFileDropped(const std::string& path) { (void)path; }
// Going into the background (a phone: another app, the home screen), or the system is short
// of memory: the work should be kept now, as the app may be closed while it's away.
virtual void onBackground() {}
// Hot reload: called twice a second (hotreload 1) after the engine reloaded what changed // Hot reload: called twice a second (hotreload 1) after the engine reloaded what changed
// (`changed`: textures, models, sounds); the game reloads its own (scripts, the map). // (`changed`: textures, models, sounds); the game reloads its own (scripts, the map).
virtual void hotReload(const std::vector<std::string>& changed) { (void)changed; } virtual void hotReload(const std::vector<std::string>& changed) { (void)changed; }

View file

@ -21,14 +21,7 @@ namespace {
// one) unless asked. Vulkan picks the discrete GPU itself; this makes OpenGL do the same. Set // one) unless asked. Vulkan picks the discrete GPU itself; this makes OpenGL do the same. Set
// before SDL loads the GL library; anything the user already set wins. KEEL_IGPU=1 opts out. // before SDL loads the GL library; anything the user already set wins. KEEL_IGPU=1 opts out.
void preferDedicatedGpu() { void preferDedicatedGpu() {
#if defined(__APPLE__) #ifndef _WIN32
// macOS has no Vulkan of its own: the app carries MoltenVK (Vulkan on Metal) in
// Contents/Frameworks. (Unless the user pointed SDL somewhere else.)
if (const char* base = SDL_GetBasePath(); base && !SDL_GetHint(SDL_HINT_VULKAN_LIBRARY)) {
const std::filesystem::path mvk = std::filesystem::path(base) / ".." / "Frameworks" / "libMoltenVK.dylib";
if (std::filesystem::exists(mvk)) SDL_SetHint(SDL_HINT_VULKAN_LIBRARY, mvk.lexically_normal().string().c_str());
}
#elif !defined(_WIN32)
if (std::getenv("KEEL_IGPU")) return; if (std::getenv("KEEL_IGPU")) return;
if (std::filesystem::exists("/proc/driver/nvidia")) { // NVIDIA's own driver: PRIME render offload if (std::filesystem::exists("/proc/driver/nvidia")) { // NVIDIA's own driver: PRIME render offload
setenv("__NV_PRIME_RENDER_OFFLOAD", "1", 0); setenv("__NV_PRIME_RENDER_OFFLOAD", "1", 0);
@ -47,11 +40,7 @@ bool Window::init(const char* title, int width, int height, bool opengl) {
log::error("SDL_Init failed: {}", SDL_GetError()); log::error("SDL_Init failed: {}", SDL_GetError());
return false; return false;
} }
SDL_WindowFlags flags = (opengl ? SDL_WINDOW_OPENGL : SDL_WINDOW_VULKAN) | SDL_WINDOW_RESIZABLE | SDL_WINDOW_HIGH_PIXEL_DENSITY; window_ = SDL_CreateWindow(title, width, height, (opengl ? SDL_WINDOW_OPENGL : SDL_WINDOW_VULKAN) | SDL_WINDOW_RESIZABLE | SDL_WINDOW_HIGH_PIXEL_DENSITY);
#if defined(__ANDROID__)
flags |= SDL_WINDOW_FULLSCREEN; // (the whole screen: no status or navigation bar over the edges)
#endif
window_ = SDL_CreateWindow(title, width, height, flags);
if (!window_) { if (!window_) {
log::error("SDL_CreateWindow failed: {}", SDL_GetError()); log::error("SDL_CreateWindow failed: {}", SDL_GetError());
return false; return false;

View file

@ -14,20 +14,8 @@
#include <string> #include <string>
#include <vector> #include <vector>
struct ImDrawList;
struct ImDrawCmd;
namespace keel { namespace keel {
// Draw callbacks round an sRGB picture drawn in the UI, so it's read as the plain numbers ImGui's
// own colours are (read as sRGB it turns linear, and looks too dark). None when uiTexture already
// gives such a view (Vulkan).
struct UiRawRead {
void (*begin)(const ImDrawList*, const ImDrawCmd*) = nullptr;
void (*end)(const ImDrawList*, const ImDrawCmd*) = nullptr;
void* data = nullptr;
};
class Window; class Window;
using TextureId = uint32_t; using TextureId = uint32_t;
@ -65,8 +53,7 @@ struct DrawData { // DRAW_FIELDS in model.glsl
glm::vec4 cube[6]; glm::vec4 cube[6];
glm::vec4 params; // x = sun visibility, y = lit by probes, z = liquid cut, w = LiquidStyle glm::vec4 params; // x = sun visibility, y = lit by probes, z = liquid cut, w = LiquidStyle
glm::vec4 plane; // liquid surface glm::vec4 plane; // liquid surface
glm::vec4 probe; // the nearest reflection probe: x = its texture (0 = none), y = its top level; z = time (s); glm::vec4 probe; // the nearest reflection probe: x = its texture (0 = none), y = its top level; z = time (s)
// w = its own surface without a material map: 0 none, else 1 + metal (0..255) + rough (0..255) * 256
}; };
struct FrameData { // FRAME_FIELDS in model.glsl, then (Vulkan) four buffer addresses struct FrameData { // FRAME_FIELDS in model.glsl, then (Vulkan) four buffer addresses
glm::mat4 cascades[4]; glm::mat4 cascades[4];
@ -218,8 +205,6 @@ public:
virtual void beginUi() = 0; virtual void beginUi() = 0;
virtual void setVsync(bool on) = 0; virtual void setVsync(bool on) = 0;
virtual void onResize() = 0; virtual void onResize() = 0;
// Back from the background (a phone): the window's surface may be a new one.
virtual void onSurfaceLost() { onResize(); }
// A texture in table slot `slot`, mips largest first: RGBA8 pixels, or BC7 blocks (YAPF 2's // A texture in table slot `slot`, mips largest first: RGBA8 pixels, or BC7 blocks (YAPF 2's
// GPU payload) when blockCompression() says the GPU takes them. // GPU payload) when blockCompression() says the GPU takes them.
virtual void createTexture(TextureId slot, uint32_t width, uint32_t height, bool srgb, const std::vector<std::vector<uint8_t>>& mips, virtual void createTexture(TextureId slot, uint32_t width, uint32_t height, bool srgb, const std::vector<std::vector<uint8_t>>& mips,
@ -231,7 +216,6 @@ public:
virtual void uploadModel(uint32_t model, const KmdlModel& kmdl) = 0; virtual void uploadModel(uint32_t model, const KmdlModel& kmdl) = 0;
// A texture as Dear ImGui's ImTextureID, for drawing it in the UI (0 = none). // A texture as Dear ImGui's ImTextureID, for drawing it in the UI (0 = none).
virtual uint64_t uiTexture(TextureId slot) = 0; virtual uint64_t uiTexture(TextureId slot) = 0;
virtual UiRawRead uiRawRead(TextureId) { return {}; }
// Starts a frame; false when nothing can be drawn (minimised). Sets the drawable size. // Starts a frame; false when nothing can be drawn (minimised). Sets the drawable size.
virtual bool beginFrame(uint32_t& width, uint32_t& height) = 0; virtual bool beginFrame(uint32_t& width, uint32_t& height) = 0;
// Renders the packet, then ImGui's draw data, and presents. // Renders the packet, then ImGui's draw data, and presents.

View file

@ -100,7 +100,7 @@ glm::mat4 viewProjection(const Camera& c, float aspect) {
const glm::vec3 forward = forwardOf(c); const glm::vec3 forward = forwardOf(c);
const glm::vec3 up = glm::vec3(glm::rotate(glm::mat4(1), glm::radians(c.roll), forward) * glm::vec4(0, 0, 1, 0)); const glm::vec3 up = glm::vec3(glm::rotate(glm::mat4(1), glm::radians(c.roll), forward) * glm::vec4(0, 0, 1, 0));
glm::mat4 view = glm::lookAt(c.origin, c.origin + forward, up); glm::mat4 view = glm::lookAt(c.origin, c.origin + forward, up);
glm::mat4 proj = glm::perspective(glm::radians(c.fovY), aspect, c.zNear, c.zFar); glm::mat4 proj = glm::perspective(glm::radians(c.fovY), aspect, 2.0f, 16384.0f);
proj[1][1] *= -1.0f; // clip-space Y points down (Vulkan; OpenGL is set up to match) proj[1][1] *= -1.0f; // clip-space Y points down (Vulkan; OpenGL is set up to match)
proj[2][0] -= c.shift.x, proj[2][1] -= c.shift.y; proj[2][0] -= c.shift.x, proj[2][1] -= c.shift.y;
return proj * view; return proj * view;
@ -211,32 +211,18 @@ void Renderer::drawSprite(const glm::vec3& at, float size, const glm::vec4& colo
list.push_back({glm::vec4(at, size * 0.5f), color, glm::vec4(0, 0, 0, rotation), glm::vec4(0, 0, 0, tex)}); list.push_back({glm::vec4(at, size * 0.5f), color, glm::vec4(0, 0, 0, rotation), glm::vec4(0, 0, 0, tex)});
} }
void Renderer::drawGrid(const glm::dvec3& under, const glm::vec3& u, const glm::vec3& v, float metre, bool dark, float reach) { void Renderer::drawStreak(const glm::vec3& from, const glm::vec3& to, float width, const glm::vec4& color, TextureId texture, bool additive) {
if (packet_.sprites.size() >= 8192) return;
// Its middle a whole number of kilometres along u and v (so the lines are worked out from
// small numbers, sprite.frag): a square round it out to `reach` past the eye.
const double km = 1000.0 * double(metre);
const glm::dvec3 du(u), dv(v);
const double cu = std::round(glm::dot(under, du) / km) * km, cv = std::round(glm::dot(under, dv) / km) * km;
const glm::dvec3 centre = du * cu + dv * cv;
const glm::vec2 eye(float(glm::dot(under, du) - cu), float(glm::dot(under, dv) - cv));
const float half = reach + glm::length(eye);
// (colour.a < 0 marks it; sprite.vert / sprite.frag)
packet_.sprites.push_back({glm::vec4(glm::vec3(centre), half), glm::vec4(eye, 0.0f, dark ? -1.0f : -2.0f), glm::vec4(u, metre), glm::vec4(v, reach)});
}
void Renderer::drawStreak(const glm::vec3& from, const glm::vec3& to, float width, const glm::vec4& color, TextureId texture, bool additive, float widthTo) {
auto& list = additive ? packet_.glowSprites : packet_.sprites; auto& list = additive ? packet_.glowSprites : packet_.sprites;
if (list.size() >= 8192) return; if (list.size() >= 8192) return;
uint32_t bits = texture; uint32_t bits = texture;
float tex; float tex;
std::memcpy(&tex, &bits, 4); std::memcpy(&tex, &bits, 4);
// up.xyz: half its length along it (non-zero marks a streak); right.w: half its width at `to`. // up.xyz: half its length along it (non-zero marks a streak; its side is found per frame).
list.push_back({glm::vec4((from + to) * 0.5f, width * 0.5f), color, glm::vec4(0.0f, 0.0f, 0.0f, (widthTo < 0.0f ? width : widthTo) * 0.5f), glm::vec4((to - from) * 0.5f, tex)}); list.push_back({glm::vec4((from + to) * 0.5f, width * 0.5f), color, glm::vec4(0.0f), glm::vec4((to - from) * 0.5f, tex)});
} }
void Renderer::drawModel(ModelId model, const glm::mat4& transform, const glm::vec4& tint, const std::vector<glm::mat4>* skin, void Renderer::drawModel(ModelId model, const glm::mat4& transform, const glm::vec4& tint, const std::vector<glm::mat4>* skin,
const ModelLighting* lighting, bool castShadow, const glm::vec4* liquidPlane, LiquidStyle liquid, const Surface* surface) { const ModelLighting* lighting, bool castShadow, const glm::vec4* liquidPlane, LiquidStyle liquid) {
if (!model.valid() || packet_.draws.size() >= kMaxDraws) return; if (!model.valid() || packet_.draws.size() >= kMaxDraws) return;
ModelDraw d{model.index, transform, tint}; ModelDraw d{model.index, transform, tint};
d.castShadow = castShadow || shadowOnly_; d.castShadow = castShadow || shadowOnly_;
@ -275,8 +261,6 @@ void Renderer::drawModel(ModelId model, const glm::mat4& transform, const glm::v
} }
static const auto t0 = std::chrono::steady_clock::now(); // water's waves move with this static const auto t0 = std::chrono::steady_clock::now(); // water's waves move with this
dd.probe.z = hasClock_ ? float(std::fmod(clock_, 3600.0)) : std::fmod(std::chrono::duration<float>(std::chrono::steady_clock::now() - t0).count(), 3600.0f); dd.probe.z = hasClock_ ? float(std::fmod(clock_, 3600.0)) : std::fmod(std::chrono::duration<float>(std::chrono::steady_clock::now() - t0).count(), 3600.0f);
if (surface)
dd.probe.w = 1.0f + std::round(std::clamp(surface->metal, 0.0f, 1.0f) * 255.0f) + std::round(std::clamp(surface->rough, 0.0f, 1.0f) * 255.0f) * 256.0f;
packet_.draws.push_back(dd); packet_.draws.push_back(dd);
if (skin && mdl.skinned && packet_.joints.size() + skin->size() <= kMaxJointsPerFrame) { if (skin && mdl.skinned && packet_.joints.size() + skin->size() <= kMaxJointsPerFrame) {
d.firstJoint = uint32_t(packet_.joints.size()); d.firstJoint = uint32_t(packet_.joints.size());
@ -461,13 +445,11 @@ void Renderer::buildPacket(uint32_t width, uint32_t height) {
for (const glm::vec3& q : corners) radius = std::max(radius, glm::length(q - center)); for (const glm::vec3& q : corners) radius = std::max(radius, glm::length(q - center));
radius = std::ceil(radius / 16.0f) * 16.0f; radius = std::ceil(radius / 16.0f) * 16.0f;
const float texel = 2.0f * radius / cascadeSize; const float texel = 2.0f * radius / cascadeSize;
// (in doubles: far from the origin a float's steps are near a texel's, and it'd shimmer) glm::mat4 lightView = glm::lookAt(glm::vec3(0), -sunDir_, up);
const glm::dmat4 lightView0 = glm::lookAt(glm::dvec3(0), -glm::dvec3(sunDir_), glm::dvec3(up)); glm::vec4 lc = lightView * glm::vec4(center, 1);
glm::dvec4 lc = lightView0 * glm::dvec4(glm::dvec3(center), 1);
lc.x = std::floor(lc.x / texel) * texel; lc.x = std::floor(lc.x / texel) * texel;
lc.y = std::floor(lc.y / texel) * texel; lc.y = std::floor(lc.y / texel) * texel;
center = glm::vec3(glm::inverse(lightView0) * lc); center = glm::vec3(glm::inverse(lightView) * lc);
glm::mat4 lightView;
lightView = glm::lookAt(center + sunDir_ * 8000.0f, center, up); lightView = glm::lookAt(center + sunDir_ * 8000.0f, center, up);
frame.cascades[c] = glm::ortho(-radius, radius, -radius, radius, 1.0f, 16000.0f) * lightView; frame.cascades[c] = glm::ortho(-radius, radius, -radius, radius, 1.0f, 16000.0f) * lightView;
frame.splits[c] = f; frame.splits[c] = f;
@ -761,7 +743,7 @@ void Renderer::buildPacket(uint32_t width, uint32_t height) {
p.exposure *= glm::mix(1.0f, 0.3f, std::clamp(p.frame.weather.z, 0.0f, 1.0f)); p.exposure *= glm::mix(1.0f, 0.3f, std::clamp(p.frame.weather.z, 0.0f, 1.0f));
const float tanY = std::tan(glm::radians(camera_.fovY) * 0.5f); const float tanY = std::tan(glm::radians(camera_.fovY) * 0.5f);
p.post.aoStrength = std::clamp(r_ssao.f(), 0.0f, 1.0f); // (the TAA fields were set with the camera above) p.post.aoStrength = std::clamp(r_ssao.f(), 0.0f, 1.0f); // (the TAA fields were set with the camera above)
p.post.aoRadius = std::max(r_ssao_radius.f(), 0.001f); // (small for close-up work: a modeller sets it to its view) p.post.aoRadius = std::max(r_ssao_radius.f(), 1.0f);
p.post.bloomStrength = std::clamp(r_bloom.f(), 0.0f, 1.0f); p.post.bloomStrength = std::clamp(r_bloom.f(), 0.0f, 1.0f);
p.post.saturation = r_saturation.f(); p.post.saturation = r_saturation.f();
p.post.vignette = r_vignette.f(); p.post.vignette = r_vignette.f();
@ -773,16 +755,16 @@ void Renderer::buildPacket(uint32_t width, uint32_t height) {
up = glm::cross(right, f); up = glm::cross(right, f);
for (auto* list : {&p.sprites, &p.glowSprites}) for (auto* list : {&p.sprites, &p.glowSprites})
for (gpu::Sprite& s : *list) { for (gpu::Sprite& s : *list) {
if (s.color.a < 0.0f) continue; // (a grid: on its own plane) if (const glm::vec3 half(s.up); glm::dot(half, half) > 0.0f) { // a streak: its side faces the eye
if (const glm::vec3 half(s.up); glm::dot(half, half) > 0.0f) { // a streak: each end's side faces the eye (sprite.vert) glm::vec3 side = glm::cross(half, camera_.origin - glm::vec3(s.posSize));
s.right = glm::vec4(camera_.origin, s.right.w); side = glm::length(side) > 1e-6f ? glm::normalize(side) : right;
s.posSize.w = -std::max(s.posSize.w, 1e-9f); // (below 0: a streak; its half width at `from`) s.right = glm::vec4(side * s.posSize.w, 0.0f);
s.posSize.w = 1.0f;
continue; continue;
} }
s.right = glm::vec4(right, s.right.w), s.up = glm::vec4(up, s.up.w); s.right = glm::vec4(right, s.right.w), s.up = glm::vec4(up, s.up.w);
} }
std::sort(p.sprites.begin(), p.sprites.end(), [&](const gpu::Sprite& a, const gpu::Sprite& b) { std::sort(p.sprites.begin(), p.sprites.end(), [&](const gpu::Sprite& a, const gpu::Sprite& b) {
if ((a.color.a < 0.0f) != (b.color.a < 0.0f)) return a.color.a < 0.0f; // (grids first: they're under everything)
return glm::dot(glm::vec3(a.posSize) - camera_.origin, f) > glm::dot(glm::vec3(b.posSize) - camera_.origin, f); return glm::dot(glm::vec3(a.posSize) - camera_.origin, f) > glm::dot(glm::vec3(b.posSize) - camera_.origin, f);
}); });
} }
@ -805,7 +787,7 @@ void Renderer::buildPacket(uint32_t width, uint32_t height) {
p.post.sunColor = glm::vec4(sunColor_, 0.0f); p.post.sunColor = glm::vec4(sunColor_, 0.0f);
} }
} }
p.post.proj = glm::vec4(tanY * float(width) / float(height), tanY, camera_.zNear, camera_.zFar); p.post.proj = glm::vec4(tanY * float(width) / float(height), tanY, 2.0f, 16384.0f);
p.clearColor = glm::vec3(srgbToLinear(sky.r), srgbToLinear(sky.g), srgbToLinear(sky.b)) / p.exposure; p.clearColor = glm::vec3(srgbToLinear(sky.r), srgbToLinear(sky.g), srgbToLinear(sky.b)) / p.exposure;
// The mirror's pass: this frame's data, but seen from the reflected eye, cut at the mirror, // The mirror's pass: this frame's data, but seen from the reflected eye, cut at the mirror,
// lit by every light (the clusters are this view's), and no fog grid (that's this view's too). // lit by every light (the clusters are this view's), and no fog grid (that's this view's too).

View file

@ -22,15 +22,9 @@ struct Camera {
float roll = 0.0f; // degrees, positive tilts right float roll = 0.0f; // degrees, positive tilts right
float fovY = 74.0f; // degrees float fovY = 74.0f; // degrees
glm::vec2 shift{0.0f}; // where the picture's middle sits on the screen (-1..1 across, down): a view beside panels glm::vec2 shift{0.0f}; // where the picture's middle sits on the screen (-1..1 across, down): a view beside panels
float zNear = 2.0f, zFar = 16384.0f; // what's nearer or further isn't drawn (units)
}; };
// Baked light for a moving model (from the map's probes): ambient cube + sun visibility. // Baked light for a moving model (from the map's probes): ambient cube + sun visibility.
// A whole model's surface where it has no material map: how metal (0..1) and how rough (0..1).
struct Surface {
float metal = 0.0f, rough = 0.85f;
};
struct ModelLighting { struct ModelLighting {
glm::vec3 cube[6]; // light arriving from +X -X +Y -Y +Z -Z glm::vec3 cube[6]; // light arriving from +X -X +Y -Y +Z -Z
float sunVisibility = 1.0f; float sunVisibility = 1.0f;
@ -77,7 +71,6 @@ public:
// A loaded texture for ImGui::Image (ImTextureID); 0 if it isn't one. // A loaded texture for ImGui::Image (ImTextureID); 0 if it isn't one.
uint64_t uiTexture(TextureId texture) { return backend_ ? backend_->uiTexture(texture) : 0; } uint64_t uiTexture(TextureId texture) { return backend_ ? backend_->uiTexture(texture) : 0; }
UiRawRead uiRawRead(TextureId texture) { return backend_ ? backend_->uiRawRead(texture) : UiRawRead{}; }
// Pixel size of a loaded texture. // Pixel size of a loaded texture.
glm::ivec2 textureSize(TextureId texture) const { glm::ivec2 textureSize(TextureId texture) const {
const auto it = textureSizes_.find(texture); const auto it = textureSizes_.find(texture);
@ -98,20 +91,14 @@ public:
// A particle: a camera-facing square `size` across, unlit (color can be HDR-bright). // A particle: a camera-facing square `size` across, unlit (color can be HDR-bright).
// additive: light added on (fire, sparks, flashes); else blended (smoke, dust). // additive: light added on (fire, sparks, flashes); else blended (smoke, dust).
void drawSprite(const glm::vec3& at, float size, const glm::vec4& color, TextureId texture, float rotation = 0.0f, bool additive = true); void drawSprite(const glm::vec3& at, float size, const glm::vec4& color, TextureId texture, float rotation = 0.0f, bool additive = true);
// A sprite stretched from `from` to `to`, `width` across (`widthTo` at `to`: < 0, the same), // A sprite stretched from `from` to `to`, `width` across, turned to face the eye as much as
// each end turned to face the eye as much as it can (rain; lines drawn in pieces meet exactly). // it can (rain).
void drawStreak(const glm::vec3& from, const glm::vec3& to, float width, const glm::vec4& color, TextureId texture, bool additive = false, float widthTo = -1.0f); void drawStreak(const glm::vec3& from, const glm::vec3& to, float width, const glm::vec4& color, TextureId texture, bool additive = false);
// A grid of lines on the plane through the origin along u and v (unit), drawn for each pixel:
// every 10^n metres (`metre`: units in one), each set showing by how far apart its lines are
// on the screen, the axes coloured; dark lines (on the sunny ground) or light ones (in the
// grey room); fading out `reach` from `under` (the point under the eye, as a double: far out,
// a float's too coarse for lines a pixel wide).
void drawGrid(const glm::dvec3& under, const glm::vec3& u, const glm::vec3& v, float metre, bool dark, float reach);
// skin: one matrix per joint (anim::skinMatrices) for skinned models; ignored otherwise. // skin: one matrix per joint (anim::skinMatrices) for skinned models; ignored otherwise.
// lighting: probe light for moving things (nullptr = lightmap or simple sun shading). // lighting: probe light for moving things (nullptr = lightmap or simple sun shading).
void drawModel(ModelId model, const glm::mat4& transform, const glm::vec4& tint = glm::vec4(1.0f), void drawModel(ModelId model, const glm::mat4& transform, const glm::vec4& tint = glm::vec4(1.0f),
const std::vector<glm::mat4>* skin = nullptr, const ModelLighting* lighting = nullptr, bool castShadow = true, const std::vector<glm::mat4>* skin = nullptr, const ModelLighting* lighting = nullptr, bool castShadow = true,
const glm::vec4* liquidPlane = nullptr, LiquidStyle liquid = LiquidStyle::None, const Surface* surface = nullptr); const glm::vec4* liquidPlane = nullptr, LiquidStyle liquid = LiquidStyle::None);
// liquidPlane (world space: xyz normal, w distance): draws a closed mesh as liquid filling it // liquidPlane (world space: xyz normal, w distance): draws a closed mesh as liquid filling it
// up to the plane — above it is cut away, and its back faces show as the flat surface. // up to the plane — above it is cut away, and its back faces show as the flat surface.
// Alpha below 1 (tint × material) makes a submesh translucent: sorted, blended, after opaque. // Alpha below 1 (tint × material) makes a submesh translucent: sorted, blended, after opaque.
@ -164,9 +151,6 @@ public:
void onResize() { void onResize() {
if (backend_) backend_->onResize(); if (backend_) backend_->onResize();
} }
void onSurfaceLost() {
if (backend_) backend_->onSurfaceLost();
}
private: private:
struct Submesh { struct Submesh {

View file

@ -232,12 +232,6 @@ public:
gl::SamplerParameteri(clampSampler_, GL_TEXTURE_MAG_FILTER, GL_LINEAR); gl::SamplerParameteri(clampSampler_, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
gl::SamplerParameteri(clampSampler_, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); gl::SamplerParameteri(clampSampler_, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
gl::SamplerParameteri(clampSampler_, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE); gl::SamplerParameteri(clampSampler_, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
gl::CreateSamplers(1, &uiRawSampler_); // (UI pictures: sRGB read as plain numbers, as ImGui's colours are)
gl::SamplerParameteri(uiRawSampler_, GL_TEXTURE_MIN_FILTER, GL_LINEAR_MIPMAP_LINEAR);
gl::SamplerParameteri(uiRawSampler_, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
gl::SamplerParameteri(uiRawSampler_, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
gl::SamplerParameteri(uiRawSampler_, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
gl::SamplerParameteri(uiRawSampler_, 0x8A48 /* GL_TEXTURE_SRGB_DECODE_EXT */, 0x8A4A /* GL_SKIP_DECODE_EXT */);
gl::CreateSamplers(1, &compareSampler_); gl::CreateSamplers(1, &compareSampler_);
gl::SamplerParameteri(compareSampler_, GL_TEXTURE_MIN_FILTER, GL_LINEAR); gl::SamplerParameteri(compareSampler_, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
gl::SamplerParameteri(compareSampler_, GL_TEXTURE_MAG_FILTER, GL_LINEAR); gl::SamplerParameteri(compareSampler_, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
@ -326,7 +320,7 @@ public:
} }
for (GLuint b : {textureTable_, shadowTable_, draws_, joints_, frame_, mirrorFrame_, lights_, views_, cells_, indices_, push_, empty_}) for (GLuint b : {textureTable_, shadowTable_, draws_, joints_, frame_, mirrorFrame_, lights_, views_, cells_, indices_, push_, empty_})
gl::DeleteBuffers(1, &b); gl::DeleteBuffers(1, &b);
for (GLuint s : {repeatSampler_, clampSampler_, compareSampler_, uiRawSampler_}) gl::DeleteSamplers(1, &s); for (GLuint s : {repeatSampler_, clampSampler_, compareSampler_}) gl::DeleteSamplers(1, &s);
for (GLuint p : {meshProgram_, skinnedProgram_, shadowMeshProgram_, shadowSkinnedProgram_, boxProgram_, tonemapProgram_, skyProgram_, hzbProgram_, ssaoProgram_, ssaoBlurProgram_, ssrProgram_, adaptProgram_, volumetricProgram_, spriteProgram_, for (GLuint p : {meshProgram_, skinnedProgram_, shadowMeshProgram_, shadowSkinnedProgram_, boxProgram_, tonemapProgram_, skyProgram_, hzbProgram_, ssaoProgram_, ssaoBlurProgram_, ssrProgram_, adaptProgram_, volumetricProgram_, spriteProgram_,
bloomDownProgram_, bloomUpProgram_, taaProgram_}) bloomDownProgram_, bloomUpProgram_, taaProgram_})
gl::DeleteProgram(p); gl::DeleteProgram(p);
@ -364,7 +358,6 @@ public:
gl::TextureSubImage2D(t.texture, GLint(level), 0, 0, GLsizei(std::max(1u, w >> level)), GLsizei(std::max(1u, h >> level)), GL_RGBA, gl::TextureSubImage2D(t.texture, GLint(level), 0, 0, GLsizei(std::max(1u, w >> level)), GLsizei(std::max(1u, h >> level)), GL_RGBA,
GL_UNSIGNED_BYTE, mips[level].data()); GL_UNSIGNED_BYTE, mips[level].data());
} }
t.srgb = srgb;
if (auto it = textures_.find(slot); it != textures_.end()) retiredTextures_.emplace_back(frameNumber_, it->second); // queued draws may use it if (auto it = textures_.find(slot); it != textures_.end()) retiredTextures_.emplace_back(frameNumber_, it->second); // queued draws may use it
setHandles(slot, t, repeatSampler_, false); // like Vulkan: one repeating sampler for every texture setHandles(slot, t, repeatSampler_, false); // like Vulkan: one repeating sampler for every texture
textures_[slot] = t; textures_[slot] = t;
@ -392,13 +385,6 @@ public:
const auto it = textures_.find(slot); const auto it = textures_.find(slot);
return it == textures_.end() ? 0 : uint64_t(it->second.texture); return it == textures_.end() ? 0 : uint64_t(it->second.texture);
} }
UiRawRead uiRawRead(TextureId slot) override {
const auto it = textures_.find(slot);
if (it == textures_.end() || !it->second.srgb) return {};
// (A sampler, not the texture's own setting: that's fixed once it has a bindless handle.)
return {[](const ImDrawList*, const ImDrawCmd* c) { gl::BindSampler(0, GLuint(uintptr_t(c->UserCallbackData))); },
[](const ImDrawList*, const ImDrawCmd*) { gl::BindSampler(0, 0); }, reinterpret_cast<void*>(uintptr_t(uiRawSampler_))};
}
bool beginFrame(uint32_t& width, uint32_t& height) override { bool beginFrame(uint32_t& width, uint32_t& height) override {
int w = 0, h = 0; int w = 0, h = 0;
@ -438,7 +424,6 @@ private:
struct Texture { struct Texture {
GLuint texture = 0; GLuint texture = 0;
GLuint64 handle = 0, shadowHandle = 0; GLuint64 handle = 0, shadowHandle = 0;
bool srgb = false;
}; };
struct Model { struct Model {
GLuint vertices = 0, indices = 0, skin = 0; GLuint vertices = 0, indices = 0, skin = 0;
@ -544,7 +529,7 @@ private:
SDL_Window* window_ = nullptr; SDL_Window* window_ = nullptr;
SDL_GLContext context_ = nullptr; SDL_GLContext context_ = nullptr;
bool imgui_ = false; bool imgui_ = false;
GLuint repeatSampler_ = 0, clampSampler_ = 0, compareSampler_ = 0, uiRawSampler_ = 0; GLuint repeatSampler_ = 0, clampSampler_ = 0, compareSampler_ = 0;
GLuint mirrorFrame_ = 0; // the mirror's pass's frame data GLuint mirrorFrame_ = 0; // the mirror's pass's frame data
GLuint textureTable_ = 0, shadowTable_ = 0, draws_ = 0, joints_ = 0, frame_ = 0, lights_ = 0, views_ = 0, cells_ = 0, indices_ = 0; GLuint textureTable_ = 0, shadowTable_ = 0, draws_ = 0, joints_ = 0, frame_ = 0, lights_ = 0, views_ = 0, cells_ = 0, indices_ = 0;
GLuint push_ = 0, empty_ = 0, vao_ = 0; GLuint push_ = 0, empty_ = 0, vao_ = 0;

View file

@ -100,11 +100,7 @@ namespace {
X(void, DrawBuffer, (GLenum)) \ X(void, DrawBuffer, (GLenum)) \
X(void, DrawBuffers, (GLsizei, const GLenum*)) \ X(void, DrawBuffers, (GLsizei, const GLenum*)) \
X(void, TexBuffer, (GLenum, GLenum, GLuint)) \ X(void, TexBuffer, (GLenum, GLenum, GLuint)) \
X(void, BindFragDataLocation, (GLuint, GLuint, const GLchar*)) \ X(void, BindFragDataLocation, (GLuint, GLuint, const GLchar*))
X(void, GenSamplers, (GLsizei, GLuint*)) \
X(void, DeleteSamplers, (GLsizei, const GLuint*)) \
X(void, BindSampler, (GLuint, GLuint)) \
X(void, SamplerParameteri, (GLuint, GLenum, GLint))
namespace glx { namespace glx {
#define KEEL_GLX_DECLARE(ret, name, params) using name##_t = ret(APIENTRY*) params; name##_t name = nullptr; #define KEEL_GLX_DECLARE(ret, name, params) using name##_t = ret(APIENTRY*) params; name##_t name = nullptr;
@ -366,72 +362,24 @@ void main() { o_color = vec4(u_color.rgb * (0.5 + 0.5 * max(dot(v_normal, normal
const char* kSpriteVert = R"( const char* kSpriteVert = R"(
uniform mat4 u_viewProj; uniform mat4 u_viewProj;
uniform vec4 u_posSize, u_right, u_up; // right.w: turn (radians) uniform vec4 u_posSize, u_right, u_up; // right.w: turn (radians)
uniform vec4 u_color;
out vec2 v_uv; out vec2 v_uv;
out vec2 v_rel; // (a grid: where on it, from its middle)
const vec2 kQuad[6] = vec2[](vec2(-1,-1), vec2(1,-1), vec2(1,1), vec2(-1,-1), vec2(1,1), vec2(-1,1)); const vec2 kQuad[6] = vec2[](vec2(-1,-1), vec2(1,-1), vec2(1,1), vec2(-1,-1), vec2(1,1), vec2(-1,1));
void main() { void main() {
vec2 q = kQuad[gl_VertexID]; vec2 q = kQuad[gl_VertexID];
float c = cos(u_right.w), s = sin(u_right.w); float c = cos(u_right.w), s = sin(u_right.w);
vec2 r = vec2(c * q.x - s * q.y, s * q.x + c * q.y); vec2 r = vec2(c * q.x - s * q.y, s * q.x + c * q.y);
vec3 world = u_posSize.xyz + (u_right.xyz * r.x + u_up.xyz * r.y) * u_posSize.w; vec3 world = u_posSize.xyz + (u_right.xyz * r.x + u_up.xyz * r.y) * u_posSize.w;
if (u_color.a < 0.0) world = u_posSize.xyz + (u_right.xyz * q.x + u_up.xyz * q.y) * u_posSize.w; // a grid: a square on its plane
else if (u_posSize.w < 0.0) { // a streak: right.xyz the eye, w its half width at the far end; each end's side faces the eye
vec3 end = u_posSize.xyz + u_up.xyz * q.y, side = cross(u_up.xyz, u_right.xyz - end);
world = end + (length(side) > 1e-9 ? normalize(side) : vec3(0.0, 0.0, 1.0)) * q.x * (q.y < 0.0 ? -u_posSize.w : u_right.w);
}
v_uv = vec2(q.x * 0.5 + 0.5, 0.5 - q.y * 0.5); v_uv = vec2(q.x * 0.5 + 0.5, 0.5 - q.y * 0.5);
v_rel = u_color.a < 0.0 ? q * u_posSize.w : vec2(0.0);
gl_Position = u_viewProj * vec4(world, 1.0); gl_Position = u_viewProj * vec4(world, 1.0);
gl_Position.z = gl_Position.z * 2.0 - gl_Position.w; gl_Position.z = gl_Position.z * 2.0 - gl_Position.w;
} }
)"; )";
const char* kSpriteFrag = R"( const char* kSpriteFrag = R"(
in vec2 v_uv; in vec2 v_uv;
in vec2 v_rel;
uniform sampler2D u_albedo; uniform sampler2D u_albedo;
uniform vec4 u_color, u_posSize, u_right, u_up; uniform vec4 u_color;
out vec4 o_color; out vec4 o_color;
// A grid (a sprite with colour.a < 0: -1 dark lines, -2 light ones), worked out for each pixel
// where it lies on its plane: lines every 10^n metres (`metre`: units in one), the four sets from
// the finest that isn't crowded together here, each showing by how far apart its lines are on the
// screen (none where they crowd, stronger where they're far apart: nothing jumps as the view
// moves), about a pixel wide, smooth; the axes through the middle in the colour of the world's
// axis they run along. All from `rel`, where it is from the square's middle (a whole number of
// kilometres from the world's: far out, the world's own numbers are too coarse for lines a
// pixel wide); `eye`: the point under the eye, from the middle; it fades out `reach` from there.
vec3 gridAxisColor(vec3 d) {
d = abs(d);
return d.x > 0.5 ? vec3(0.9, 0.25, 0.25) : d.y > 0.5 ? vec3(0.3, 0.75, 0.25) : vec3(0.25, 0.45, 0.95);
}
vec4 gridColor(vec2 rel, vec3 centre, vec3 u, vec3 v, float metre, float kind, vec2 eye, float reach) {
vec2 base = vec2(dot(centre, u), dot(centre, v)); // (the middle: whole kilometres)
vec2 fw = max(fwidth(rel), vec2(1e-9));
float finest = floor(log(min(fw.x, fw.y) * 5.0 / metre) / log(10.0)); // (10^this m: 5 pixels apart or more)
vec3 look = kind < -1.5 ? vec3(1.0, 0.05, 0.16) : vec3(0.0, 0.14, 0.4); // (shade, how faint, how strong)
float lines = 0.0;
for (int k = 0; k < 4; ++k) {
float s = metre * pow(10.0, finest + float(k));
vec2 apart = s / fw; // (pixels between this set's lines, each way)
vec2 off = abs(fract(fract(base / s) + rel / s + 0.5) - 0.5) * apart; // (pixels to the nearest of them)
vec2 strong = smoothstep(20.0, 160.0, apart);
vec2 cover = clamp(0.5 * (1.0 + 0.6 * strong) + 0.5 - off, 0.0, 1.0);
vec2 a = cover * smoothstep(5.0, 20.0, apart) * mix(vec2(look.y), vec2(look.z), strong);
lines = max(lines, max(a.x, a.y));
}
vec2 axis = clamp(1.25 - abs(base + rel) / fw, 0.0, 1.0) * 0.9; // (x: the line along v, y: along u)
float fade = smoothstep(reach, reach * 0.6, length(rel - eye));
vec3 col = vec3(look.x) * lines;
float a = lines;
col = mix(col, gridAxisColor(v), axis.x), a = mix(a, 1.0, axis.x);
col = mix(col, gridAxisColor(u), axis.y), a = mix(a, 1.0, axis.y);
return vec4(col / max(a, 1e-6), a * fade);
}
void main() { void main() {
if (u_color.a < 0.0) {
o_color = gridColor(v_rel, u_posSize.xyz, normalize(u_right.xyz), normalize(u_up.xyz), u_right.w, u_color.a, u_color.xy, u_up.w);
return;
}
vec4 t = texture(u_albedo, v_uv); vec4 t = texture(u_albedo, v_uv);
o_color = vec4(t.rgb * u_color.rgb, clamp((t.a - 0.03) / 0.97, 0.0, 1.0) * u_color.a); o_color = vec4(t.rgb * u_color.rgb, clamp((t.a - 0.03) / 0.97, 0.0, 1.0) * u_color.a);
} }
@ -551,14 +499,6 @@ public:
ImGui::GetIO().IniFilename = nullptr; ImGui::GetIO().IniFilename = nullptr;
ImGui::StyleColorsDark(); ImGui::StyleColorsDark();
imgui_ = ImGui_ImplSDL3_InitForOpenGL(window_, context_) && ImGui_ImplOpenGL3_Init(es_ ? "#version 300 es" : "#version 140"); imgui_ = ImGui_ImplSDL3_InitForOpenGL(window_, context_) && ImGui_ImplOpenGL3_Init(es_ ? "#version 300 es" : "#version 140");
if (glx::GenSamplers && glx::BindSampler && glx::SamplerParameteri) { // (3.3 on: UI pictures read as plain numbers, as ImGui's colours are)
glx::GenSamplers(1, &uiRawSampler_);
glx::SamplerParameteri(uiRawSampler_, GL_TEXTURE_MIN_FILTER, GL_LINEAR_MIPMAP_LINEAR);
glx::SamplerParameteri(uiRawSampler_, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
glx::SamplerParameteri(uiRawSampler_, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
glx::SamplerParameteri(uiRawSampler_, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
glx::SamplerParameteri(uiRawSampler_, 0x8A48 /* GL_TEXTURE_SRGB_DECODE_EXT */, 0x8A4A /* GL_SKIP_DECODE_EXT */);
}
log::info("Fallback renderer: tier {} ({})", tier_, tier_ >= 2 ? "shadows, normal maps, highlights, sky reflections, 16 lights" : "lightmaps, sun shadows, sky"); log::info("Fallback renderer: tier {} ({})", tier_, tier_ >= 2 ? "shadows, normal maps, highlights, sky reflections, 16 lights" : "lightmaps, sun shadows, sky");
return imgui_; return imgui_;
} }
@ -575,7 +515,6 @@ public:
for (GLuint t : textures_) for (GLuint t : textures_)
if (t) glx::DeleteTextures(1, &t); if (t) glx::DeleteTextures(1, &t);
for (Model& m : models_) releaseModel(m); for (Model& m : models_) releaseModel(m);
if (uiRawSampler_) glx::DeleteSamplers(1, &uiRawSampler_);
releaseTargets(); releaseTargets();
for (GLuint* t : {&shadowMap_, &jointTexture_}) glx::DeleteTextures(1, t); for (GLuint* t : {&shadowMap_, &jointTexture_}) glx::DeleteTextures(1, t);
glx::DeleteFramebuffers(1, &shadowFbo_); glx::DeleteFramebuffers(1, &shadowFbo_);
@ -599,8 +538,7 @@ public:
// No block compression here (old GPUs): the renderer decodes BC7 to RGBA8 first. // No block compression here (old GPUs): the renderer decodes BC7 to RGBA8 first.
void createTexture(TextureId slot, uint32_t w, uint32_t h, bool srgb, const std::vector<std::vector<uint8_t>>& mips, void createTexture(TextureId slot, uint32_t w, uint32_t h, bool srgb, const std::vector<std::vector<uint8_t>>& mips,
TexFormat = TexFormat::RGBA8) override { TexFormat = TexFormat::RGBA8) override {
if (slot >= textures_.size()) textures_.resize(slot + 1, 0), srgb_.resize(slot + 1, false); if (slot >= textures_.size()) textures_.resize(slot + 1, 0);
srgb_[slot] = srgb;
GLuint& t = textures_[slot]; GLuint& t = textures_[slot];
if (!t) glx::GenTextures(1, &t); if (!t) glx::GenTextures(1, &t);
glx::BindTexture(GL_TEXTURE_2D, t); glx::BindTexture(GL_TEXTURE_2D, t);
@ -647,22 +585,6 @@ public:
} }
uint64_t uiTexture(TextureId slot) override { return slot < textures_.size() ? uint64_t(textures_[slot]) : 0; } uint64_t uiTexture(TextureId slot) override { return slot < textures_.size() ? uint64_t(textures_[slot]) : 0; }
// (A sampler object where there are any; else the texture's own setting, switched for the draw and back.)
UiRawRead uiRawRead(TextureId slot) override {
if (slot >= textures_.size() || !srgb_[slot]) return {};
if (uiRawSampler_)
return {[](const ImDrawList*, const ImDrawCmd* c) { glx::BindSampler(0, GLuint(uintptr_t(c->UserCallbackData))); },
[](const ImDrawList*, const ImDrawCmd*) { glx::BindSampler(0, 0); }, reinterpret_cast<void*>(uintptr_t(uiRawSampler_))};
return {[](const ImDrawList*, const ImDrawCmd* c) {
glx::BindTexture(GL_TEXTURE_2D, GLuint(uintptr_t(c->UserCallbackData)));
glx::TexParameteri(GL_TEXTURE_2D, 0x8A48 /* GL_TEXTURE_SRGB_DECODE_EXT */, 0x8A4A /* GL_SKIP_DECODE_EXT */);
},
[](const ImDrawList*, const ImDrawCmd* c) {
glx::BindTexture(GL_TEXTURE_2D, GLuint(uintptr_t(c->UserCallbackData)));
glx::TexParameteri(GL_TEXTURE_2D, 0x8A48 /* GL_TEXTURE_SRGB_DECODE_EXT */, 0x8A49 /* GL_DECODE_EXT */);
},
reinterpret_cast<void*>(uintptr_t(textures_[slot]))};
}
bool beginFrame(uint32_t& width, uint32_t& height) override { bool beginFrame(uint32_t& width, uint32_t& height) override {
int w = 0, h = 0; int w = 0, h = 0;
@ -800,8 +722,6 @@ private:
bool imgui_ = false; bool imgui_ = false;
Program mesh_, depth_, sky_, box_, tonemap_, sprite_; Program mesh_, depth_, sky_, box_, tonemap_, sprite_;
std::vector<GLuint> textures_; std::vector<GLuint> textures_;
std::vector<bool> srgb_;
GLuint uiRawSampler_ = 0;
std::vector<Model> models_; std::vector<Model> models_;
GLuint frameUbo_ = 0, jointBuffer_ = 0, jointTexture_ = 0, emptyVao_ = 0; GLuint frameUbo_ = 0, jointBuffer_ = 0, jointTexture_ = 0, emptyVao_ = 0;
GLuint shadowMap_ = 0, shadowFbo_ = 0; GLuint shadowMap_ = 0, shadowFbo_ = 0;

View file

@ -34,7 +34,6 @@
GLenum format, GLsizei size, const void* data)) \ GLenum format, GLsizei size, const void* data)) \
X(void, CreateSamplers, (GLsizei n, GLuint* samplers)) \ X(void, CreateSamplers, (GLsizei n, GLuint* samplers)) \
X(void, DeleteSamplers, (GLsizei n, const GLuint* samplers)) \ X(void, DeleteSamplers, (GLsizei n, const GLuint* samplers)) \
X(void, BindSampler, (GLuint unit, GLuint sampler)) \
X(void, SamplerParameteri, (GLuint sampler, GLenum pname, GLint param)) \ X(void, SamplerParameteri, (GLuint sampler, GLenum pname, GLint param)) \
X(void, SamplerParameterf, (GLuint sampler, GLenum pname, GLfloat param)) \ X(void, SamplerParameterf, (GLuint sampler, GLenum pname, GLfloat param)) \
X(GLuint64, GetTextureSamplerHandleARB, (GLuint texture, GLuint sampler)) \ X(GLuint64, GetTextureSamplerHandleARB, (GLuint texture, GLuint sampler)) \

View file

@ -404,7 +404,6 @@ public:
} }
void setVsync(bool on) override { device_.setVsync(on); } void setVsync(bool on) override { device_.setVsync(on); }
void onResize() override { device_.markResized(); } void onResize() override { device_.markResized(); }
void onSurfaceLost() override { device_.markSurfaceLost(); }
bool renderScale() const override { return true; } bool renderScale() const override { return true; }
bool blockCompression() const override { bool blockCompression() const override {
@ -426,7 +425,6 @@ public:
ici.arrayLayers = 1; ici.arrayLayers = 1;
ici.samples = VK_SAMPLE_COUNT_1_BIT; ici.samples = VK_SAMPLE_COUNT_1_BIT;
ici.usage = VK_IMAGE_USAGE_SAMPLED_BIT | VK_IMAGE_USAGE_TRANSFER_DST_BIT; ici.usage = VK_IMAGE_USAGE_SAMPLED_BIT | VK_IMAGE_USAGE_TRANSFER_DST_BIT;
if (srgb) ici.flags = VK_IMAGE_CREATE_MUTABLE_FORMAT_BIT; // (the UI reads it as plain numbers: uiView)
VmaAllocationCreateInfo aci{}; VmaAllocationCreateInfo aci{};
aci.usage = VMA_MEMORY_USAGE_AUTO; aci.usage = VMA_MEMORY_USAGE_AUTO;
VK_CHECK(vmaCreateImage(device_.allocator(), &ici, &aci, &t.image, &t.alloc, nullptr)); VK_CHECK(vmaCreateImage(device_.allocator(), &ici, &aci, &t.image, &t.alloc, nullptr));
@ -456,12 +454,6 @@ public:
vci.format = format; vci.format = format;
vci.subresourceRange = {VK_IMAGE_ASPECT_COLOR_BIT, 0, uint32_t(mips.size()), 0, 1}; vci.subresourceRange = {VK_IMAGE_ASPECT_COLOR_BIT, 0, uint32_t(mips.size()), 0, 1};
VK_CHECK(vkCreateImageView(device_.device(), &vci, nullptr, &t.view)); VK_CHECK(vkCreateImageView(device_.device(), &vci, nullptr, &t.view));
// ImGui's colours are sRGB numbers drawn as they are, so the UI must read the picture without
// turning it linear first (else it shows too dark): the same image, seen as UNORM.
if (srgb) {
vci.format = fmt == TexFormat::BC7 ? VK_FORMAT_BC7_UNORM_BLOCK : VK_FORMAT_R8G8B8A8_UNORM;
VK_CHECK(vkCreateImageView(device_.device(), &vci, nullptr, &t.uiView));
}
if (textures_.size() <= slot) textures_.resize(slot + 1); if (textures_.size() <= slot) textures_.resize(slot + 1);
// Replaced (hot reload): frames in flight may still sample the old one. // Replaced (hot reload): frames in flight may still sample the old one.
if (textures_[slot].image) retiredTextures_.emplace_back(frameNumber_, textures_[slot]); if (textures_[slot].image) retiredTextures_.emplace_back(frameNumber_, textures_[slot]);
@ -492,10 +484,7 @@ public:
uiTextures_.erase(it), it = uiTextures_.end(); uiTextures_.erase(it), it = uiTextures_.end();
} }
if (it == uiTextures_.end()) if (it == uiTextures_.end())
it = uiTextures_.emplace(slot, UiTexture{uint64_t(ImGui_ImplVulkan_AddTexture(sampler_, textures_[slot].uiView ? textures_[slot].uiView : textures_[slot].view, it = uiTextures_.emplace(slot, UiTexture{uint64_t(ImGui_ImplVulkan_AddTexture(sampler_, textures_[slot].view, VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL)), textures_[slot].view}).first;
VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL)),
textures_[slot].view})
.first;
return it->second.set; return it->second.set;
} }
@ -546,7 +535,6 @@ private:
VkImage image = VK_NULL_HANDLE; VkImage image = VK_NULL_HANDLE;
VmaAllocation alloc = VK_NULL_HANDLE; VmaAllocation alloc = VK_NULL_HANDLE;
VkImageView view = VK_NULL_HANDLE; VkImageView view = VK_NULL_HANDLE;
VkImageView uiView = VK_NULL_HANDLE; // (an sRGB picture seen as UNORM, for the UI)
}; };
// A ray-tracing shape (bottom-level acceleration structure): one per submesh, by first index. // A ray-tracing shape (bottom-level acceleration structure): one per submesh, by first index.
struct Blas { struct Blas {
@ -561,7 +549,6 @@ private:
void destroyTexture(Texture& t) { void destroyTexture(Texture& t) {
if (t.view) vkDestroyImageView(device_.device(), t.view, nullptr); if (t.view) vkDestroyImageView(device_.device(), t.view, nullptr);
if (t.uiView) vkDestroyImageView(device_.device(), t.uiView, nullptr);
if (t.image) vmaDestroyImage(device_.allocator(), t.image, t.alloc); if (t.image) vmaDestroyImage(device_.allocator(), t.image, t.alloc);
t = {}; t = {};
} }
@ -684,7 +671,7 @@ private:
Texture hzbMips_[kHzbLevels]; // [0] unused (that's hzb_) Texture hzbMips_[kHzbLevels]; // [0] unused (that's hzb_)
uint32_t gpuCulled_ = 0; uint32_t gpuCulled_ = 0;
const vk::Buffer* cullFor_ = nullptr; // this frame's commands, while the opaque lists are drawn const vk::Buffer* cullFor_ = nullptr; // this frame's commands, while the opaque lists are drawn
bool gpuCulling() const override { return cullPipeline_ != VK_NULL_HANDLE; } bool gpuCulling() const override { return true; }
// The fog grid (froxel.comp): two cell buffers (this frame's, last frame's) and the integrated one. // The fog grid (froxel.comp): two cell buffers (this frame's, last frame's) and the integrated one.
struct FroxelPush { VkDeviceAddress frame, cur, prev, integrated; glm::vec4 proj, prevPos, prevFwd; uint32_t mode, frameIndex; VkDeviceAddress tlas, smoke, smokeSky; glm::vec4 smokeOrigin; }; struct FroxelPush { VkDeviceAddress frame, cur, prev, integrated; glm::vec4 proj, prevPos, prevFwd; uint32_t mode, frameIndex; VkDeviceAddress tlas, smoke, smokeSky; glm::vec4 smokeOrigin; };
struct SmokeSkyPush { VkDeviceAddress frame, sky; }; struct SmokeSkyPush { VkDeviceAddress frame, sky; };
@ -698,7 +685,7 @@ private:
VkPipeline froxelPipeline_ = VK_NULL_HANDLE, froxelRtPipeline_ = VK_NULL_HANDLE; // (rays: sky seen from each cell) VkPipeline froxelPipeline_ = VK_NULL_HANDLE, froxelRtPipeline_ = VK_NULL_HANDLE; // (rays: sky seen from each cell)
uint32_t froxelFrame_ = 0; uint32_t froxelFrame_ = 0;
glm::vec4 froxelPrevPos_{0.0f}, froxelPrevFwd_{0.0f}; glm::vec4 froxelPrevPos_{0.0f}, froxelPrevFwd_{0.0f};
bool froxelFog() const override { return froxelPipeline_ != VK_NULL_HANDLE; } bool froxelFog() const override { return true; }
// Real-time GI (gi.comp): the scene for rays (per frame in flight), and the probes (this // Real-time GI (gi.comp): the scene for rays (per frame in flight), and the probes (this
// frame's, last frame's). // frame's, last frame's).
struct Tlas { struct Tlas {
@ -937,10 +924,8 @@ void VulkanBackend::createPipelines() {
adaptPipeline_ = makePipeline(dev, layout_, {kFullscreenVert, sizeof kFullscreenVert, kAdaptFrag, sizeof kAdaptFrag, kSceneFormat, false}); adaptPipeline_ = makePipeline(dev, layout_, {kFullscreenVert, sizeof kFullscreenVert, kAdaptFrag, sizeof kAdaptFrag, kSceneFormat, false});
ssrPipeline_ = makePipeline(dev, layout_, {kFullscreenVert, sizeof kFullscreenVert, kSsrFrag, sizeof kSsrFrag, kSceneFormat, false}); ssrPipeline_ = makePipeline(dev, layout_, {kFullscreenVert, sizeof kFullscreenVert, kSsrFrag, sizeof kSsrFrag, kSceneFormat, false});
shadowMeshPipeline_ = makePipeline(dev, layout_, {kMeshVert, sizeof kMeshVert, nullptr, 0, VK_FORMAT_UNDEFINED, true, true}); shadowMeshPipeline_ = makePipeline(dev, layout_, {kMeshVert, sizeof kMeshVert, nullptr, 0, VK_FORMAT_UNDEFINED, true, true});
// Compute pipelines (GPU culling, the fog grid, ray-traced light), on the same layout. All of // Compute pipelines (GPU culling, the fog grid), on the same layout.
// them are extras: one a driver can't build is left out (and what it does is off). (The auto compute = [&](const uint32_t* code, size_t size, VkPipeline& out) {
// Galaxy S22's driver says it has ray queries but can't build gi.comp or froxel_rt.comp.)
auto compute = [&](const char* name, const uint32_t* code, size_t size, VkPipeline& out) {
VkShaderModuleCreateInfo ci{VK_STRUCTURE_TYPE_SHADER_MODULE_CREATE_INFO}; VkShaderModuleCreateInfo ci{VK_STRUCTURE_TYPE_SHADER_MODULE_CREATE_INFO};
ci.codeSize = size; ci.codeSize = size;
ci.pCode = code; ci.pCode = code;
@ -949,15 +934,12 @@ void VulkanBackend::createPipelines() {
VkComputePipelineCreateInfo pi{VK_STRUCTURE_TYPE_COMPUTE_PIPELINE_CREATE_INFO}; VkComputePipelineCreateInfo pi{VK_STRUCTURE_TYPE_COMPUTE_PIPELINE_CREATE_INFO};
pi.stage = {VK_STRUCTURE_TYPE_PIPELINE_SHADER_STAGE_CREATE_INFO, nullptr, 0, VK_SHADER_STAGE_COMPUTE_BIT, m, "main", nullptr}; pi.stage = {VK_STRUCTURE_TYPE_PIPELINE_SHADER_STAGE_CREATE_INFO, nullptr, 0, VK_SHADER_STAGE_COMPUTE_BIT, m, "main", nullptr};
pi.layout = layout_; pi.layout = layout_;
if (const VkResult res = vkCreateComputePipelines(dev, VK_NULL_HANDLE, 1, &pi, nullptr, &out); res != VK_SUCCESS) { VK_CHECK(vkCreateComputePipelines(dev, VK_NULL_HANDLE, 1, &pi, nullptr, &out));
log::warn("The graphics driver couldn't build the {} shader (VkResult {}): it's off", name, int(res));
out = VK_NULL_HANDLE;
}
vkDestroyShaderModule(dev, m, nullptr); vkDestroyShaderModule(dev, m, nullptr);
}; };
compute("GPU culling", kCullComp, sizeof kCullComp, cullPipeline_); compute(kCullComp, sizeof kCullComp, cullPipeline_);
compute("fog grid", kFroxelComp, sizeof kFroxelComp, froxelPipeline_); compute(kFroxelComp, sizeof kFroxelComp, froxelPipeline_);
if (device_.rayQuery()) compute("ray-traced light", kGiComp, sizeof kGiComp, giPipeline_), compute("ray-traced fog", kFroxelRtComp, sizeof kFroxelRtComp, froxelRtPipeline_); if (device_.rayQuery()) compute(kGiComp, sizeof kGiComp, giPipeline_), compute(kFroxelRtComp, sizeof kFroxelRtComp, froxelRtPipeline_);
// Depth pre-pass, and the scene's opaque meshes after it. // Depth pre-pass, and the scene's opaque meshes after it.
depthMeshPipeline_ = makePipeline(dev, layout_, {kMeshVert, sizeof kMeshVert, kDepthFrag, sizeof kDepthFrag, VK_FORMAT_UNDEFINED, true}); depthMeshPipeline_ = makePipeline(dev, layout_, {kMeshVert, sizeof kMeshVert, kDepthFrag, sizeof kDepthFrag, VK_FORMAT_UNDEFINED, true});
depthSkinnedPipeline_ = makePipeline(dev, layout_, {kSkinnedVert, sizeof kSkinnedVert, kDepthFrag, sizeof kDepthFrag, VK_FORMAT_UNDEFINED, true}); depthSkinnedPipeline_ = makePipeline(dev, layout_, {kSkinnedVert, sizeof kSkinnedVert, kDepthFrag, sizeof kDepthFrag, VK_FORMAT_UNDEFINED, true});
@ -1413,17 +1395,6 @@ void VulkanBackend::render(FramePacket& p) {
vkCmdSetScissor(cmd, 0, 1, &scissor); vkCmdSetScissor(cmd, 0, 1, &scissor);
drawMeshes(cmd, p.opaque, p.viewProj, false, depthMeshPipeline_, depthSkinnedPipeline_, cullFor_, true); drawMeshes(cmd, p.opaque, p.viewProj, false, depthMeshPipeline_, depthSkinnedPipeline_, cullFor_, true);
vkCmdEndRendering(cmd); vkCmdEndRendering(cmd);
// The scene pass tests against that depth: wait for it to be written (two render passes
// aren't ordered on their own; without this, tiles of it could still be the old depth).
VkMemoryBarrier2 mb{VK_STRUCTURE_TYPE_MEMORY_BARRIER_2};
mb.srcStageMask = VK_PIPELINE_STAGE_2_EARLY_FRAGMENT_TESTS_BIT | VK_PIPELINE_STAGE_2_LATE_FRAGMENT_TESTS_BIT;
mb.srcAccessMask = VK_ACCESS_2_DEPTH_STENCIL_ATTACHMENT_WRITE_BIT;
mb.dstStageMask = VK_PIPELINE_STAGE_2_EARLY_FRAGMENT_TESTS_BIT | VK_PIPELINE_STAGE_2_LATE_FRAGMENT_TESTS_BIT;
mb.dstAccessMask = VK_ACCESS_2_DEPTH_STENCIL_ATTACHMENT_READ_BIT | VK_ACCESS_2_DEPTH_STENCIL_ATTACHMENT_WRITE_BIT;
VkDependencyInfo dep{VK_STRUCTURE_TYPE_DEPENDENCY_INFO};
dep.memoryBarrierCount = 1;
dep.pMemoryBarriers = &mb;
vkCmdPipelineBarrier2(cmd, &dep);
mark(cmd, "depth pre-pass"); mark(cmd, "depth pre-pass");
depth.loadOp = VK_ATTACHMENT_LOAD_OP_LOAD; depth.loadOp = VK_ATTACHMENT_LOAD_OP_LOAD;
} }

View file

@ -46,11 +46,6 @@ static VKAPI_ATTR VkBool32 VKAPI_CALL debugCallback(VkDebugUtilsMessageSeverityF
bool Device::init(const Window& window, bool validation) { bool Device::init(const Window& window, bool validation) {
window_ = &window; window_ = &window;
#if defined(__APPLE__)
// The library SDL loaded for the window (the app's MoltenVK, or a Vulkan SDK's loader).
if (auto gipa = reinterpret_cast<PFN_vkGetInstanceProcAddr>(SDL_Vulkan_GetVkGetInstanceProcAddr())) volkInitializeCustom(gipa);
else
#endif
if (volkInitialize() != VK_SUCCESS) { if (volkInitialize() != VK_SUCCESS) {
log::error("No Vulkan loader found - is a Vulkan driver installed?"); log::error("No Vulkan loader found - is a Vulkan driver installed?");
return false; return false;
@ -232,9 +227,6 @@ bool Device::createSwapchain() {
vkDeviceWaitIdle(device_); vkDeviceWaitIdle(device_);
VkSwapchainKHR old = swapchain_; VkSwapchainKHR old = swapchain_;
auto sc = vkb::SwapchainBuilder(physical_, device_, surface_) auto sc = vkb::SwapchainBuilder(physical_, device_, surface_)
#if defined(__ANDROID__) // (the compositor turns the picture with the phone: drawn as it's seen)
.set_pre_transform_flags(VK_SURFACE_TRANSFORM_IDENTITY_BIT_KHR)
#endif
.set_desired_format({VK_FORMAT_B8G8R8A8_UNORM, VK_COLOR_SPACE_SRGB_NONLINEAR_KHR}) .set_desired_format({VK_FORMAT_B8G8R8A8_UNORM, VK_COLOR_SPACE_SRGB_NONLINEAR_KHR})
.set_desired_present_mode(vsync_ ? VK_PRESENT_MODE_FIFO_KHR : VK_PRESENT_MODE_MAILBOX_KHR) .set_desired_present_mode(vsync_ ? VK_PRESENT_MODE_FIFO_KHR : VK_PRESENT_MODE_MAILBOX_KHR)
.add_fallback_present_mode(vsync_ ? VK_PRESENT_MODE_FIFO_KHR : VK_PRESENT_MODE_IMMEDIATE_KHR) .add_fallback_present_mode(vsync_ ? VK_PRESENT_MODE_FIFO_KHR : VK_PRESENT_MODE_IMMEDIATE_KHR)
@ -263,24 +255,6 @@ bool Device::createSwapchain() {
return true; return true;
} }
// A new surface on the window as it is now (Android makes a new one each time the app comes back).
bool Device::recreateSurface() {
vkDeviceWaitIdle(device_);
destroySwapchainViews();
if (swapchain_) vkDestroySwapchainKHR(device_, swapchain_, nullptr), swapchain_ = VK_NULL_HANDLE;
if (surface_) vkDestroySurfaceKHR(instance_, surface_, nullptr), surface_ = VK_NULL_HANDLE;
if (!SDL_Vulkan_CreateSurface(window_->handle(), instance_, nullptr, &surface_)) {
log::warn("SDL_Vulkan_CreateSurface (again): {}", SDL_GetError());
surface_ = VK_NULL_HANDLE;
return false; // (tried again next frame)
}
VkBool32 present = VK_FALSE;
vkGetPhysicalDeviceSurfaceSupportKHR(physical_, queueFamily_, surface_, &present);
if (!present) log::warn("The new surface can't be presented to from this queue");
surfaceLost_ = false;
return createSwapchain();
}
void Device::destroySwapchainViews() { void Device::destroySwapchainViews() {
for (VkImageView v : views_) vkDestroyImageView(device_, v, nullptr); for (VkImageView v : views_) vkDestroyImageView(device_, v, nullptr);
for (VkSemaphore s : renderDone_) vkDestroySemaphore(device_, s, nullptr); for (VkSemaphore s : renderDone_) vkDestroySemaphore(device_, s, nullptr);
@ -289,7 +263,6 @@ void Device::destroySwapchainViews() {
} }
VkCommandBuffer Device::beginFrame() { VkCommandBuffer Device::beginFrame() {
if (surfaceLost_ && !recreateSurface()) return nullptr;
if (swapchainDirty_ && !createSwapchain()) return nullptr; if (swapchainDirty_ && !createSwapchain()) return nullptr;
Frame& f = frames_[frameIndex_]; Frame& f = frames_[frameIndex_];
@ -299,10 +272,6 @@ VkCommandBuffer Device::beginFrame() {
swapchainDirty_ = true; swapchainDirty_ = true;
return nullptr; return nullptr;
} }
if (r == VK_ERROR_SURFACE_LOST_KHR) {
surfaceLost_ = true;
return nullptr;
}
if (r != VK_SUBOPTIMAL_KHR) VK_CHECK(r); if (r != VK_SUBOPTIMAL_KHR) VK_CHECK(r);
VK_CHECK(vkResetFences(device_, 1, &f.inFlight)); VK_CHECK(vkResetFences(device_, 1, &f.inFlight));
@ -343,15 +312,7 @@ void Device::endFrame() {
pi.pSwapchains = &swapchain_; pi.pSwapchains = &swapchain_;
pi.pImageIndices = &imageIndex_; pi.pImageIndices = &imageIndex_;
VkResult r = vkQueuePresentKHR(queue_, &pi); VkResult r = vkQueuePresentKHR(queue_, &pi);
#if defined(__ANDROID__) // (suboptimal every frame while the phone's turned: the compositor turns it; only a new size matters)
if (r == VK_SUBOPTIMAL_KHR) {
uint32_t w = 0, h = 0;
window_->pixelSize(w, h);
if (w == extent_.width && h == extent_.height) r = VK_SUCCESS;
}
#endif
if (r == VK_ERROR_OUT_OF_DATE_KHR || r == VK_SUBOPTIMAL_KHR) swapchainDirty_ = true; if (r == VK_ERROR_OUT_OF_DATE_KHR || r == VK_SUBOPTIMAL_KHR) swapchainDirty_ = true;
else if (r == VK_ERROR_SURFACE_LOST_KHR) surfaceLost_ = true;
else VK_CHECK(r); else VK_CHECK(r);
frameIndex_ = (frameIndex_ + 1) % kFramesInFlight; frameIndex_ = (frameIndex_ + 1) % kFramesInFlight;

View file

@ -50,8 +50,6 @@ public:
void setVsync(bool vsync) { vsync_ = vsync; swapchainDirty_ = true; } void setVsync(bool vsync) { vsync_ = vsync; swapchainDirty_ = true; }
void markResized() { swapchainDirty_ = true; } void markResized() { swapchainDirty_ = true; }
// The window's surface went away (a phone: the app was in the background): made again.
void markSurfaceLost() { surfaceLost_ = true; }
void waitIdle() { vkDeviceWaitIdle(device_); } void waitIdle() { vkDeviceWaitIdle(device_); }
VkInstance instance() const { return instance_; } VkInstance instance() const { return instance_; }
@ -83,7 +81,6 @@ private:
}; };
bool createSwapchain(); bool createSwapchain();
bool recreateSurface();
void destroySwapchainViews(); void destroySwapchainViews();
const Window* window_ = nullptr; const Window* window_ = nullptr;
@ -105,7 +102,7 @@ private:
std::vector<VkImageView> views_; std::vector<VkImageView> views_;
std::vector<VkSemaphore> renderDone_; // per swapchain image, so present never reuses one in flight std::vector<VkSemaphore> renderDone_; // per swapchain image, so present never reuses one in flight
bool vsync_ = true; bool vsync_ = true;
bool swapchainDirty_ = false, surfaceLost_ = false; bool swapchainDirty_ = false;
Frame frames_[kFramesInFlight]; Frame frames_[kFramesInFlight];
VkCommandPool immPool_ = VK_NULL_HANDLE; VkCommandPool immPool_ = VK_NULL_HANDLE;

View file

@ -159,10 +159,6 @@ void main() {
n = normalize(tbn * vec3(xy, sqrt(max(1.0 - dot(xy, xy), 0.0)))); n = normalize(tbn * vec3(xy, sqrt(max(1.0 - dot(xy, xy), 0.0))));
metal = m.z; metal = m.z;
gloss = m.w; gloss = m.w;
} else if (DRAW.probe.w > 0.5) { // the model's own surface (Renderer::Surface)
uint s = uint(DRAW.probe.w - 0.5);
metal = float(s & 255u) / 255.0;
gloss = 1.0 - float((s >> 8) & 255u) / 255.0;
} }
// Weather: how open to the sky this spot is (the lightmap's sky map; moving things: their // Weather: how open to the sky this spot is (the lightmap's sky map; moving things: their
// sun visibility), then rain wetting it (darker, glossy) and snow lying on what faces up. // sun visibility), then rain wetting it (darker, glossy) and snow lying on what faces up.

View file

@ -5,9 +5,6 @@ layout(location = 0) out vec3 v_normal;
layout(location = 1) out vec2 v_uv; layout(location = 1) out vec2 v_uv;
layout(location = 2) out vec2 v_lightmapUv; layout(location = 2) out vec2 v_lightmapUv;
layout(location = 3) out vec3 v_world; layout(location = 3) out vec3 v_world;
// The same depth in the depth pre-pass and the scene pass (else the compiler, dropping what the pre-pass
// doesn't use, can round it differently and the scene pass fails its depth test in patches).
invariant gl_Position;
void main() { void main() {
Vertex vx = VERTEX(VERTEX_ID); Vertex vx = VERTEX(VERTEX_ID);

View file

@ -5,9 +5,6 @@ layout(location = 0) out vec3 v_normal;
layout(location = 1) out vec2 v_uv; layout(location = 1) out vec2 v_uv;
layout(location = 2) out vec2 v_lightmapUv; layout(location = 2) out vec2 v_lightmapUv;
layout(location = 3) out vec3 v_world; layout(location = 3) out vec3 v_world;
// The same depth in the depth pre-pass and the scene pass (else the compiler, dropping what the pre-pass
// doesn't use, can round it differently and the scene pass fails its depth test in patches).
invariant gl_Position;
void main() { void main() {
Vertex vx = VERTEX(VERTEX_ID); Vertex vx = VERTEX(VERTEX_ID);

View file

@ -9,49 +9,8 @@ PUSH_BLOCK {
vec4 up; vec4 up;
} pc; } pc;
layout(location = 0) in vec2 v_uv; layout(location = 0) in vec2 v_uv;
layout(location = 1) in vec2 v_rel;
// A grid (a sprite with colour.a < 0: -1 dark lines, -2 light ones), worked out for each pixel
// where it lies on its plane: lines every 10^n metres (`metre`: units in one), the four sets from
// the finest that isn't crowded together here, each showing by how far apart its lines are on the
// screen (none where they crowd, stronger where they're far apart: nothing jumps as the view
// moves), about a pixel wide, smooth; the axes through the middle in the colour of the world's
// axis they run along. All from `rel`, where it is from the square's middle (a whole number of
// kilometres from the world's: far out, the world's own numbers are too coarse for lines a
// pixel wide); `eye`: the point under the eye, from the middle; it fades out `reach` from there.
vec3 gridAxisColor(vec3 d) {
d = abs(d);
return d.x > 0.5 ? vec3(0.9, 0.25, 0.25) : d.y > 0.5 ? vec3(0.3, 0.75, 0.25) : vec3(0.25, 0.45, 0.95);
}
vec4 gridColor(vec2 rel, vec3 centre, vec3 u, vec3 v, float metre, float kind, vec2 eye, float reach) {
vec2 base = vec2(dot(centre, u), dot(centre, v)); // (the middle: whole kilometres)
vec2 fw = max(fwidth(rel), vec2(1e-9));
float finest = floor(log(min(fw.x, fw.y) * 5.0 / metre) / log(10.0)); // (10^this m: 5 pixels apart or more)
vec3 look = kind < -1.5 ? vec3(1.0, 0.05, 0.16) : vec3(0.0, 0.14, 0.4); // (shade, how faint, how strong)
float lines = 0.0;
for (int k = 0; k < 4; ++k) {
float s = metre * pow(10.0, finest + float(k));
vec2 apart = s / fw; // (pixels between this set's lines, each way)
vec2 off = abs(fract(fract(base / s) + rel / s + 0.5) - 0.5) * apart; // (pixels to the nearest of them)
vec2 strong = smoothstep(20.0, 160.0, apart);
vec2 cover = clamp(0.5 * (1.0 + 0.6 * strong) + 0.5 - off, 0.0, 1.0);
vec2 a = cover * smoothstep(5.0, 20.0, apart) * mix(vec2(look.y), vec2(look.z), strong);
lines = max(lines, max(a.x, a.y));
}
vec2 axis = clamp(1.25 - abs(base + rel) / fw, 0.0, 1.0) * 0.9; // (x: the line along v, y: along u)
float fade = smoothstep(reach, reach * 0.6, length(rel - eye));
vec3 col = vec3(look.x) * lines;
float a = lines;
col = mix(col, gridAxisColor(v), axis.x), a = mix(a, 1.0, axis.x);
col = mix(col, gridAxisColor(u), axis.y), a = mix(a, 1.0, axis.y);
return vec4(col / max(a, 1e-6), a * fade);
}
layout(location = 0) out vec4 o_color; layout(location = 0) out vec4 o_color;
void main() { void main() {
if (pc.color.a < 0.0) { // a grid: right.w the units in a metre, up.w how far it reaches, color.xy where the eye is over it
o_color = gridColor(v_rel, pc.posSize.xyz, normalize(pc.right.xyz), normalize(pc.up.xyz), pc.right.w, pc.color.a, pc.color.xy, pc.up.w);
return;
}
vec4 t = sampleTex(floatBitsToUint(pc.up.w), v_uv); vec4 t = sampleTex(floatBitsToUint(pc.up.w), v_uv);
// Block compression leaves a little alpha where there should be none; bright sprites // Block compression leaves a little alpha where there should be none; bright sprites
// would show it as a square: cut it. // would show it as a square: cut it.

View file

@ -9,20 +9,13 @@ PUSH_BLOCK {
vec4 up; // camera up, w = texture index (bits) vec4 up; // camera up, w = texture index (bits)
} pc; } pc;
layout(location = 0) out vec2 v_uv; layout(location = 0) out vec2 v_uv;
layout(location = 1) out vec2 v_rel; // (a grid: where on it, from its middle)
const vec2 kQuad[6] = vec2[](vec2(-1,-1), vec2(1,-1), vec2(1,1), vec2(-1,-1), vec2(1,1), vec2(-1,1)); const vec2 kQuad[6] = vec2[](vec2(-1,-1), vec2(1,-1), vec2(1,1), vec2(-1,-1), vec2(1,1), vec2(-1,1));
void main() { void main() {
vec2 q = kQuad[VERTEX_ID]; vec2 q = kQuad[VERTEX_ID];
float c = cos(pc.right.w), s = sin(pc.right.w); float c = cos(pc.right.w), s = sin(pc.right.w);
vec2 r = vec2(c * q.x - s * q.y, s * q.x + c * q.y); vec2 r = vec2(c * q.x - s * q.y, s * q.x + c * q.y);
vec3 world = pc.posSize.xyz + (pc.right.xyz * r.x + pc.up.xyz * r.y) * pc.posSize.w; vec3 world = pc.posSize.xyz + (pc.right.xyz * r.x + pc.up.xyz * r.y) * pc.posSize.w;
if (pc.color.a < 0.0) world = pc.posSize.xyz + (pc.right.xyz * q.x + pc.up.xyz * q.y) * pc.posSize.w; // a grid: a square on its plane
else if (pc.posSize.w < 0.0) { // a streak: right.xyz the eye, w its half width at the far end; each end's side faces the eye
vec3 end = pc.posSize.xyz + pc.up.xyz * q.y, side = cross(pc.up.xyz, pc.right.xyz - end);
world = end + (length(side) > 1e-9 ? normalize(side) : vec3(0.0, 0.0, 1.0)) * q.x * (q.y < 0.0 ? -pc.posSize.w : pc.right.w);
}
v_uv = q * 0.5 + 0.5; v_uv = q * 0.5 + 0.5;
v_uv.y = 1.0 - v_uv.y; v_uv.y = 1.0 - v_uv.y;
v_rel = pc.color.a < 0.0 ? q * pc.posSize.w : vec2(0.0);
gl_Position = pc.viewProj * vec4(world, 1.0); gl_Position = pc.viewProj * vec4(world, 1.0);
} }

View file

@ -56,8 +56,7 @@ void main() {
vec3 v = viewPos(q) - p; vec3 v = viewPos(q) - p;
float vv = dot(v, v); float vv = dot(v, v);
float falloff = max(0.0, 1.0 - vv / (pc.radius * pc.radius)); float falloff = max(0.0, 1.0 - vv / (pc.radius * pc.radius));
// (+ a little against dividing by nothing: its share of the reach, so any scale looks the same) occlusion += max(0.0, dot(v, n) - 0.01 * p.z) / (vv + 1.0) * falloff;
occlusion += max(0.0, dot(v, n) - 0.01 * p.z) / (vv + pc.radius * pc.radius * 0.0017) * falloff;
} }
float ao = max(0.0, 1.0 - 2.0 * occlusion / float(kSamples) * pc.radius * 0.6); float ao = max(0.0, 1.0 - 2.0 * occlusion / float(kSamples) * pc.radius * 0.6);
o_ao = vec4(mix(1.0, ao * ao, pc.strength)); o_ao = vec4(mix(1.0, ao * ao, pc.strength));

View file

@ -5,21 +5,15 @@
#include <imgui.h> #include <imgui.h>
#include <imgui_internal.h> #include <imgui_internal.h>
#include <SDL3/SDL_video.h>
#include <algorithm> #include <algorithm>
#include <cmath>
#include <cstring> #include <cstring>
// ImGui's RenderFrame asks this first (patched in by cmake/Dependencies.cmake).
extern bool (*GImRenderFrameHook)(ImDrawList*, ImVec2, ImVec2, ImU32, bool, float);
namespace keel::ui { namespace keel::ui {
namespace { namespace {
ImFont* g_fonts[4] = {}; ImFont* g_fonts[3] = {};
float g_scale = 1.0f; float g_scale = 1.0f;
ImFont* loadFont(const char* path, float size, const char* fallback = nullptr) { ImFont* loadFont(const char* path, float size) {
auto data = fs::read(path); auto data = fs::read(path);
if (!data) { if (!data) {
log::warn("Font {} not found, using the built-in one", path); log::warn("Font {} not found, using the built-in one", path);
@ -27,285 +21,74 @@ ImFont* loadFont(const char* path, float size, const char* fallback = nullptr) {
} }
void* owned = IM_ALLOC(data->size()); // the atlas frees it void* owned = IM_ALLOC(data->size()); // the atlas frees it
std::memcpy(owned, data->data(), data->size()); std::memcpy(owned, data->data(), data->size());
ImFont* f = ImGui::GetIO().Fonts->AddFontFromMemoryTTF(owned, int(data->size()), size); return ImGui::GetIO().Fonts->AddFontFromMemoryTTF(owned, int(data->size()), size);
if (auto more = fallback ? fs::read(fallback) : std::nullopt) { // what the face hasn't got (arrows, symbols): from another
ImFontConfig cfg;
cfg.MergeMode = true;
void* o = IM_ALLOC(more->size());
std::memcpy(o, more->data(), more->size());
ImGui::GetIO().Fonts->AddFontFromMemoryTTF(o, int(more->size()), size, &cfg);
}
return f;
} }
// ── Colour: Niveus's derivation (tokens/color.css, themes/*.css), in OKLab ──
struct Lab {
float L, a, b, alpha = 1.0f;
};
float toLinear(float c) { return c <= 0.04045f ? c / 12.92f : std::pow((c + 0.055f) / 1.055f, 2.4f); }
float toSrgb(float c) { return c <= 0.0031308f ? 12.92f * c : 1.055f * std::pow(c, 1.0f / 2.4f) - 0.055f; }
Lab lab(uint32_t rgb, float alpha = 1.0f) {
const float r = toLinear(float((rgb >> 16) & 255) / 255.0f), g = toLinear(float((rgb >> 8) & 255) / 255.0f), b = toLinear(float(rgb & 255) / 255.0f);
const float l = std::cbrt(0.4122214708f * r + 0.5363325363f * g + 0.0514459929f * b);
const float m = std::cbrt(0.2119034982f * r + 0.6806995451f * g + 0.1073969566f * b);
const float s = std::cbrt(0.0883024619f * r + 0.2817188376f * g + 0.6299787005f * b);
return {0.2104542553f * l + 0.7936177850f * m - 0.0040720468f * s, 1.9779984951f * l - 2.4285922050f * m + 0.4505937099f * s,
0.0259040371f * l + 0.7827717662f * m - 0.8086757660f * s, alpha};
}
ImVec4 rgba(const Lab& c) {
const float l = std::pow(c.L + 0.3963377774f * c.a + 0.2158037573f * c.b, 3.0f);
const float m = std::pow(c.L - 0.1055613458f * c.a - 0.0638541728f * c.b, 3.0f);
const float s = std::pow(c.L - 0.0894841775f * c.a - 1.2914855480f * c.b, 3.0f);
auto out = [](float v) { return std::clamp(toSrgb(std::max(v, 0.0f)), 0.0f, 1.0f); };
return ImVec4(out(4.0767416621f * l - 3.3077115913f * m + 0.2309699292f * s), out(-1.2684380046f * l + 2.6097574011f * m - 0.3413193965f * s),
out(-0.0041960863f * l - 0.7034186147f * m + 1.7076147010f * s), c.alpha);
}
Lab mix(const Lab& x, const Lab& y, float t) { return {x.L + (y.L - x.L) * t, x.a + (y.a - x.a) * t, x.b + (y.b - x.b) * t, 1.0f}; }
float chroma(const Lab& c) { return std::hypot(c.a, c.b); }
// The same hue with lightness L and chroma C (oklch(from c L C h)).
Lab lch(const Lab& c, float L, float C, float alpha = 1.0f) {
const float k = chroma(c) > 1e-6f ? C / chroma(c) : 0.0f;
return {L, c.a * k, c.b * k, alpha};
}
// A fill pushed out of the mid-tones until its label reads (--nv-contrast-*).
Lab fill(const Lab& c) { return lch(c, c.L < 0.7f ? std::min(c.L, 0.53f) : std::max(c.L, 0.78f), std::clamp(chroma(c), 0.02f, 0.25f)); }
Lab onFill(const Lab& f) { return lch(f, 0.14f + std::clamp((0.68f - f.L) * 100.0f, 0.0f, 1.0f) * 0.84f, chroma(f) * 0.2f); }
uint32_t g_brand = 0xff7828; // Fry3DX's hot orange
Appearance g_appearance = Appearance::System;
bool g_dark = true, g_styled = false;
ImVec4 g_role[size_t(Role::Count)];
void derive(bool darkLook) {
const Lab brand = lab(g_brand);
auto tint = [&](uint32_t grey, float t = 0.04f) { return rgba(mix(lab(grey), brand, t)); };
auto rgb = [](int r, int g, int b, float a = 1.0f) { return ImVec4(r / 255.0f, g / 255.0f, b / 255.0f, a); };
auto set = [](Role r, ImVec4 v) { g_role[size_t(r)] = v; };
const Lab accent = fill(brand);
const Lab success = fill(mix(lab(0x189a60), brand, 0.12f)), warning = fill(mix(lab(0xeda520), brand, 0.12f)), danger = fill(mix(lab(0xe2334a), brand, 0.12f));
set(Role::OnAccent, rgba(onFill(accent)));
set(Role::OnDanger, rgba(onFill(danger)));
set(Role::Success, rgba(success)), set(Role::Warning, rgba(warning)), set(Role::Danger, rgba(danger));
if (darkLook) {
set(Role::Background, tint(0x121214)), set(Role::BackgroundSubtle, tint(0x18181b)), set(Role::Surface, tint(0x1f1f22));
set(Role::SurfaceElevated, tint(0x2a2a2e)), set(Role::SurfaceInset, tint(0x111113)), set(Role::SurfaceHover, tint(0x313136));
set(Role::SurfaceActive, tint(0x39393f)), set(Role::Scrim, rgb(6, 6, 8, 0.62f));
set(Role::Text, rgb(0xec, 0xeb, 0xe8)), set(Role::TextSecondary, tint(0xa9a8a3, 0.08f)), set(Role::TextTertiary, tint(0x7d7c77, 0.08f));
set(Role::TextDisabled, rgb(0x58, 0x58, 0x5c));
set(Role::BorderSubtle, rgb(255, 255, 255, 0.06f)), set(Role::Border, rgb(255, 255, 255, 0.1f)), set(Role::BorderStrong, rgb(255, 255, 255, 0.2f));
set(Role::Accent, rgba(accent)), set(Role::AccentHover, rgba(lch(accent, accent.L + 0.05f, chroma(accent))));
set(Role::AccentActive, rgba(lch(accent, accent.L + 0.09f, chroma(accent))));
set(Role::AccentSubtle, rgba({accent.L, accent.a, accent.b, 0.2f})), set(Role::AccentSubtleHover, rgba({accent.L, accent.a, accent.b, 0.28f}));
set(Role::AccentText, rgba(lch(accent, std::clamp(accent.L + 0.2f, 0.74f, 0.9f), chroma(accent) * 0.9f)));
auto text = [](const Lab& c) { return rgba(lch(c, std::clamp(c.L + 0.2f, 0.76f, 0.9f), chroma(c) * 0.9f)); };
set(Role::SuccessText, text(success)), set(Role::WarningText, text(warning)), set(Role::DangerText, text(danger));
set(Role::Highlight, rgb(255, 255, 255, 0.11f)), set(Role::Lowlight, rgb(0, 0, 0, 0.42f)), set(Role::Outline, rgb(0, 0, 0, 0.72f));
set(Role::Shadow, rgb(0, 0, 0, 0.5f)), set(Role::ShadowInset, rgb(0, 0, 0, 0.65f));
set(Role::SheenTop, rgb(255, 255, 255, 0.06f)), set(Role::SheenBottom, rgb(0, 0, 0, 0.09f));
set(Role::SheenStrongTop, rgb(255, 255, 255, 0.2f)), set(Role::SheenStrongBottom, rgb(0, 0, 0, 0.12f));
} else {
set(Role::Background, tint(0xe8e5de)), set(Role::BackgroundSubtle, tint(0xeeece6)), set(Role::Surface, rgb(0xfb, 0xfb, 0xfa));
set(Role::SurfaceElevated, rgb(0xf8, 0xf8, 0xf6)), set(Role::SurfaceInset, tint(0xe3e2dd)), set(Role::SurfaceHover, tint(0xf1f0ec));
set(Role::SurfaceActive, tint(0xe6e5e0)), set(Role::Scrim, rgb(28, 24, 18, 0.34f));
set(Role::Text, rgb(0x1d, 0x1d, 0x20)), set(Role::TextSecondary, tint(0x63625d, 0.08f)), set(Role::TextTertiary, tint(0x87867f, 0.08f));
set(Role::TextDisabled, rgb(0xae, 0xad, 0xa7));
set(Role::BorderSubtle, rgb(28, 24, 18, 0.07f)), set(Role::Border, rgb(28, 24, 18, 0.13f)), set(Role::BorderStrong, rgb(28, 24, 18, 0.22f));
set(Role::Accent, rgba(accent)), set(Role::AccentHover, rgba(lch(accent, accent.L - 0.045f, chroma(accent))));
set(Role::AccentActive, rgba(lch(accent, accent.L - 0.09f, chroma(accent))));
set(Role::AccentSubtle, rgba(lch(accent, 0.965f, chroma(accent) * 0.16f))), set(Role::AccentSubtleHover, rgba(lch(accent, 0.93f, chroma(accent) * 0.22f)));
set(Role::AccentText, rgba(lch(accent, std::clamp(accent.L - 0.12f, 0.3f, 0.5f), chroma(accent))));
auto text = [](const Lab& c) { return rgba(lch(c, std::clamp(c.L - 0.1f, 0.3f, 0.48f), chroma(c))); };
set(Role::SuccessText, text(success)), set(Role::WarningText, text(warning)), set(Role::DangerText, text(danger));
set(Role::Highlight, rgb(255, 255, 255, 0.9f)), set(Role::Lowlight, rgb(40, 32, 20, 0.07f)), set(Role::Outline, rgb(40, 32, 20, 0.05f));
set(Role::Shadow, rgb(40, 32, 20, 0.12f)), set(Role::ShadowInset, rgb(40, 32, 20, 0.17f));
set(Role::SheenTop, rgb(255, 255, 255, 0.75f)), set(Role::SheenBottom, rgb(40, 32, 20, 0.04f));
set(Role::SheenStrongTop, rgb(255, 255, 255, 0.24f)), set(Role::SheenStrongBottom, rgb(20, 16, 10, 0.1f));
}
}
ImVec4 role(Role r) { return g_role[size_t(r)]; }
ImVec4 withAlpha(ImVec4 c, float a) { return c.w = a, c; }
void theme() { void theme() {
derive(g_dark);
ImGuiStyle& s = ImGui::GetStyle(); ImGuiStyle& s = ImGui::GetStyle();
const float fontScale = s.FontScaleMain, fontDpi = s.FontScaleDpi;
s = ImGuiStyle(); s = ImGuiStyle();
s.FontScaleMain = fontScale, s.FontScaleDpi = fontDpi; s.WindowRounding = 2;
// Niveus's radii and 4px rhythm. Frames have no border of ImGui's: their edges are the s.FrameRounding = 1;
// keys' and wells' own (drawn by frameHook). s.GrabRounding = 1;
s.WindowRounding = 8, s.ChildRounding = 6, s.PopupRounding = 8, s.FrameRounding = 6, s.GrabRounding = 5, s.TabRounding = 6; s.TabRounding = 1;
s.ScrollbarRounding = 8, s.ScrollbarSize = 11, s.GrabMinSize = 12; s.ScrollbarRounding = 1;
s.WindowBorderSize = 1, s.PopupBorderSize = 1, s.FrameBorderSize = 0, s.TabBorderSize = 0, s.DockingSeparatorSize = 2; s.WindowBorderSize = 1;
s.WindowPadding = ImVec2(10, 10), s.FramePadding = ImVec2(8, 5), s.ItemSpacing = ImVec2(8, 6), s.ItemInnerSpacing = ImVec2(6, 4); s.FrameBorderSize = 1;
s.SeparatorTextBorderSize = 1, s.SeparatorTextPadding = ImVec2(10, 3); s.WindowPadding = ImVec2(14, 12);
s.FramePadding = ImVec2(8, 5);
s.ItemSpacing = ImVec2(10, 8);
s.WindowTitleAlign = ImVec2(0.0f, 0.5f); s.WindowTitleAlign = ImVec2(0.0f, 0.5f);
s.WindowMenuButtonPosition = ImGuiDir_None;
ImVec4* c = s.Colors; ImVec4* c = s.Colors;
c[ImGuiCol_Text] = role(Role::Text); auto rgb = [](int r, int g, int b, float a = 1.0f) { return ImVec4(r / 255.0f, g / 255.0f, b / 255.0f, a); };
c[ImGuiCol_TextDisabled] = role(Role::TextTertiary); // Source 2013 GameUI's charcoal panels, grey frames and near-white text, with a red
c[ImGuiCol_WindowBg] = role(Role::Surface); // where Source has its orange.
c[ImGuiCol_ChildBg] = ImVec4(0, 0, 0, 0); const ImVec4 red = rgb(kAccent[0], kAccent[1], kAccent[2]), redLight = rgb(245, 70, 76), redDark = rgb(150, 24, 32);
c[ImGuiCol_PopupBg] = role(Role::SurfaceElevated); c[ImGuiCol_Text] = rgb(235, 230, 220);
c[ImGuiCol_Border] = role(Role::Border); c[ImGuiCol_TextDisabled] = rgb(140, 136, 128);
c[ImGuiCol_BorderShadow] = ImVec4(0, 0, 0, 0); c[ImGuiCol_WindowBg] = rgb(40, 40, 38, 0.94f);
c[ImGuiCol_FrameBg] = role(Role::SurfaceInset); c[ImGuiCol_ChildBg] = rgb(30, 30, 29, 0.6f);
c[ImGuiCol_FrameBgHovered] = g_dark ? role(Role::Background) : role(Role::SurfaceActive); c[ImGuiCol_PopupBg] = rgb(36, 36, 34, 0.98f);
c[ImGuiCol_FrameBgActive] = c[ImGuiCol_FrameBgHovered]; c[ImGuiCol_Border] = rgb(90, 88, 82, 0.8f);
c[ImGuiCol_TitleBg] = c[ImGuiCol_TitleBgActive] = c[ImGuiCol_TitleBgCollapsed] = role(Role::BackgroundSubtle); c[ImGuiCol_FrameBg] = rgb(28, 28, 27);
c[ImGuiCol_MenuBarBg] = role(Role::BackgroundSubtle); c[ImGuiCol_FrameBgHovered] = rgb(50, 49, 46);
c[ImGuiCol_ScrollbarBg] = ImVec4(0, 0, 0, 0); c[ImGuiCol_FrameBgActive] = rgb(60, 58, 54);
c[ImGuiCol_ScrollbarGrab] = role(Role::BorderStrong); c[ImGuiCol_TitleBg] = rgb(52, 51, 48);
c[ImGuiCol_ScrollbarGrabHovered] = role(Role::TextTertiary); c[ImGuiCol_TitleBgActive] = rgb(70, 30, 32);
c[ImGuiCol_ScrollbarGrabActive] = role(Role::TextSecondary); c[ImGuiCol_Button] = rgb(70, 68, 63);
c[ImGuiCol_CheckMark] = role(Role::Accent); c[ImGuiCol_ButtonHovered] = rgb(95, 92, 84);
c[ImGuiCol_SliderGrab] = role(Role::Accent); c[ImGuiCol_ButtonActive] = red;
c[ImGuiCol_SliderGrabActive] = role(Role::AccentHover); c[ImGuiCol_Header] = rgb(70, 68, 63);
c[ImGuiCol_Button] = role(Role::SurfaceElevated); c[ImGuiCol_HeaderHovered] = rgb(95, 92, 84);
c[ImGuiCol_ButtonHovered] = role(Role::SurfaceHover); c[ImGuiCol_HeaderActive] = red;
c[ImGuiCol_ButtonActive] = role(Role::SurfaceActive); c[ImGuiCol_Tab] = rgb(55, 54, 50);
c[ImGuiCol_Header] = role(Role::AccentSubtle); c[ImGuiCol_TabHovered] = rgb(95, 92, 84);
c[ImGuiCol_HeaderHovered] = role(Role::SurfaceHover); c[ImGuiCol_TabSelected] = redDark;
c[ImGuiCol_HeaderActive] = role(Role::AccentSubtleHover); c[ImGuiCol_TabSelectedOverline] = red;
c[ImGuiCol_Separator] = role(Role::BorderSubtle); c[ImGuiCol_SliderGrab] = red;
c[ImGuiCol_SeparatorHovered] = c[ImGuiCol_SeparatorActive] = role(Role::Accent); c[ImGuiCol_SliderGrabActive] = redLight;
c[ImGuiCol_ResizeGrip] = ImVec4(0, 0, 0, 0); c[ImGuiCol_CheckMark] = redLight;
c[ImGuiCol_ResizeGripHovered] = c[ImGuiCol_ResizeGripActive] = role(Role::AccentSubtleHover); c[ImGuiCol_TextSelectedBg] = rgb(kAccent[0], kAccent[1], kAccent[2], 0.45f);
c[ImGuiCol_InputTextCursor] = role(Role::Accent); c[ImGuiCol_NavCursor] = red;
c[ImGuiCol_Tab] = c[ImGuiCol_TabDimmed] = role(Role::BackgroundSubtle); c[ImGuiCol_Separator] = rgb(90, 88, 82, 0.6f);
c[ImGuiCol_TabHovered] = role(Role::SurfaceHover); c[ImGuiCol_ScrollbarBg] = rgb(28, 28, 27, 0.6f);
c[ImGuiCol_TabSelected] = c[ImGuiCol_TabDimmedSelected] = role(Role::Surface); c[ImGuiCol_ScrollbarGrab] = rgb(80, 78, 72);
c[ImGuiCol_TabSelectedOverline] = role(Role::Accent);
c[ImGuiCol_TabDimmedSelectedOverline] = ImVec4(0, 0, 0, 0);
c[ImGuiCol_DockingPreview] = withAlpha(role(Role::Accent), 0.4f);
c[ImGuiCol_DockingEmptyBg] = role(Role::Background);
c[ImGuiCol_PlotLines] = c[ImGuiCol_PlotHistogram] = role(Role::Accent);
c[ImGuiCol_PlotLinesHovered] = c[ImGuiCol_PlotHistogramHovered] = role(Role::AccentHover);
c[ImGuiCol_TableHeaderBg] = role(Role::BackgroundSubtle);
c[ImGuiCol_TableBorderStrong] = role(Role::Border);
c[ImGuiCol_TableBorderLight] = role(Role::BorderSubtle);
c[ImGuiCol_TableRowBg] = ImVec4(0, 0, 0, 0);
c[ImGuiCol_TableRowBgAlt] = withAlpha(role(Role::Text), 0.03f);
c[ImGuiCol_TextLink] = role(Role::AccentText);
c[ImGuiCol_TextSelectedBg] = withAlpha(role(Role::Accent), 0.3f);
c[ImGuiCol_TreeLines] = role(Role::BorderSubtle);
c[ImGuiCol_DragDropTarget] = role(Role::Accent);
c[ImGuiCol_NavCursor] = withAlpha(role(Role::Accent), 0.6f);
c[ImGuiCol_NavWindowingHighlight] = role(Role::Accent);
c[ImGuiCol_NavWindowingDimBg] = c[ImGuiCol_ModalWindowDimBg] = withAlpha(role(Role::Scrim), role(Role::Scrim).w * 0.5f); // (an editor: the work stays readable behind)
}
ImU32 u32(ImVec4 c) { return ImGui::ColorConvertFloat4ToU32(c); }
ImVec4 over(ImVec4 base, ImVec4 top) { // `top` laid over `base`
const float a = top.w;
return ImVec4(base.x + (top.x - base.x) * a, base.y + (top.y - base.y) * a, base.z + (top.z - base.z) * a, base.w);
}
bool saturated(ImVec4 c) { return std::max({c.x, c.y, c.z}) - std::min({c.x, c.y, c.z}) > 0.3f; }
// A key with `fillCol` as its face: contact shadow, the face with light falling down it, a lit top
// edge and a shaded bottom one; pressed, it sinks: flat, with the shade inside along its top.
void key(ImDrawList* dl, ImVec2 a, ImVec2 b, float r, ImVec4 face, bool pressed) {
const bool filled = saturated(face);
if (pressed) {
dl->AddRectFilled(a, b, u32(face), r);
dl->AddRect(a, b, u32(role(Role::Border)), r);
dl->PushClipRect(a, b, true);
dl->AddRect(ImVec2(a.x, a.y), ImVec2(b.x, b.y + 4), u32(withAlpha(role(Role::ShadowInset), role(Role::ShadowInset).w * 0.6f)), r, 0, 2.0f);
dl->PopClipRect();
return;
}
dl->AddRectFilled(ImVec2(a.x, a.y + 1), ImVec2(b.x, b.y + 2), u32(withAlpha(role(Role::Shadow), role(Role::Shadow).w * 0.7f)), r); // its shadow
if (g_dark) dl->AddRect(ImVec2(a.x - 0.5f, a.y - 0.5f), ImVec2(b.x + 0.5f, b.y + 0.5f), u32(role(Role::Outline)), r + 0.5f); // the contact line
const ImVec4 top = over(face, role(filled ? Role::SheenStrongTop : Role::SheenTop)), bottom = over(face, role(filled ? Role::SheenStrongBottom : Role::SheenBottom));
const int v0 = dl->VtxBuffer.Size;
dl->AddRectFilled(a, b, u32(face), r);
ImGui::ShadeVertsLinearColorGradientKeepAlpha(dl, v0, dl->VtxBuffer.Size, a, ImVec2(a.x, b.y), u32(top), u32(bottom));
if (!filled) dl->AddRect(a, b, u32(role(Role::Border)), r);
const float in = std::min(r * 0.7f, (b.x - a.x) * 0.3f);
dl->AddLine(ImVec2(a.x + in, a.y + 1.0f), ImVec2(b.x - in, a.y + 1.0f), u32(filled ? withAlpha(role(Role::Highlight), 0.35f) : role(Role::Highlight)));
dl->AddLine(ImVec2(a.x + in, b.y - 1.0f), ImVec2(b.x - in, b.y - 1.0f), u32(role(Role::Lowlight)));
}
// ImGui's RenderFrame (patched in, see cmake/Dependencies.cmake): buttons become keys and fields wells.
bool frameHook(ImDrawList* dl, ImVec2 a, ImVec2 b, ImU32 col, bool, float r) {
if (!g_styled) return false;
const ImU32 normal = ImGui::GetColorU32(ImGuiCol_Button), hovered = ImGui::GetColorU32(ImGuiCol_ButtonHovered), held = ImGui::GetColorU32(ImGuiCol_ButtonActive);
if (col == normal || col == hovered || col == held) {
key(dl, a, b, r, ImGui::ColorConvertU32ToFloat4(col), col == held && held != normal);
return true;
}
if (col == ImGui::GetColorU32(ImGuiCol_FrameBg) || col == ImGui::GetColorU32(ImGuiCol_FrameBgHovered) || col == ImGui::GetColorU32(ImGuiCol_FrameBgActive)) {
drawWell(dl, a, b, r, col != ImGui::GetColorU32(ImGuiCol_FrameBg));
return true;
}
return false;
}
Appearance fromSystem() { return SDL_GetSystemTheme() == SDL_SYSTEM_THEME_LIGHT ? Appearance::Light : Appearance::Dark; }
void restyle() {
g_dark = (g_appearance == Appearance::System ? fromSystem() : g_appearance) != Appearance::Light;
theme();
g_styled = true;
} }
} // namespace } // namespace
void init() { void init() {
ImGuiIO& io = ImGui::GetIO(); ImGuiIO& io = ImGui::GetIO();
g_fonts[0] = loadFont("fonts/InstrumentSans-Regular.ttf", 16.0f, "fonts/DejaVuSans.ttf"); g_fonts[0] = loadFont("fonts/DejaVuSans.ttf", 16.0f);
g_fonts[1] = loadFont("fonts/InstrumentSans-SemiBold.ttf", 16.0f, "fonts/DejaVuSans-Bold.ttf"); g_fonts[1] = loadFont("fonts/DejaVuSans-Bold.ttf", 16.0f);
g_fonts[2] = loadFont("fonts/InstrumentSans-Bold.ttf", 48.0f); g_fonts[2] = loadFont("fonts/DejaVuSans-Bold.ttf", 48.0f);
g_fonts[3] = loadFont("fonts/JetBrainsMono-Regular.ttf", 15.0f, "fonts/DejaVuSans.ttf");
if (!g_fonts[0]) g_fonts[0] = loadFont("fonts/DejaVuSans.ttf", 16.0f); // (an older content folder)
if (g_fonts[0]) io.FontDefault = g_fonts[0]; if (g_fonts[0]) io.FontDefault = g_fonts[0];
GImRenderFrameHook = frameHook; theme();
restyle();
}
void setAppearance(Appearance a) {
g_appearance = a;
restyle();
}
Appearance appearance() { return g_appearance; }
bool dark() { return g_dark; }
void setBrand(uint8_t r, uint8_t g, uint8_t b) {
g_brand = uint32_t(r) << 16 | uint32_t(g) << 8 | b;
restyle();
}
uint32_t color(Role r, float alpha) {
ImVec4 c = role(r);
c.w *= alpha;
return u32(c);
} }
uint32_t accent(float alpha, float brightness) { uint32_t accent(float alpha, float brightness) {
const ImVec4 c = role(Role::Accent); return ImGui::ColorConvertFloat4ToU32(ImVec4(std::min(kAccent[0] / 255.0f * brightness, 1.0f), std::min(kAccent[1] / 255.0f * brightness, 1.0f),
return u32(ImVec4(std::min(c.x * brightness, 1.0f), std::min(c.y * brightness, 1.0f), std::min(c.z * brightness, 1.0f), alpha)); std::min(kAccent[2] / 255.0f * brightness, 1.0f), alpha));
} }
void drawKey(ImDrawList* dl, const ImVec2& a, const ImVec2& b, float rounding, KeyState state) {
switch (state) {
case KeyState::Normal: return key(dl, a, b, rounding, role(Role::SurfaceElevated), false);
case KeyState::Hovered: return key(dl, a, b, rounding, role(Role::SurfaceHover), false);
case KeyState::Pressed: return key(dl, a, b, rounding, role(Role::SurfaceActive), true);
case KeyState::On: return key(dl, a, b, rounding, role(Role::Accent), false);
}
}
void drawWell(ImDrawList* dl, const ImVec2& a, const ImVec2& b, float r, bool hovered) {
dl->AddRectFilled(a, b, ImGui::GetColorU32(hovered ? ImGuiCol_FrameBgHovered : ImGuiCol_FrameBg), r);
// Shade inside along the top (the light's blocked by the rim), light along the bottom lip.
dl->PushClipRect(a, b, true);
const ImVec4 sh = role(Role::ShadowInset);
dl->AddRect(a, ImVec2(b.x, b.y + 6), u32(withAlpha(sh, sh.w * 0.55f)), r, 0, 1.5f);
dl->AddRect(ImVec2(a.x, a.y + 1), ImVec2(b.x, b.y + 6), u32(withAlpha(sh, sh.w * 0.2f)), r, 0, 1.5f);
dl->PopClipRect();
dl->AddRect(a, b, u32(role(Role::BorderSubtle)), r);
const float in = std::min(r, (b.x - a.x) * 0.3f);
dl->AddLine(ImVec2(a.x + in, b.y + 0.5f), ImVec2(b.x - in, b.y + 0.5f), u32(withAlpha(role(Role::Highlight), role(Role::Highlight).w * 0.6f)));
}
uint32_t keyText(KeyState state) { return u32(role(state == KeyState::On ? Role::OnAccent : Role::Text)); }
ImFont* font(Font f) { ImFont* font(Font f) {
ImFont* r = g_fonts[int(f)]; ImFont* r = g_fonts[int(f)];
return r ? r : ImGui::GetFont(); return r ? r : ImGui::GetFont();
@ -339,7 +122,6 @@ void applyPixelScale() {
void beginFrame(float windowHeight) { void beginFrame(float windowHeight) {
g_scale = std::clamp(windowHeight / 1080.0f, 0.75f, 2.5f); g_scale = std::clamp(windowHeight / 1080.0f, 0.75f, 2.5f);
ImGui::GetStyle().FontScaleMain = g_scale; ImGui::GetStyle().FontScaleMain = g_scale;
if (g_appearance == Appearance::System && g_styled && (fromSystem() != Appearance::Light) != g_dark) restyle(); // (the desktop changed)
} }
namespace { namespace {
@ -380,7 +162,7 @@ void drawPopups() {
// focus cancels the press, so OK would never click. // focus cancels the press, so OK would never click.
const bool refocus = !ImGui::IsAnyMouseDown(); const bool refocus = !ImGui::IsAnyMouseDown();
if (refocus) ImGui::SetNextWindowFocus(); if (refocus) ImGui::SetNextWindowFocus();
ImGui::PushStyleColor(ImGuiCol_WindowBg, color(Role::Scrim)); ImGui::PushStyleColor(ImGuiCol_WindowBg, IM_COL32(0, 0, 0, 120));
ImGui::PushStyleVar(ImGuiStyleVar_WindowBorderSize, 0.0f); ImGui::PushStyleVar(ImGuiStyleVar_WindowBorderSize, 0.0f);
ImGui::Begin("##popupdim", nullptr, ImGuiWindowFlags_NoDecoration | ImGuiWindowFlags_NoSavedSettings | ImGuiWindowFlags_NoMove); ImGui::Begin("##popupdim", nullptr, ImGuiWindowFlags_NoDecoration | ImGuiWindowFlags_NoSavedSettings | ImGuiWindowFlags_NoMove);
ImGui::End(); ImGui::End();

View file

@ -1,19 +1,16 @@
#pragma once #pragma once
// The UI's look: Niveus (github.com/Nexoniarz/niveus) on top of Dear ImGui. Buttons are keys, // Game UI look: fonts from the game folder and a Source-like theme (dark
// bevelled with light falling across them; fields are wells cut into the surface; warm // translucent panels, flat buttons, light text, red highlights) on top of Dear ImGui.
// graphite in dark, soft paper in light, and one brand colour that every accent is made from.
#include <cstdint> #include <cstdint>
#include <functional> #include <functional>
#include <string> #include <string>
#include <vector> #include <vector>
struct ImFont; struct ImFont;
struct ImDrawList;
struct ImVec2;
namespace keel::ui { namespace keel::ui {
enum class Font { Regular, Bold, Title, Mono }; enum class Font { Regular, Bold, Title };
// After the renderer (ImGui context) and the VFS are up. // After the renderer (ImGui context) and the VFS are up.
void init(); void init();
@ -28,38 +25,10 @@ float pixelScale();
// The backends call this between the platform's NewFrame and ImGui::NewFrame. // The backends call this between the platform's NewFrame and ImGui::NewFrame.
void applyPixelScale(); void applyPixelScale();
// ── Appearance ── // The highlight colour: highlights, the logo.
enum class Appearance { System, Dark, Light }; inline constexpr uint8_t kAccent[3] = {227, 34, 45};
void setAppearance(Appearance a); // System: follows the desktop's (and changes with it)
Appearance appearance();
bool dark(); // what's showing now
// The brand colour (sRGB): the accent and its states, the text on it and the tint in the greys
// are all made from it, in both appearances.
void setBrand(uint8_t r, uint8_t g, uint8_t b);
// Niveus's semantic roles.
enum class Role {
Background, BackgroundSubtle, Surface, SurfaceElevated, SurfaceInset, SurfaceHover, SurfaceActive, Scrim,
Text, TextSecondary, TextTertiary, TextDisabled,
BorderSubtle, Border, BorderStrong,
Accent, AccentHover, AccentActive, AccentSubtle, AccentSubtleHover, AccentText, OnAccent,
Success, SuccessText, Warning, WarningText, Danger, DangerText, OnDanger,
Highlight, Lowlight, Outline, Shadow, ShadowInset, SheenTop, SheenBottom, SheenStrongTop, SheenStrongBottom,
Count
};
uint32_t color(Role r, float alpha = 1.0f); // ImU32
// The accent (alpha, and brighter or darker by `brightness`).
uint32_t accent(float alpha = 1.0f, float brightness = 1.0f); // ImU32 uint32_t accent(float alpha = 1.0f, float brightness = 1.0f); // ImU32
// The materials, for widgets drawn by hand.
enum class KeyState { Normal, Hovered, Pressed, On };
// A raised key; `on` keys are filled with the accent.
void drawKey(ImDrawList* dl, const ImVec2& a, const ImVec2& b, float rounding, KeyState state);
// A well cut into the surface (fields, tracks).
void drawWell(ImDrawList* dl, const ImVec2& a, const ImVec2& b, float rounding, bool hovered = false);
// The text/icon colour that goes on a key in that state.
uint32_t keyText(KeyState state);
// ── Popups: modal messages over everything (disconnects, errors, questions) ── // ── Popups: modal messages over everything (disconnects, errors, questions) ──
struct PopupButton { struct PopupButton {
std::string label; std::string label;

View file

@ -1,20 +0,0 @@
{
"_": "Fry3DX's details, in one place: the build reads them (the version, the About box, the Help menu, Windows' file details). Change them here; CMake picks them up on the next build.",
"name": "Fry3DX",
"long": "Frying 3D Extreme",
"version": "0.8.1",
"tagline": "A 3D modeller that does what you'd expect.",
"description": "A 3D modeller that does what you'd expect: pick a tool, click and drag. Model, sculpt, paint, render and get it ready to print, and write a script when clicking gets repetitive.",
"website": "https://nexoniarz.github.io/Fry3DX/",
"repo": "https://github.com/Nexoniarz/Fry3DX",
"issues": "https://github.com/Nexoniarz/Fry3DX/issues",
"releases": "https://github.com/Nexoniarz/Fry3DX/releases/latest",
"license": "Apache License 2.0",
"copyright": "Copyright 2026 Nexoniarz",
"company": "Nexoniarz",
"look": "Niveus",
"creators": [
{ "name": "Nexoniarz", "role": "Made it: the idea, the design, the code, the look", "url": "https://github.com/Nexoniarz" }
],
"builtWith": ["SDL 3", "Dear ImGui", "Lua 5.4", "GLM", "Manifold", "zstd", "stb", "volk", "VMA"]
}

View file

@ -1,61 +1,24 @@
# Fry3DX: the app (app.cpp) on the engine, and its modelling core (mesh, scene) as a library the # Fry3DX: the app (app.cpp) on the engine, and its modelling core (mesh, scene) as a library the
# tests use too. # tests use too.
# Manifold (github.com/elalish/manifold): exact, robust combining of closed shapes. Just the add_library(fry_core STATIC mesh.cpp tools.cpp cut.cpp uv.cpp image.cpp sticker.cpp shapes.cpp scene.cpp package.cpp export.cpp print.cpp boolean.cpp modify.cpp)
# library: no threads (TBB), no 2D, no bindings.
include(FetchContent)
set(MANIFOLD_PAR OFF CACHE BOOL "" FORCE)
set(MANIFOLD_CROSS_SECTION OFF CACHE BOOL "" FORCE)
set(MANIFOLD_TEST OFF CACHE BOOL "" FORCE)
set(MANIFOLD_PYBIND OFF CACHE BOOL "" FORCE)
set(MANIFOLD_CBIND OFF CACHE BOOL "" FORCE)
set(MANIFOLD_JSBIND OFF CACHE BOOL "" FORCE)
set(MANIFOLD_EXPORT OFF CACHE BOOL "" FORCE)
FetchContent_Declare(manifold GIT_REPOSITORY https://github.com/elalish/manifold.git
GIT_TAG v3.0.1 GIT_SHALLOW ON SYSTEM)
FetchContent_MakeAvailable(manifold)
add_library(fry_core STATIC mesh.cpp bvh.cpp text.cpp import.cpp tools.cpp cut.cpp uv.cpp image.cpp sticker.cpp shapes.cpp scene.cpp package.cpp export.cpp print.cpp boolean.cpp modify.cpp sculpt.cpp)
target_include_directories(fry_core PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}) target_include_directories(fry_core PUBLIC ${CMAKE_CURRENT_SOURCE_DIR})
target_link_libraries(fry_core PUBLIC glm::glm libzstd_static PRIVATE stb cgltf manifold) target_link_libraries(fry_core PUBLIC glm::glm libzstd_static)
target_compile_definitions(fry_core PUBLIC GLM_ENABLE_EXPERIMENTAL) target_compile_definitions(fry_core PUBLIC GLM_ENABLE_EXPERIMENTAL)
# Scripts (Lua) that build and change the scene: also without a window, for the tests.
add_library(fry_script STATIC script.cpp)
target_link_libraries(fry_script PUBLIC fry_core PRIVATE lua)
# The app: its class declared in app.h, what it does in app*.cpp by part.
set(FRY_APP_SOURCES app.cpp app_model.cpp app_paint.cpp app_panels.cpp app_print.cpp app_scripts.cpp app_view.cpp commands.cpp icons.cpp)
if(ANDROID) # SDL's Java side loads the app as libmain.so and calls its (SDL_)main if(ANDROID) # SDL's Java side loads the app as libmain.so and calls its (SDL_)main
add_library(fry3dx SHARED ${FRY_APP_SOURCES}) add_library(fry3dx SHARED app.cpp icons.cpp)
set_target_properties(fry3dx PROPERTIES OUTPUT_NAME main LIBRARY_OUTPUT_DIRECTORY ${CMAKE_RUNTIME_OUTPUT_DIRECTORY}) set_target_properties(fry3dx PROPERTIES OUTPUT_NAME main LIBRARY_OUTPUT_DIRECTORY ${CMAKE_RUNTIME_OUTPUT_DIRECTORY})
elseif(APPLE) # a Mac app: Fry3DX.app, content in Contents/Resources, MoltenVK in Contents/Frameworks
set(icns ${PROJECT_SOURCE_DIR}/branding/fry3dx.icns)
add_executable(fry3dx MACOSX_BUNDLE ${FRY_APP_SOURCES} ${icns})
set_source_files_properties(${icns} PROPERTIES MACOSX_PACKAGE_LOCATION Resources)
set_target_properties(fry3dx PROPERTIES OUTPUT_NAME Fry3DX MACOSX_BUNDLE_INFO_PLIST ${PROJECT_SOURCE_DIR}/cmake/Info.plist.in)
# Vulkan on a Mac is MoltenVK (Vulkan on Metal): FRY_MOLTENVK=/path/to/libMoltenVK.dylib, or
# found (Homebrew's molten-vk, a Vulkan SDK). Without it, the app falls back to OpenGL 4.1.
find_library(FRY_MOLTENVK NAMES libMoltenVK.dylib MoltenVK HINTS $ENV{VULKAN_SDK}/lib /opt/homebrew/lib /usr/local/lib NO_CMAKE_FIND_ROOT_PATH)
if(FRY_MOLTENVK)
add_custom_command(TARGET fry3dx POST_BUILD
COMMAND ${CMAKE_COMMAND} -E make_directory $<TARGET_BUNDLE_CONTENT_DIR:fry3dx>/Frameworks
COMMAND ${CMAKE_COMMAND} -E copy_if_different ${FRY_MOLTENVK} $<TARGET_BUNDLE_CONTENT_DIR:fry3dx>/Frameworks/libMoltenVK.dylib
VERBATIM)
else()
message(WARNING "No MoltenVK found (set FRY_MOLTENVK): Fry3DX.app will only have OpenGL 4.1")
endif()
else() else()
add_executable(fry3dx ${FRY_APP_SOURCES}) add_executable(fry3dx app.cpp icons.cpp)
endif() endif()
if(WIN32) # a window program (no console window), with its icon if(WIN32) # a window program (no console window), with its icon
target_sources(fry3dx PRIVATE ${CMAKE_BINARY_DIR}/generated/fry3dx.rc) target_sources(fry3dx PRIVATE fry3dx.rc)
set_target_properties(fry3dx PROPERTIES WIN32_EXECUTABLE ON) set_target_properties(fry3dx PROPERTIES WIN32_EXECUTABLE ON)
target_link_options(fry3dx PRIVATE /ENTRY:mainCRTStartup) target_link_options(fry3dx PRIVATE /ENTRY:mainCRTStartup)
endif() endif()
target_link_libraries(fry3dx PRIVATE fry_core fry_script keel stb) target_link_libraries(fry3dx PRIVATE fry_core keel stb)
fry_project_header(${CMAKE_BINARY_DIR}/generated/fry_project.h ${CMAKE_BINARY_DIR}/generated/fry3dx.rc) # (project.json)
target_include_directories(fry3dx PRIVATE ${CMAKE_BINARY_DIR}/generated)
add_dependencies(fry3dx content) add_dependencies(fry3dx content)
foreach(t fry_core fry_script fry3dx) foreach(t fry_core fry3dx)
if(MSVC) if(MSVC)
target_compile_options(${t} PRIVATE /W3) target_compile_options(${t} PRIVATE /W3)
else() else()

File diff suppressed because it is too large Load diff

1549
src/app.h

File diff suppressed because it is too large Load diff

View file

@ -1,809 +0,0 @@
// Fry3DX: modelling: the knife and holes, combining and copies, spin and text, drawing a
// shape, and the mouse in the view.
#include "app.h"
using namespace fry;
namespace stdfs = std::filesystem;
namespace fryapp {
// ───────────────────────────── the knife and holes ─────────────────────────────
// Snapped to steps of 45 degrees from the last point while Ctrl is held.
auto App::knifeEnd(const ImGuiIO& io) const -> ImVec2 {
ImVec2 m = io.MousePos;
const bool have = !knifePts_.empty() || knifeDown_;
if (!io.KeyCtrl || !have) return m;
const ImVec2 from = knifePts_.empty() ? knifePress_ : knifePts_.back();
const float dx = m.x - from.x, dy = m.y - from.y, len = std::hypot(dx, dy);
const float a = std::round(std::atan2(dy, dx) / (kPi / 4)) * (kPi / 4);
return ImVec2(from.x + std::cos(a) * len, from.y + std::sin(a) * len);
}
auto App::subMesh(const Mesh& m, const std::set<int>& faces) -> Mesh {
Mesh p;
p.verts = m.verts;
for (int f : faces) p.faces.push_back(m.faces[size_t(f)]);
removeUnused(p);
return p;
}
// A new object like `from` (its look, place and finish), with another shape.
auto App::objectLike(uint32_t from, Mesh mesh, const std::string& name) -> uint32_t {
const Object copy = *find(from);
Object& n = addObject(name, std::move(mesh));
const uint32_t id = n.id;
const std::string unique = n.name;
Mesh m = std::move(n.mesh);
n = copy, n.id = id, n.name = unique, n.mesh = std::move(m), n.locked = false;
n.recipe.clear(), n.settings.clear(), n.uvs.clear();
touched(id);
return id;
}
auto App::knifeApply(uint32_t id, std::vector<ImVec2> pts) -> void {
Object* o = find(id);
if (!o || pts.size() < 2) return;
if (knifeThrough_) { // (on past its ends, so a line cuts right across)
auto past = [](ImVec2 a, ImVec2 b) {
const float dx = a.x - b.x, dy = a.y - b.y, l = std::max(std::hypot(dx, dy), 1e-3f);
return ImVec2(a.x + dx / l * 4000.0f, a.y + dy / l * 4000.0f);
};
pts.front() = past(pts[0], pts[1]);
pts.back() = past(pts[pts.size() - 1], pts[pts.size() - 2]);
}
const glm::mat4 inv = glm::inverse(objectMatrix(*o));
const glm::vec3 eye(inv * glm::vec4(eye_, 1.0f));
std::vector<glm::vec3> path;
for (ImVec2 p : pts) path.push_back(glm::vec3(inv * glm::vec4(eye_ + mouseRay(p).dir * dist_, 1.0f)));
pushUndo();
const auto made = knifeCut(o->mesh, eye, path, knifeThrough_);
if (made.empty()) {
dropUndo();
return say(knifeThrough_ ? "The line didn't cross the shape" : "The line didn't cross any face you can see (Tool options: cut right through)");
}
clearElements();
handMade(*o);
touched(id);
if (!knifeThrough_ || knifeAfter_ == 0) {
elem_ = Elem::Edge, selEdges_ = made;
return say(std::format("Cut: {} new edge{} (selected)", made.size(), made.size() == 1 ? "" : "s"));
}
auto pieces = splitAlong(o->mesh, made, knifeFill_);
if (pieces.size() < 2) {
elem_ = Elem::Edge, selEdges_ = made;
return say("Cut, but not all the way round, so it's still one piece");
}
auto size = [&](const std::set<int>& f) {
if (knifeFill_) return volumeOf(o->mesh, f);
const Mesh p = subMesh(o->mesh, f);
const glm::vec3 d = boundsMax(p) - boundsMin(p);
return d.x * d.y * d.z + 1e-9f * float(f.size());
};
std::sort(pieces.begin(), pieces.end(), [&](const std::set<int>& a, const std::set<int>& b) { return size(a) > size(b); });
const size_t n = pieces.size();
if (knifeAfter_ == 1) {
selFaces_ = pieces[1], elem_ = Elem::Face;
return say(std::format("Cut into {} pieces (the smaller selected: W moves it, P makes it its own object, Del deletes it)", n));
}
std::set<int> others;
for (size_t i = 1; i < n; ++i) others.insert(pieces[i].begin(), pieces[i].end());
std::vector<Mesh> parts;
if (knifeAfter_ == 2)
for (size_t i = 1; i < n; ++i) parts.push_back(subMesh(o->mesh, pieces[i]));
deleteFaces(o->mesh, others);
removeUnused(o->mesh);
const std::string base = o->name;
for (Mesh& part : parts) objectLike(id, std::move(part), base + " piece");
touched(id);
say(knifeAfter_ == 2 ? std::format("Cut into {} objects (see Layers)", n) : "Cut off: the smaller part is gone");
}
// The outline, `across` wide in the object's own size (a rectangle keeps its proportions).
auto App::holeShapeOutline(const Object& o, int face, const glm::vec3& centre, float across) const -> std::vector<glm::vec3> {
const float tall = across * holeTall_ / std::max(holeSize_, 1e-6f);
return holeOutline(o.mesh, face, centre, HoleShape(holeShape_), across, tall, holeSides_, holeTurn_);
}
auto App::applyHole(uint32_t id, int face, const glm::vec3& centre, float across) -> void {
Object* o = find(id);
if (!o || face < 0 || face >= int(o->mesh.faces.size()) || across <= 0) return;
const float scale = objectScale(*o);
static const char* names[] = {"A round", "A rectangular", "A triangular", "A hexagonal"};
const auto outline = holeShapeOutline(*o, face, centre, across);
pushUndo();
holeTall_ *= across * scale / std::max(holeSize_, 1e-6f), holeSize_ = across * scale;
if (holeMode_ == 2) { // just the outline: a new face, picked to extrude, bevel or paint
const int made = imprintOutline(o->mesh, face, outline);
if (made < 0) return dropUndo(), say("The outline has to fit inside the face");
changedShape(*o);
selected_ = {id}, active_ = id;
if (mode_ != Mode::Edit) enterEdit();
elem_ = Elem::Face, selFaces_ = {made};
return say(std::format("{} outline cut, {:.1f} mm across: its new face is picked (Extrude, Bevel, or paint it)", names[holeShape_], holeSize_ * 1000));
}
if (!cutHole(o->mesh, face, outline, holeMode_ == 0 ? 0.0f : holeDepth_ / scale)) {
dropUndo();
return say(holeMode_ == 0 ? "The hole has to fit inside the face, and inside the face behind it (it goes through)" : "The hole has to fit inside the face");
}
changedShape(*o);
say(std::format("{} hole, {:.1f} mm across{}", names[holeShape_], holeSize_ * 1000,
holeMode_ == 0 ? ", all the way through" : std::format(", {:.1f} mm deep", holeDepth_ * 1000)));
}
// The hole tool's outline under the mouse (or being dragged out), in the world.
auto App::holePreview(const ImGuiIO& io) -> std::vector<glm::vec3> {
uint32_t id = hole_.object;
int face = hole_.face;
glm::vec3 centre = hole_.centre;
float across = holeAcrossFor(hole_.radius);
if (!hole_.active) {
const Ray ray = mouseRay(io.MousePos);
const Hit h = pickObject(ray, mode_ == Mode::Edit ? active_ : 0, true);
if (!h.object) return {};
const Object* o = find(h.object);
id = h.object, face = h.face;
centre = glm::vec3(glm::inverse(objectMatrix(*o)) * glm::vec4(ray.origin + ray.dir * h.t, 1.0f));
across = holeSize_ / objectScale(*o);
}
const Object* o = find(id);
if (!o || face < 0 || face >= int(o->mesh.faces.size())) return {};
const glm::mat4 m = objectMatrix(*o);
std::vector<glm::vec3> out;
for (const glm::vec3& p : holeShapeOutline(*o, face, centre, across)) out.push_back(glm::vec3(m * glm::vec4(p, 1.0f)));
return out;
}
// Where the mouse is on a face's plane (the object's own space).
auto App::onFacePlane(const Object& o, int face, ImVec2 mouse) -> std::optional<glm::vec3> {
const glm::mat4 inv = glm::inverse(objectMatrix(o));
const Ray r = mouseRay(mouse);
const Ray local{glm::vec3(inv * glm::vec4(r.origin, 1.0f)), glm::normalize(glm::vec3(inv * glm::vec4(r.dir, 0.0f)))};
return hitPlane(local, faceCenter(o.mesh, face), faceNormal(o.mesh, face));
}
// ───────────────────────────── combining, copies, thickness ─────────────────────────────
// The last picked object with the others added, taken out of it, or only where they overlap
// kept; the others go.
auto App::combineSelected(Combine op) -> void {
if (mode_ == Mode::Edit) leaveEdit();
Object* a = find(active_);
if (!a || selected_.size() < 2) return say("Pick two or more objects: the last picked is kept, the others are added to it / taken out of it");
pushUndo();
Mesh result = a->hasFinish() ? finishedMesh(*a) : a->mesh;
const glm::mat4 into = glm::inverse(objectMatrix(*a));
int used = 0;
for (const Object& b : scene_.objects) {
if (b.id == a->id || !selected_.count(b.id)) continue;
Mesh m = b.hasFinish() ? finishedMesh(b) : b.mesh;
transform(m, into * objectMatrix(b));
if (!combine(result, m, op)) {
takeBack();
return say(op == Combine::Overlap ? "They don't overlap: nothing would be left" : "That didn't leave anything");
}
++used;
}
a = find(active_);
a->mesh = std::move(result);
a->mirror = 0, a->round = 0, a->smoothLevels = 0;
const std::set<uint32_t> others = [&] {
std::set<uint32_t> s = selected_;
s.erase(a->id);
return s;
}();
changedShape(*a);
std::erase_if(scene_.objects, [&](const Object& o) { return others.count(o.id) > 0; });
selected_ = {active_};
static const char* done[] = {"added to it", "taken out of it", "overlapping kept"};
say(std::format("{}: {} other{} {} ({} faces)", find(active_)->name, used, used == 1 ? "" : "s", done[int(op)], find(active_)->mesh.faces.size()));
}
auto App::makeCopies() -> void {
if (mode_ == Mode::Edit) leaveEdit();
if (selected_.empty()) return say("Pick what to copy first");
const CopiesSetting& c = copies_;
// Along a path: the last one picked is the path (its outline, or its line), the others go along it.
std::vector<fry::PathPoint> along;
int pathSkip = 0;
if (c.path) {
const Object* path = find(active_);
if (!path || selected_.size() < 2) return say("Pick what to copy, then (Shift) the path last: a drawn shape, a line, a ring...");
bool loop = false;
Mesh m = path->mesh;
transform(m, objectMatrix(*path));
const std::vector<glm::vec3> line = fry::pathOf(m, loop);
along = fry::alongPath(line, loop, c.count);
if (along.empty()) return say("The path has no length to go along");
// (one already on the path's start: that's the first, the copies after it)
const float near = glm::distance(boundsMin(m), boundsMax(m)) * 1e-3f + 1e-6f;
for (uint32_t id : selected_)
if (const Object* o = find(id); o && id != active_ && glm::distance(o->position, along[0].at) < near) {
along = fry::alongPath(line, loop, c.count + 1);
pathSkip = 1;
break;
}
}
pushUndo();
std::set<uint32_t> made = selected_;
const std::vector<Object> originals = [&] {
std::vector<Object> v;
for (const Object& o : scene_.objects)
if (selected_.count(o.id) && !(c.path && o.id == active_)) v.push_back(o);
return v;
}();
const glm::vec3 axis = c.axis == 0 ? glm::vec3(1, 0, 0) : c.axis == 1 ? glm::vec3(0, 1, 0) : glm::vec3(0, 0, 1);
for (const Object& o : originals) {
Object* joinInto = c.joined ? find(o.id) : nullptr;
for (int k = c.path ? pathSkip : 1; k <= (c.path ? int(along.size()) - 1 : c.count); ++k) {
glm::mat4 T(1.0f);
if (c.path) { // (its middle onto the point; turned as the path turns from its start)
const glm::quat turn = c.follow ? glm::rotation(along[0].along, along[size_t(k)].along) : glm::quat(1, 0, 0, 0);
T = glm::translate(glm::mat4(1.0f), along[size_t(k)].at) * glm::mat4_cast(turn) * glm::translate(glm::mat4(1.0f), -o.position);
} else if (c.round) {
const float a = glm::radians(c.angle) * float(k) / float(c.angle >= 359.99f ? c.count + 1 : c.count);
T = glm::translate(glm::mat4(1.0f), c.centre) * glm::rotate(glm::mat4(1.0f), a, axis) * glm::translate(glm::mat4(1.0f), -c.centre);
} else
T = glm::translate(glm::mat4(1.0f), c.step * float(k));
if (joinInto) { // (into the original's own shape)
Mesh m = o.mesh;
transform(m, glm::inverse(objectMatrix(o)) * T * objectMatrix(o));
append(joinInto->mesh, m);
continue;
}
Object& n = addObject(o.name, o.mesh);
const uint32_t id = n.id;
const std::string name = n.name;
n = o, n.id = id, n.name = name, n.locked = false;
n.position = glm::vec3(T * glm::vec4(o.position, 1.0f));
if (c.round || c.path) n.rotation = glm::normalize(glm::quat_cast(glm::mat3(T)) * o.rotation);
made.insert(id);
}
if (joinInto) changedShape(*joinInto);
}
selected_ = made;
touchAll();
if (c.path) selected_.erase(active_), active_ = selected_.empty() ? 0 : *selected_.rbegin(); // (the path isn't one of them)
const size_t n = c.path ? along.size() - size_t(pathSkip) : size_t(c.count);
say(std::format("{} cop{} of each made{}{}", n, n == 1 ? "y" : "ies", c.path ? " along the path" : "", c.joined ? ", joined into it" : ""));
}
auto App::copiesWindow() -> void {
if (!showCopies_) return;
ImGui::SetNextWindowPos(ImGui::GetMainViewport()->GetCenter(), ImGuiCond_Appearing, ImVec2(0.5f, 0.5f));
if (!ImGui::Begin("Make copies", &showCopies_, ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoDocking)) return ImGui::End();
CopiesSetting& c = copies_;
const float w = 280;
ImGui::SetNextItemWidth(w);
ImGui::SliderInt("##count", &c.count, 1, 100, "%d copies");
int kind = c.path ? 2 : c.round ? 1 : 0;
ImGui::SetNextItemWidth(w);
if (ImGui::Combo("##kind", &kind, "In a row\0Round in a circle\0Along a path\0")) c.round = kind == 1, c.path = kind == 2;
if (c.path) {
ImGui::PushTextWrapPos(ImGui::GetCursorPosX() + w);
ImGui::TextDisabled("Pick what to copy, then the path last (Shift+click): a drawn shape's outline, a line, a ring. The copies are spread evenly along it.");
ImGui::PopTextWrapPos();
ImGui::Checkbox("Turn them as the path turns", &c.follow);
if (const Object* p = selected_.size() >= 2 ? find(active_) : nullptr) ImGui::TextColored(accentText(), "Path: %s", p->name.c_str());
else ImGui::TextColored(roleColor(keel::ui::Role::DangerText), "No path picked yet");
} else if (!c.round) {
glm::vec3 mm = c.step * 1000.0f;
ImGui::SetNextItemWidth(w);
if (ImGui::DragFloat3("##step", &mm.x, 1.0f, -1e6f, 1e6f, "%.1f mm")) c.step = mm / 1000.0f;
ImGui::TextDisabled("each one this far on from the last (X Y Z)");
} else {
ImGui::SetNextItemWidth(w);
ImGui::Combo("##axis", &c.axis, "Round the X axis\0Round the Y axis\0Round the Z axis (up)\0");
ImGui::SetNextItemWidth(w);
ImGui::SliderFloat("##angle", &c.angle, 1.0f, 360.0f, "over %.0f degrees");
glm::vec3 mm = c.centre * 1000.0f;
ImGui::SetNextItemWidth(w);
if (ImGui::DragFloat3("##centre", &mm.x, 1.0f, -1e6f, 1e6f, "%.1f mm")) c.centre = mm / 1000.0f;
ImGui::TextDisabled("round this point (X Y Z)");
if (ImGui::Button("Round the picked one's middle", ImVec2(w, 0)))
if (const Object* o = find(active_)) c.centre = o->position;
}
ImGui::Checkbox("Joined into one object", &c.joined);
ImGui::Spacing();
pushAccent();
if (ImGui::Button("Make them", ImVec2(w, 0))) makeCopies(), showCopies_ = false;
popAccent();
ImGui::End();
}
auto App::thickenActive() -> void {
Object* o = mode_ == Mode::Edit ? editing() : find(active_);
if (!o || selected_.empty()) return say("Pick the object to make thick first");
pushUndo();
if (o->hasFinish()) o->mesh = finishedMesh(*o), o->mirror = 0, o->round = 0, o->smoothLevels = 0;
const float t = thickness_ / objectScale(*o);
thicken(o->mesh, thicknessOut_ ? -t : t);
changedShape(*o);
say(std::format("{} made {:.1f} mm thick{}", o->name, thickness_ * 1000, checkForPrint(o->mesh).closed() ? " (closed all round)" : ""));
}
// A model made elsewhere: each of its objects, standing on the ground in the middle of the view.
auto App::importFile(const std::string& file) -> void {
std::string err;
std::vector<fry::ImportedMesh> got = fry::importModel(file, &err);
if (got.empty()) return say(err.empty() ? "Nothing in " + fileName(file) : err);
pushUndo();
if (mode_ == Mode::Edit) leaveEdit();
glm::vec3 lo(1e30f), hi(-1e30f);
for (const auto& g : got) lo = glm::min(lo, boundsMin(g.mesh)), hi = glm::max(hi, boundsMax(g.mesh));
const glm::vec3 shift(-(lo.x + hi.x) / 2, -(lo.y + hi.y) / 2, -lo.z), at(std::round(target_.x * 10) / 10, std::round(target_.y * 10) / 10, 0.0f);
selected_.clear();
size_t faces = 0;
for (auto& g : got) {
// (each object's own middle as its place, so it turns about itself)
const glm::vec3 lo1 = boundsMin(g.mesh) + shift, hi1 = boundsMax(g.mesh) + shift, mid((lo1.x + hi1.x) / 2, (lo1.y + hi1.y) / 2, lo1.z);
transform(g.mesh, glm::translate(glm::mat4(1.0f), shift - mid));
faces += g.mesh.faces.size();
Object& o = addObject(g.name.empty() ? "Imported" : g.name, std::move(g.mesh));
o.position = at + mid;
selected_.insert(o.id), active_ = o.id;
}
const glm::vec3 size = (hi - lo) * 1000.0f;
say(std::format("{}: {} object{}, {} faces, {:.1f} x {:.1f} x {:.1f} mm", fileName(file), got.size(), got.size() == 1 ? "" : "s", faces, size.x, size.y, size.z));
if (glm::length(hi - lo) > 0) frameSelected();
}
auto App::spinWindow() -> void {
if (!showSpin_) return;
ImGui::SetNextWindowPos(ImGui::GetMainViewport()->GetCenter(), ImGuiCond_Appearing, ImVec2(0.5f, 0.5f));
if (!ImGui::Begin("Spin", &showSpin_, ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoDocking)) return ImGui::End();
const float w = 300;
ImGui::PushTextWrapPos(w);
ImGui::TextUnformatted("The picked shape turned round an axis: draw half a vase's outline beside it, spin it all the way round. Its open edges become the walls.");
ImGui::PopTextWrapPos();
ImGui::SetNextItemWidth(w);
ImGui::Combo("##axis", &spinAxis_, "Round X (across)\0Round Y (deep)\0Round Z (up)\0");
ImGui::SetNextItemWidth(w);
int where = spinWorld_;
if (ImGui::Combo("##where", &where, "Through the shape's own origin\0Through the middle of the world\0")) spinWorld_ = where;
ImGui::SetNextItemWidth(w);
ImGui::DragFloat("##deg", &spinDegrees_, 1.0f, -3600, 3600, "%.0f degrees");
ImGui::SetNextItemWidth(w);
ImGui::DragInt("##steps", &spinSteps_, 0.5f, 3, 512, "%d steps");
float mm = spinRise_ * 1000;
ImGui::SetNextItemWidth(w);
if (ImGui::DragFloat("##rise", &mm, 0.5f, -1e5f, 1e5f, mm == 0 ? "no rise (a spin)" : "rises %.1f mm (a screw)")) spinRise_ = mm / 1000;
ImGui::Checkbox("Keep the outline too", &spinKeep_);
pushAccent();
if (ImGui::Button("Spin it", ImVec2(w, 0))) spinSelected();
popAccent();
ImGui::End();
}
auto App::spinSelected() -> void {
Object* o = find(active_);
if (!o || !selected_.count(o->id)) return say("Pick the shape to spin first");
if (o->locked) return say(o->name + " is locked");
if (checkForPrint(shownMesh(*o)).openEdges == 0)
return say("Spin turns an outline or an open surface (a drawn shape, half a vase's side): this one is closed all round");
if (mode_ == Mode::Edit) leaveEdit();
pushUndo();
Mesh world = shownMesh(*o);
transform(world, objectMatrix(*o));
glm::vec3 axis(0.0f);
axis[spinAxis_] = 1.0f;
Mesh m = fry::spin(world, spinWorld_ ? glm::vec3(0.0f) : o->position, axis, spinDegrees_, spinSteps_, spinRise_);
if (m.faces.empty()) return takeBack(), say("Nothing to spin");
transform(m, glm::translate(glm::mat4(1.0f), -o->position));
Object& made = spinKeep_ ? addObject(o->name + " spun", Mesh{}) : *o;
if (spinKeep_) made.position = find(active_)->position, made.color = find(active_)->color;
made.mesh = std::move(m), made.rotation = glm::quat(1, 0, 0, 0), made.scale = glm::vec3(1.0f);
made.mirror = 0, made.round = 0, made.smoothLevels = 0; // (spun as it showed: its finish is in the shape now)
handMade(made), touched(made.id);
selected_ = {made.id}, active_ = made.id;
say(std::format("Spun {:.0f} degrees{}: {} faces", spinDegrees_, spinRise_ != 0 ? std::format(", rising {:.1f} mm", spinRise_ * 1000) : "", made.mesh.faces.size()));
}
auto App::textWindow() -> void {
if (!showText_) return;
ImGui::SetNextWindowPos(ImGui::GetMainViewport()->GetCenter(), ImGuiCond_Appearing, ImVec2(0.5f, 0.5f));
if (!ImGui::Begin("Add text", &showText_, ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoDocking)) return ImGui::End();
const float w = 300;
ImGui::InputTextMultiline("##text", textBuf_, sizeof textBuf_, ImVec2(w, ImGui::GetTextLineHeight() * 3.6f));
ImGui::SetNextItemWidth(w);
const std::string fontName = textFontFile_.empty() ? kFonts[textFont_] : stdfs::path(textFontFile_).filename().string();
if (ImGui::BeginCombo("##font", fontName.c_str())) {
for (int i = 0; i < int(std::size(kFonts)); ++i)
if (ImGui::Selectable(kFonts[i], textFontFile_.empty() && textFont_ == i)) textFont_ = i, textFontFile_.clear();
if (ImGui::Selectable("A font file of your own (.ttf, .otf)...")) dialog(13);
ImGui::EndCombo();
}
float mm[3] = {textHeight_ * 1000, textDepth_ * 1000, textSpacing_ * 100};
ImGui::SetNextItemWidth(w);
if (ImGui::DragFloat("##h", &mm[0], 0.2f, 0.5f, 1e5f, "letters %.1f mm tall")) textHeight_ = std::max(mm[0], 0.1f) / 1000;
ImGui::SetNextItemWidth(w);
if (ImGui::DragFloat("##d", &mm[1], 0.1f, 0.1f, 1e5f, "%.1f mm thick")) textDepth_ = std::max(mm[1], 0.05f) / 1000;
ImGui::SetNextItemWidth(w);
if (ImGui::DragFloat("##s", &mm[2], 0.2f, -30, 100, "letters %+.0f%% further apart")) textSpacing_ = mm[2] / 100;
ImGui::SetNextItemWidth(w);
ImGui::Combo("##al", &textAlign_, "Lines to the left\0Lines in the middle\0Lines to the right\0");
ImGui::Checkbox("Standing up (a sign), not lying (a label to print)", &textStanding_);
pushAccent();
if (ImGui::Button("Add it", ImVec2(w, 0))) addText();
popAccent();
ImGui::End();
}
auto App::addText() -> void {
std::vector<uint8_t> font;
if (!textFontFile_.empty()) {
std::ifstream in(textFontFile_, std::ios::binary);
font.assign(std::istreambuf_iterator<char>(in), {});
} else if (auto f = keel::fs::read(kFontFiles[textFont_]))
font = std::move(*f);
fry::TextStyle st;
st.height = textHeight_, st.depth = textDepth_, st.spacing = textSpacing_, st.align = textAlign_;
std::string err;
Mesh m = fry::makeText(font, textBuf_, st, &err);
if (m.faces.empty()) return say(err.empty() ? "No text to make" : err);
// (the letters' middle on the ground at the origin; standing up: facing the front)
const glm::vec3 lo = boundsMin(m), hi = boundsMax(m);
transform(m, glm::translate(glm::mat4(1.0f), glm::vec3(-(lo.x + hi.x) / 2, -(lo.y + hi.y) / 2, 0.0f)));
if (textStanding_) transform(m, glm::translate(glm::mat4(1.0f), glm::vec3(0, 0, (hi.y - lo.y) / 2)) * glm::rotate(glm::mat4(1.0f), kPi / 2, glm::vec3(1, 0, 0)));
pushUndo();
if (mode_ == Mode::Edit) leaveEdit();
std::string name = textBuf_;
if (const size_t nl = name.find('\n'); nl != std::string::npos) name.resize(nl);
if (name.size() > 24) name = name.substr(0, 24) + "...";
Object& o = addObject("Text: " + name, std::move(m));
o.position = glm::vec3(std::round(target_.x * 10) / 10, std::round(target_.y * 10) / 10, 0.0f);
// (reading left to right as the view looks: turned to the nearest quarter)
const glm::vec3 f = viewDir();
if (std::abs(f.x) + std::abs(f.y) > 1e-4f) {
const float turn = std::round(std::atan2(-f.x, f.y) / (kPi / 2)) * (kPi / 2);
o.rotation = glm::angleAxis(turn, glm::vec3(0, 0, 1));
}
selected_ = {o.id}, active_ = o.id;
showText_ = false;
say(std::format("Text added, {:.0f} mm tall letters, {} faces", textHeight_ * 1000, o.mesh.faces.size()));
}
auto App::thickenWindow() -> void {
if (!showThicken_) return;
ImGui::SetNextWindowPos(ImGui::GetMainViewport()->GetCenter(), ImGuiCond_Appearing, ImVec2(0.5f, 0.5f));
if (!ImGui::Begin("Make it thick", &showThicken_, ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoDocking)) return ImGui::End();
const float w = 260;
float mm = thickness_ * 1000;
ImGui::SetNextItemWidth(w);
if (ImGui::DragFloat("##t", &mm, 0.1f, 0.1f, 1e5f, "%.2f mm thick")) thickness_ = mm / 1000;
int out = thicknessOut_ ? 1 : 0;
ImGui::SetNextItemWidth(w);
if (ImGui::Combo("##dir", &out, "Inwards (a closed shape: hollow)\0Outwards\0")) thicknessOut_ = out == 1;
ImGui::TextWrapped("A flat or open surface gets a back and edges: a solid you can print. A closed shape becomes hollow, walls this thick (to save material or make a box).");
pushAccent();
if (ImGui::Button("Make it thick", ImVec2(w, 0))) thickenActive(), showThicken_ = false;
popAccent();
ImGui::End();
}
auto App::cleanFlatFaces() -> void {
Object* o = mode_ == Mode::Edit ? editing() : find(active_);
if (!o || selected_.empty()) return say("Pick an object first");
pushUndo();
const int n = mergeFlatFaces(o->mesh);
changedShape(*o);
say(n ? std::format("{} faces joined into the flat ones beside them", n) : "Nothing to join: no flat faces side by side");
}
// ───────────────────────────── drawing a shape ─────────────────────────────
// The plane shapes are drawn on: the one the view looks straight at, or the ground.
auto App::workPlane(glm::vec3& point, glm::vec3& normal) const -> void {
point = glm::vec3(0.0f);
normal = view_ == View::Front || view_ == View::Back ? glm::vec3(1, 0, 0) : view_ == View::Left || view_ == View::Right ? glm::vec3(0, 1, 0) : glm::vec3(0, 0, 1);
}
// The plane drawn on: the face under the first point if it was on an object, else the view's.
auto App::drawPlane(glm::vec3& p, glm::vec3& n) -> void {
if (drawOnto_ && find(drawOnto_)) p = drawOnP_, n = drawOnN_;
else workPlane(p, n);
}
auto App::drawPoint(const ImGuiIO& io) -> std::optional<glm::vec3> {
const Ray ray = mouseRay(io.MousePos);
if (drawPts_.empty()) { // (the first point: on an object, its face is where it's drawn)
drawOnto_ = 0;
const Hit h = pickObject(ray, 0, true);
if (const Object* o = h.object ? find(h.object) : nullptr) {
const glm::mat4 m = objectMatrix(*o);
drawOnto_ = o->id;
drawOnP_ = ray.origin + ray.dir * h.t;
drawOnN_ = glm::normalize(glm::transpose(glm::inverse(glm::mat3(m))) * faceNormal(o->mesh, h.face));
}
}
glm::vec3 p, n;
drawPlane(p, n);
auto hit = hitPlane(ray, p, n);
if (!hit) return std::nullopt;
if (snap_ != io.KeyCtrl) { // (on the grid, then back onto the plane)
const float step = gridStep() / 2.0f;
*hit = glm::vec3(snapTo(hit->x, step), snapTo(hit->y, step), snapTo(hit->z, step));
*hit -= n * glm::dot(*hit - p, n);
}
// Exact sizes: Shift holds the line to 15 degree steps; a typed number is its length (m).
if (!drawPts_.empty() && (io.KeyShift || !sketchTyped_.empty())) {
const glm::vec3 last = drawPts_.back(), u = glm::normalize(std::abs(n.z) < 0.9f ? glm::cross(glm::vec3(0, 0, 1), n) : glm::cross(glm::vec3(1, 0, 0), n)), v = glm::cross(n, u);
glm::vec3 d = *hit - last;
float len = glm::length(d);
float a = std::atan2(glm::dot(d, v), glm::dot(d, u));
if (io.KeyShift) a = std::round(a / glm::radians(15.0f)) * glm::radians(15.0f);
if (!sketchTyped_.empty() && sketchTyped_ != "." && sketchTyped_ != "-") len = std::abs(std::strtof(sketchTyped_.c_str(), nullptr));
*hit = last + (u * std::cos(a) + v * std::sin(a)) * len;
}
return hit;
}
// (drawing a shape: the next line's length, typed)
// Drawing a shape: numbers typed go to the next line's length, Enter puts the point there.
auto App::sketchKeys(const ImGuiIO& io) -> bool {
bool used = false;
for (ImWchar c : io.InputQueueCharacters)
if ((c >= '0' && c <= '9') || (c == '.' && sketchTyped_.find('.') == std::string::npos)) sketchTyped_ += char(c), used = true;
if (ImGui::IsKeyPressed(ImGuiKey_Backspace) && !sketchTyped_.empty()) sketchTyped_.pop_back(), used = true;
if (!sketchTyped_.empty() && (ImGui::IsKeyPressed(ImGuiKey_Enter) || ImGui::IsKeyPressed(ImGuiKey_KeypadEnter))) {
if (auto p = drawPoint(io)) drawPts_.push_back(*p);
sketchTyped_.clear(), used = true;
}
return used;
}
auto App::finishShape() -> void {
sketchTyped_.clear();
if (drawPts_.size() < 3) {
drawPts_.clear();
return say("A shape needs 3 points or more");
}
glm::vec3 p, n;
drawPlane(p, n);
const uint32_t onto = drawOnto_ && find(drawOnto_) ? drawOnto_ : 0;
drawOnto_ = 0;
glm::vec3 c(0.0f);
for (const glm::vec3& q : drawPts_) c += q;
c /= float(drawPts_.size());
std::vector<glm::vec3> local;
for (const glm::vec3& q : drawPts_) local.push_back(q - c);
// On an object it stands on the face (a hair into it, so they join), or goes down into it.
const float t = drawThickness_, e = 0.0001f;
float thick = t;
if (onto && drawOnMode_ == 2) thick = t + 2 * e, c += n * (e - thick / 2);
else if (onto) thick = t + e, c += n * (t - thick / 2);
Mesh m = extrudeOutline(local, n, thick);
drawPts_.clear();
if (m.faces.empty()) return say("That shape doesn't work (points on top of each other?)");
if (onto && drawOnMode_ > 0 && find(onto)->locked) return say("That object is locked");
pushUndo();
if (onto && drawOnMode_ > 0) {
Object* o = find(onto);
if (o->hasFinish()) o->mesh = finishedMesh(*o), o->mirror = 0, o->round = 0, o->smoothLevels = 0;
Mesh shape = m;
transform(shape, glm::inverse(objectMatrix(*o)) * glm::translate(glm::mat4(1.0f), c));
Mesh result = o->mesh;
if (!combine(result, shape, drawOnMode_ == 2 ? Combine::Subtract : Combine::Add)) {
takeBack();
return say("That would leave nothing");
}
o = find(onto);
o->mesh = std::move(result);
changedShape(*o);
selected_ = {o->id}, active_ = o->id;
return say(std::format("{} {:.0f} mm {} {}", drawOnMode_ == 2 ? "Cut" : "Added", t * 1000, drawOnMode_ == 2 ? "deep into" : "high onto", o->name));
}
Object& o = addObject("Shape", std::move(m));
o.position = c;
selected_ = {o.id}, active_ = o.id;
say(std::format("Shape made, {:.0f} mm thick{}. Tab edits its shape; Thickness is in Tool options.", t * 1000, onto ? ", standing on " + find(onto)->name : ""));
}
// ───────────────────────────── the viewport's mouse ─────────────────────────────
auto App::viewport(ImGuiIO& io) -> void {
cutHover_ = {-1, -1};
if (slide_.active) return slideFrame(io);
if (tab_ == Tab::Paint) return paintViewport(io);
if (tab_ == Tab::Layout) return layoutViewport(io);
if ((tab_ == Tab::Print && printTool_ != PrintTool::None) || printTool_ == PrintTool::Measure) return printViewport(io); // (measuring: the Model tab too)
if (knifeDown_ && !ImGui::IsMouseDown(ImGuiMouseButton_Left)) {
knifeDown_ = false;
const ImVec2 end = knifeEnd(io);
if (knifePts_.empty() && std::hypot(end.x - knifePress_.x, end.y - knifePress_.y) > 8) {
if (Object* o = editing()) knifeApply(o->id, {knifePress_, end});
} else
knifePts_.push_back(end);
return;
}
if (hole_.active) {
Object* o = find(hole_.object);
if (!o) hole_.active = false;
else if (ImGui::IsMouseDown(ImGuiMouseButton_Left)) {
if (auto p = onFacePlane(*o, hole_.face, io.MousePos)) hole_.radius = glm::distance(*p, hole_.centre);
message_ = std::format("{:.1f} mm across", holeAcrossFor(hole_.radius) * objectScale(*o) * 1000), messageAt_ = now();
} else {
hole_.active = false;
const bool dragged = std::hypot(io.MousePos.x - hole_.press.x, io.MousePos.y - hole_.press.y) > 4;
applyHole(hole_.object, hole_.face, hole_.centre, dragged ? holeAcrossFor(hole_.radius) : holeSize_ / objectScale(*o));
}
return;
}
if (drag_.active) {
if (ImGui::IsMouseDown(ImGuiMouseButton_Left)) updateDrag(io);
else {
drag_.active = false;
if (drag_.tool == Tool::Bevel) clearElements(); // (renumbered)
}
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 void(tapDraw_ = false);
if (std::exchange(tapDraw_, false)) return addDrawPoint(io);
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);
} else
clickSelect(io);
}
return;
}
if (lassoing_) {
if (ImGui::IsMouseDown(ImGuiMouseButton_Left)) {
if (std::hypot(io.MousePos.x - lasso_.back().x, io.MousePos.y - lasso_.back().y) > 3) lasso_.push_back(io.MousePos);
} else {
lassoing_ = false;
if (lasso_.size() > 4) regionSelect([&](ImVec2 s) { return insidePolygon(s, lasso_); }, io.KeyShift, io.KeyCtrl, !io.KeyShift && !io.KeyCtrl);
else clickSelect(io);
}
return;
}
if (painting_) {
if (!ImGui::IsMouseDown(ImGuiMouseButton_Left)) painting_ = false;
else paint(io);
return;
}
if (!mouseInView(io)) return;
if (!pluginTool_.first.empty() && mode_ == Mode::Object) {
if (ImGui::IsMouseClicked(ImGuiMouseButton_Left) && hoverHandle(io.MousePos) < 0) clickPluginTool(io.MousePos);
if (ImGui::IsMouseClicked(ImGuiMouseButton_Left) || !pluginToolFound()) return;
}
if (tool_ == Tool::Draw) {
if (!ImGui::IsMouseClicked(ImGuiMouseButton_Left)) return;
if (touch()) return void((boxing_ = touchOrbit_ = tapDraw_ = true, boxStart_ = io.MousePos)); // (a finger: a tap puts a point down when it lifts, a drag turns the view)
addDrawPoint(io);
return;
}
if (tool_ == Tool::Knife) {
if (!editing()) return;
if (ImGui::IsMouseClicked(ImGuiMouseButton_Left)) {
if (ImGui::IsMouseDoubleClicked(ImGuiMouseButton_Left) && knifePts_.size() >= 2) {
std::vector<ImVec2> pts = knifePts_;
knifePts_.clear();
return knifeApply(active_, pts);
}
knifeDown_ = true, knifePress_ = io.MousePos;
}
return;
}
if (tool_ == Tool::Hole) {
if (!ImGui::IsMouseClicked(ImGuiMouseButton_Left)) return;
const Ray ray = mouseRay(io.MousePos);
const Hit h = pickObject(ray, mode_ == Mode::Edit ? active_ : 0, true);
if (!h.object) return;
Object* o = find(h.object);
if (o->locked) return say("That object is locked");
if (mode_ == Mode::Object) selected_ = {h.object}, active_ = h.object;
hole_ = {true, h.object, h.face, glm::vec3(glm::inverse(objectMatrix(*o)) * glm::vec4(ray.origin + ray.dir * h.t, 1.0f)), 0.0f, io.MousePos};
return;
}
if (tool_ == Tool::LoopCut) {
if (Object* o = editing()) {
cutHover_ = nearestEdge(*o, io.MousePos, 30.0f);
if (cutHover_.first >= 0 && ringQuads(o->mesh, cutHover_).empty()) cutHover_ = {-1, -1};
if (ImGui::IsMouseClicked(ImGuiMouseButton_Left) && cutHover_.first >= 0) cutAt(*o, cutHover_);
}
return;
}
if (ImGui::IsMouseClicked(ImGuiMouseButton_Left)) {
if (const int h = hoverHandle(io.MousePos); h >= 0) return startDrag(io, h);
if (mode_ == Mode::Edit && (io.KeyAlt || ImGui::IsMouseDoubleClicked(ImGuiMouseButton_Left))) return loopSelect(io);
if (ImGui::IsMouseDoubleClicked(ImGuiMouseButton_Left) && mode_ == Mode::Object) { // double click: edit its shape
const Hit hit = pickObject(mouseRay(io.MousePos));
if (hit.object) {
selected_ = {hit.object}, active_ = hit.object;
enterEdit();
return;
}
}
if (selShape_ == SelShape::Brush) {
painting_ = true;
if (!io.KeyShift && !io.KeyCtrl) selectNone();
paint(io);
} else if (selShape_ == SelShape::Lasso) lassoing_ = true, lasso_ = {io.MousePos};
else boxing_ = true, boxStart_ = io.MousePos, touchOrbit_ = touch() && tool_ != Tool::Select; // (a finger: dragging turns the view, but Select's box)
}
}
auto App::addDrawPoint(const ImGuiIO& io) -> void {
if (auto p = drawPoint(io)) {
ImVec2 s0, s1;
if (drawPts_.size() >= 3 && project(drawPts_[0], s0) && project(*p, s1) && std::hypot(s0.x - s1.x, s0.y - s1.y) < (touch() ? 24 : 10)) finishShape();
else drawPts_.push_back(*p);
}
}
// Painting: on the model under the mouse.
auto App::paintViewport(ImGuiIO& io) -> void {
if (sculpting_) return sculptViewport(io);
if (drawingStencil_) { // (drawing the stencil: a drag round the area)
if (ImGui::IsMouseDown(ImGuiMouseButton_Left) && mouseInView(io)) {
if (stencil_.empty() || std::hypot(stencil_.back().x - io.MousePos.x, stencil_.back().y - io.MousePos.y) > 3) stencil_.push_back(io.MousePos);
} else if (!stencil_.empty()) {
drawingStencil_ = false, stencilOn_ = stencil_.size() >= 3;
if (!stencilOn_) stencil_.clear();
say(stencilOn_ ? "Stencil on: paint goes only inside it" : "Too small for a stencil: drag round an area");
}
return;
}
if (paint_.waiting) {
if (!ImGui::IsMouseDown(ImGuiMouseButton_Left)) paint_.waiting = false;
else if (pickObject(mouseRay(io.MousePos), 0, true).object) paint_.waiting = false, paintAt(io.MousePos, true);
return;
}
if (paint_.active) {
if (paint_.in2D) return;
if (!ImGui::IsMouseDown(ImGuiMouseButton_Left)) return endStroke();
return paintAt(io.MousePos, false);
}
if (PictureLayer* l = editLayer()) { // (a picture being moved: on the model if it's a sticker)
if (l->sticker) stickerMouse(io);
return;
}
if (!mouseInView(io) || !ImGui::IsMouseClicked(ImGuiMouseButton_Left)) return;
const Ray ray = mouseRay(io.MousePos);
const Hit h = pickObject(ray, 0, true);
if (!h.object) {
paint_.waiting = paintTool_ == PaintTool::Brush || paintTool_ == PaintTool::Eraser;
return;
}
const bool picking = paintTool_ == PaintTool::Picker || io.KeyAlt;
if (paintTool_ == PaintTool::Picture) {
selected_ = {h.object}, active_ = h.object;
return;
}
if (picking) {
const Object* o = find(h.object);
if (o->picture) {
if (auto uv = uvUnder(*o, ray)) pickColor(*o, *uv);
} else
paintColor_ = glm::vec4(o->color, paintColor_.a);
return;
}
if (paintTool_ == PaintTool::Fill) {
selected_ = {h.object}, active_ = h.object;
Object* o = readyToPaint(h.object);
if (!o) return;
if (auto uv = uvUnder(*o, ray)) fillAt(*o, *uv);
return;
}
paintAt(io.MousePos, true);
}
// The brush: whatever passes under it is selected (or, with Ctrl, let go).
auto App::paint(const ImGuiIO& io) -> void {
if (mode_ == Mode::Object) {
const Hit h = pickObject(mouseRay(io.MousePos));
if (h.object) io.KeyCtrl ? (void)selected_.erase(h.object) : (void)(selected_.insert(h.object), active_ = h.object);
return;
}
const ImVec2 m = io.MousePos;
const float r = brushRadius_;
regionSelect([&](ImVec2 s) { return std::hypot(s.x - m.x, s.y - m.y) < r; }, true, io.KeyCtrl, false);
}
} // namespace fryapp

View file

@ -1,757 +0,0 @@
// Fry3DX: painting and sculpting, picture layers, and the Layout tab.
#include "app.h"
using namespace fry;
namespace stdfs = std::filesystem;
namespace fryapp {
// ───────────────────────────── painting ─────────────────────────────
// The object ready to be painted: its rounding and smoothing made part of it, its surface laid
// out on a picture (made the plain colour of it at first).
auto App::readyToPaint(uint32_t id) -> Object* {
Object* o = find(id);
if (!o) return nullptr;
if (o->locked) return say("That object is locked"), nullptr;
if (o->picture && o->uvsFit() && !o->hasFinish()) return o;
pushUndo();
if (o->hasFinish()) {
o->mesh = finishedMesh(*o);
o->mirror = 0, o->round = 0, o->smoothLevels = 0;
handMade(*o);
}
o->uvs = autoUnwrap(o->mesh);
if (!o->picture) o->picture = std::make_shared<Image>(makeImage(pictureSize_, pictureSize_, glm::vec4(o->color, 1.0f)));
touched(o->id);
picZoom_ = 0;
say(std::format("{} is ready to paint: its surface is laid out flat on a {}x{} picture", o->name, o->picture->w, o->picture->h));
return o;
}
// The object's picture, copied so this change can be undone; the copy is now the object's.
auto App::changePicture(Object& o) -> Image& {
pushUndo();
auto img = std::make_shared<Image>(*o.picture);
o.picture = img;
paint_.image = img;
uploaded_[o.id].dirty = true;
return *img;
}
auto App::beginStroke(Object& o, bool in2D) -> void {
Image& img = changePicture(o);
paint_.active = true, paint_.in2D = in2D, paint_.haveLast = false, paint_.object = o.id;
paint_.stroke.begin(img, paintTool_ == PaintTool::Eraser ? glm::vec4(o.color, 1.0f) : paintColor_);
}
auto App::endStroke() -> void {
if (!paint_.active) return;
paint_.active = false;
uploaded_[paint_.object].dirty = true;
paint_.image.reset();
paint_.stroke = {};
}
// Where on its picture a ray hits an object (0..1), or nothing.
auto App::uvUnder(const Object& o, const Ray& ray) -> std::optional<glm::vec2> {
if (!o.uvsFit()) return std::nullopt;
const glm::mat4 m = objectMatrix(o);
float best = 1e30f;
std::optional<glm::vec2> out;
for (size_t f = 0; f < o.mesh.faces.size(); ++f) {
const auto& fv = o.mesh.faces[f];
for (const auto& t : triangulate(o.mesh, int(f))) {
glm::vec3 w[3];
glm::vec2 uv[3];
for (int k = 0; k < 3; ++k) {
w[k] = glm::vec3(m * glm::vec4(o.mesh.verts[size_t(t[size_t(k)])], 1.0f));
uv[k] = o.uvs[f][size_t(std::find(fv.begin(), fv.end(), t[size_t(k)]) - fv.begin())];
}
const glm::vec3 e1 = w[1] - w[0], e2 = w[2] - w[0], p = glm::cross(ray.dir, e2);
const float det = glm::dot(e1, p);
if (std::abs(det) < 1e-12f) continue;
const glm::vec3 sv = ray.origin - w[0], q = glm::cross(sv, e1);
const float u = glm::dot(sv, p) / det, v = glm::dot(ray.dir, q) / det, tt = glm::dot(e2, q) / det;
if (u < 0 || v < 0 || u + v > 1 || tt <= 0 || tt >= best) continue;
best = tt, out = uv[0] * (1 - u - v) + uv[1] * u + uv[2] * v;
}
}
return out;
}
// One dab of the brush on the model: on every face near it, into the picture where that face
// lies (so it carries on across the seams between pieces of the layout).
// One dab of paint at a point of the object (the world), on the faces towards `eye` (the
// mirrored dab: towards the eye mirrored). The stencil: only where the surface shows inside
// it on the screen (the mirrored dab: where its mirror image does, `back` taking it there).
auto App::dab3D(Object& o, const glm::vec3& at, float radius, const glm::vec3* eyeAt, const glm::mat4* back) -> void {
Image& im = *paint_.image;
const glm::mat4 m = objectMatrix(o);
const glm::vec3 eye = eyeAt ? *eyeAt : eye_;
const bool stencil = stencilOn_ && stencil_.size() >= 3;
const glm::vec2 size(float(im.w), float(im.h));
const float inner = std::clamp(hardness_, 0.0f, 0.999f) * radius;
for (size_t f = 0; f < o.mesh.faces.size(); ++f) {
const auto& fv = o.mesh.faces[f];
for (const auto& t : triangulate(o.mesh, int(f))) {
glm::vec3 w[3];
glm::vec2 px[3];
for (int k = 0; k < 3; ++k) {
w[k] = glm::vec3(m * glm::vec4(o.mesh.verts[size_t(t[size_t(k)])], 1.0f));
px[k] = o.uvs[f][size_t(std::find(fv.begin(), fv.end(), t[size_t(k)]) - fv.begin())] * size;
}
const glm::vec3 lo = glm::min(w[0], glm::min(w[1], w[2])) - radius, hi = glm::max(w[0], glm::max(w[1], w[2])) + radius;
if (glm::any(glm::lessThan(at, lo)) || glm::any(glm::greaterThan(at, hi))) continue;
const glm::vec3 n = glm::cross(w[1] - w[0], w[2] - w[0]);
const float area3 = glm::length(n) * 0.5f;
if (area3 < 1e-12f || glm::dot(n, eye - w[0]) <= 0 || std::abs(glm::dot(glm::normalize(n), at - w[0])) > radius) continue;
const float areaPx = std::abs((px[1].x - px[0].x) * (px[2].y - px[0].y) - (px[2].x - px[0].x) * (px[1].y - px[0].y)) * 0.5f;
if (areaPx < 1e-6f) continue;
// The dab's middle on the picture, and how far it reaches there.
const glm::vec3 nn = glm::normalize(n), c = at - nn * glm::dot(nn, at - w[0]);
const glm::vec3 v0 = w[1] - w[0], v1 = w[2] - w[0], v2 = c - w[0];
const float d00 = glm::dot(v0, v0), d01 = glm::dot(v0, v1), d11 = glm::dot(v1, v1), d20 = glm::dot(v2, v0), d21 = glm::dot(v2, v1), den = d00 * d11 - d01 * d01;
const float bv = (d11 * d20 - d01 * d21) / den, bw = (d00 * d21 - d01 * d20) / den;
const glm::vec2 mid = px[0] * (1 - bv - bw) + px[1] * bv + px[2] * bw;
const float reach = radius * std::sqrt(areaPx / area3) * 1.5f + 2.0f;
glm::vec2 plo = glm::max(glm::min(px[0], glm::min(px[1], px[2])) - 2.0f, mid - reach), phi = glm::min(glm::max(px[0], glm::max(px[1], px[2])) + 2.0f, mid + reach);
plo = glm::max(plo, glm::vec2(0.0f)), phi = glm::min(phi, size - 1.0f);
const float twice = areaPx * 2.0f * ((px[1].x - px[0].x) * (px[2].y - px[0].y) - (px[2].x - px[0].x) * (px[1].y - px[0].y) > 0 ? 1.0f : -1.0f);
for (int y = int(plo.y); y <= int(phi.y); ++y)
for (int x = int(plo.x); x <= int(phi.x); ++x) {
const glm::vec2 q(float(x) + 0.5f, float(y) + 0.5f);
float l[3];
bool ok = true;
for (int k = 0; k < 3; ++k) {
const glm::vec2 a = px[(k + 1) % 3], b = px[(k + 2) % 3];
l[k] = ((b.x - a.x) * (q.y - a.y) - (q.x - a.x) * (b.y - a.y)) / twice;
ok &= l[k] * std::abs(twice) / std::max(glm::length(b - a), 1e-6f) >= -1.5f; // (a pixel and a half past the edge: no seams)
}
if (!ok) continue;
const glm::vec3 p = w[0] * l[0] + w[1] * l[1] + w[2] * l[2];
const float d = glm::length(p - at);
if (d >= radius) continue;
if (stencil) {
ImVec2 sp;
if (!project(back ? glm::vec3(*back * glm::vec4(p, 1.0f)) : p, sp) || !insidePolygon(sp, stencil_)) continue;
}
const float s = d <= inner ? 1.0f : 1.0f - (d - inner) / (radius - inner);
paint_.stroke.put(im, x, y, s * s * (3 - 2 * s));
}
}
}
uploaded_[o.id].dirty = true;
}
// The brush at a point of the view: starts a stroke on what's there, or carries one on.
auto App::paintAt(ImVec2 mouse, bool start) -> void {
const Ray ray = mouseRay(mouse);
if (start) {
const Hit h = pickObject(ray, 0, true);
if (!h.object) return;
selected_ = {h.object}, active_ = h.object;
Object* o = readyToPaint(h.object);
if (!o) return;
beginStroke(*o, false);
}
if (!paint_.active || paint_.in2D) return;
Object* o = find(paint_.object);
if (!o) return;
const Hit h = pickObject(ray, o->id, true);
if (!h.object) {
paint_.haveLast = false;
return;
}
const glm::vec3 at = ray.origin + ray.dir * h.t;
const float radius = brushPx_ * pixelSize(at);
const float gap = glm::distance(at, paint_.last);
// (painting both sides: the same dab mirrored across the object's own Y, its left and right)
const glm::mat4 m = objectMatrix(*o), mirror = m * glm::scale(glm::mat4(1.0f), glm::vec3(1, -1, 1)) * glm::inverse(m);
const glm::vec3 eyeMirrored = glm::vec3(mirror * glm::vec4(eye_, 1.0f));
auto dab = [&](const glm::vec3& p) {
dab3D(*o, p, radius);
if (paintMirror_) dab3D(*o, glm::vec3(mirror * glm::vec4(p, 1.0f)), radius, &eyeMirrored, &mirror);
};
if (paint_.haveLast && gap < radius * 8) {
const int steps = std::max(1, int(std::ceil(gap / (radius * 0.3f))));
for (int i = 1; i <= steps; ++i) dab(glm::mix(paint_.last, at, float(i) / float(steps)));
} else
dab(at);
paint_.last = at, paint_.haveLast = true;
}
auto App::pickColor(const Object& o, glm::vec2 uv) -> void {
const glm::vec4 c = sample(*o.picture, uv * glm::vec2(o.picture->w, o.picture->h));
paintColor_ = glm::vec4(glm::vec3(c), paintColor_.a);
}
auto App::fillAt(Object& o, glm::vec2 uv) -> void {
Image& img = changePicture(o);
floodFill(img, glm::ivec2(uv * glm::vec2(img.w, img.h)), glm::vec4(glm::vec3(paintColor_), 1.0f), fillTolerance_);
paint_.image.reset();
}
// ── Sculpting ──
// The object ready to sculpt: its finishing touches made part of it, and made finer (the same
// shape) if it has too few points to push about.
auto App::readyToSculpt(uint32_t id) -> Object* {
Object* o = find(id);
if (!o) return nullptr;
if (o->locked) return say("That object is locked"), nullptr;
pushUndo();
if (o->hasFinish()) {
o->mesh = finishedMesh(*o);
o->mirror = 0, o->round = 0, o->smoothLevels = 0;
}
handMade(*o);
if (finerForSculpting(o->mesh)) {
o->smooth = true;
say(std::format("{} was made finer to sculpt: {} points now, the same shape", o->name, o->mesh.verts.size()));
}
touched(o->id);
return o;
}
auto App::endSculpt() -> void {
if (!sculpt_.active) return;
sculpt_ = {};
}
// The brush at a point of the view: starts a stroke on what's there, or carries one on.
auto App::sculptAt(ImVec2 mouse, bool start, bool ctrl, bool shift) -> void {
const Ray ray = mouseRay(mouse);
const auto toLocal = [](const Object& o, float radius) { return radius * 3.0f / glm::max(glm::abs(o.scale.x) + glm::abs(o.scale.y) + glm::abs(o.scale.z), 1e-6f); };
const glm::vec3 other(1, -1, 1); // (the other side: the object's Y turned round)
if (start) {
const Hit h = pickObject(ray, 0, true);
if (!h.object) return;
selected_ = {h.object}, active_ = h.object;
Object* o = readyToSculpt(h.object);
if (!o) return;
sculpt_.active = true, sculpt_.object = o->id;
sculpt_.mesh = std::make_unique<SculptMesh>(o->mesh);
if (sculptBrush_ == SculptBrush::Grab && !shift) { // (made finer: the same shape, so the same spot)
sculpt_.start = ray.origin + ray.dir * h.t;
const glm::vec3 at = glm::vec3(glm::inverse(objectMatrix(*o)) * glm::vec4(sculpt_.start, 1.0f));
const float radius = toLocal(*o, sculptPx_ * pixelSize(sculpt_.start));
for (int side = 0; side < (sculptMirror_ ? 2 : 1); ++side) {
sculpt_.held[side] = grabbed(o->mesh, side ? at * other : at, radius);
for (const auto& [i, w] : sculpt_.held[side]) sculpt_.from[side].push_back(o->mesh.verts[size_t(i)]);
}
}
}
if (!sculpt_.active) return;
Object* o = find(sculpt_.object);
if (!o) return endSculpt();
const glm::mat4 inv = glm::inverse(objectMatrix(*o));
if (!sculpt_.from[0].empty()) { // grabbing: it follows the mouse across the view
const auto p = hitPlane(ray, sculpt_.start, forwardOf(yaw_, pitch_));
if (!p) return;
const glm::vec3 d = glm::vec3(inv * glm::vec4(*p - sculpt_.start, 0.0f));
moveGrabbed(o->mesh, sculpt_.held[0], sculpt_.from[0], d);
if (sculptMirror_) moveGrabbed(o->mesh, sculpt_.held[1], sculpt_.from[1], d * other);
return touched(o->id);
}
const Hit h = pickObject(ray, o->id, true);
if (!h.object) {
sculpt_.haveLast = false;
return;
}
const glm::vec3 at = ray.origin + ray.dir * h.t;
const float radius = sculptPx_ * pixelSize(at);
const SculptBrush brush = shift ? SculptBrush::Smooth : sculptBrush_;
const auto dab = [&](const glm::vec3& world) {
Dab d{glm::vec3(inv * glm::vec4(world, 1.0f)), toLocal(*o, radius), sculptStrength_, ctrl};
sculptDab(o->mesh, *sculpt_.mesh, brush, d);
if (sculptMirror_) d.at *= other, sculptDab(o->mesh, *sculpt_.mesh, brush, d);
};
// A dab every quarter of the brush along the way, not one a frame (fast strokes stay even).
const float gap = glm::distance(at, sculpt_.last);
if (!sculpt_.haveLast) dab(at);
else if (gap >= radius * 0.25f && gap < radius * 8) {
const int steps = int(gap / (radius * 0.25f));
for (int i = 1; i <= steps; ++i) dab(glm::mix(sculpt_.last, at, float(i) / float(steps)));
} else if (gap < radius * 0.25f)
return; // (not far enough for the next dab yet)
else
dab(at);
sculpt_.last = at, sculpt_.haveLast = true;
touched(o->id);
}
auto App::sculptViewport(const ImGuiIO& io) -> void {
if (sculpt_.waiting) {
if (!ImGui::IsMouseDown(ImGuiMouseButton_Left)) sculpt_.waiting = false;
else if (pickObject(mouseRay(io.MousePos), 0, true).object) sculpt_.waiting = false, sculptAt(io.MousePos, true, io.KeyCtrl, io.KeyShift);
return;
}
if (sculpt_.active) {
if (!ImGui::IsMouseDown(ImGuiMouseButton_Left)) return endSculpt();
return sculptAt(io.MousePos, false, io.KeyCtrl, io.KeyShift);
}
if (!mouseInView(io) || !ImGui::IsMouseClicked(ImGuiMouseButton_Left)) return;
if (pickObject(mouseRay(io.MousePos), 0, true).object) sculptAt(io.MousePos, true, io.KeyCtrl, io.KeyShift);
else sculpt_.waiting = sculptBrush_ != SculptBrush::Grab;
}
// ── Picture layers ──
auto App::editLayer() -> PictureLayer* {
Object* o = find(ledit_.object);
return o && ledit_.index >= 0 && ledit_.index < int(o->layers.size()) ? &o->layers[size_t(ledit_.index)] : nullptr;
}
// A picture put on the selected object as a new layer, unlocked to be moved: a sticker on the
// surface in the middle of the view, or pinned onto the flat picture.
auto App::startFloating(Image img) -> void {
Object* o = selected_.empty() ? nullptr : readyToPaint(active_);
if (!o) return say("Select the object to put the picture on first");
if (img.empty()) return;
lockLayer();
pushUndo();
PictureLayer l;
l.name = std::format("Picture {}", o->layers.size() + 1);
l.image = std::make_shared<Image>(std::move(img));
l.sticker = pasteOnSurface_;
const float aspect = float(l.image->h) / float(std::max(l.image->w, 1));
if (l.sticker) {
const glm::mat4 m = objectMatrix(*o), inv = glm::inverse(m);
const Ray ray = mouseRay(ImVec2((viewMin_.x + viewMax_.x) * 0.5f, (viewMin_.y + viewMax_.y) * 0.5f));
const Hit h = pickObject(ray, o->id, true);
if (h.object) l.face = h.face, l.centre = glm::vec3(inv * glm::vec4(ray.origin + ray.dir * h.t, 1.0f));
else { // (the face looking most at you)
float best = -2;
const glm::vec3 toEye = glm::normalize(glm::vec3(inv * glm::vec4(-viewDir(), 0.0f)));
for (size_t f = 0; f < o->mesh.faces.size(); ++f)
if (const float d = glm::dot(faceNormal(o->mesh, int(f)), toEye); d > best) best = d, l.face = int(f), l.centre = faceCenter(o->mesh, int(f));
}
// Upright as you see it, a third as big as the object.
const glm::vec3 f = viewDir(), right = glm::normalize(glm::cross(f, std::abs(f.z) > 0.99f ? glm::vec3(std::cos(glm::radians(yaw_)), std::sin(glm::radians(yaw_)), 0) : glm::vec3(0, 0, 1)));
l.up = glm::normalize(glm::vec3(inv * glm::vec4(glm::cross(right, f), 0.0f)));
const glm::vec3 d = boundsMax(o->mesh) - boundsMin(o->mesh);
l.width = std::max({d.x, d.y, d.z}) * 0.35f, l.height = l.width * aspect;
} else {
const float W = float(o->picture->w), H = float(o->picture->h);
const float k = std::min(W * 0.6f / float(l.image->w), H * 0.6f / float(l.image->h));
const glm::vec2 half(float(l.image->w) * k * 0.5f, float(l.image->h) * k * 0.5f), c(W * 0.5f, H * 0.5f);
l.corners = {c - half, glm::vec2(c.x + half.x, c.y - half.y), c + half, glm::vec2(c.x - half.x, c.y + half.y)};
}
o->layers.push_back(std::move(l));
ledit_ = {};
ledit_.object = o->id, ledit_.index = int(o->layers.size()) - 1, ledit_.fresh = true;
paintTool_ = PaintTool::Picture;
if (tab_ != Tab::Paint) setTab(Tab::Paint);
say(pasteOnSurface_ ? "Drag it round the model (it bends over edges); corners size it (Shift: stretch), the round handle turns it. Enter locks it."
: "Drag its corners in the Picture panel to fit (Shift: keep its shape), inside to move it. Enter locks it.");
}
auto App::floatFromFile(const std::string& path) -> void {
Image img;
if (!loadImageFile(path, img)) return say("Couldn't load " + path);
startFloating(std::move(img));
}
auto App::pasteFromClipboard() -> void {
for (const char* mime : {"image/png", "image/bmp", "image/jpeg"}) {
if (!SDL_HasClipboardData(mime)) continue;
size_t n = 0;
void* data = SDL_GetClipboardData(mime, &n);
if (!data) continue;
int w = 0, h = 0, c = 0;
stbi_uc* px = stbi_load_from_memory(static_cast<const stbi_uc*>(data), int(n), &w, &h, &c, 4);
SDL_free(data);
if (!px) continue;
Image img;
img.w = w, img.h = h, img.px.assign(px, px + size_t(w) * size_t(h) * 4);
stbi_image_free(px);
return startFloating(std::move(img));
}
say("Nothing to paste: copy a picture in another app first (or use Add a picture from a file)");
}
// Enter: it stays where it is, a layer (click it in the Picture panel to move it again).
auto App::lockLayer() -> void {
if (!editLayer()) return void(ledit_ = {});
ledit_ = {};
say("Locked in place, as a layer (see Properties). Click it in the Picture panel to move it again.");
}
auto App::unlockLayer(uint32_t object, int index) -> void {
lockLayer();
ledit_.object = object, ledit_.index = index;
paintTool_ = PaintTool::Picture;
if (const PictureLayer* l = editLayer()) say(std::format("{} unlocked: {}. Enter locks it again.", l->name, l->sticker ? "drag it on the model" : "drag its corners in the Picture panel"));
}
// Esc: a picture just pasted goes again; one unlocked is locked again.
auto App::cancelFloating() -> void {
Object* o = find(ledit_.object);
if (o && editLayer() && ledit_.fresh) {
o->layers.erase(o->layers.begin() + ledit_.index);
ledit_ = {};
return say("Taken away");
}
lockLayer();
}
auto App::deleteLayer(Object& o, int i) -> void {
if (i < 0 || i >= int(o.layers.size())) return;
pushUndo();
o.layers.erase(o.layers.begin() + i);
ledit_ = {};
}
// The layer painted into the painting for good (it can't be moved after).
auto App::mergeLayer(Object& o, int i) -> void {
if (i < 0 || i >= int(o.layers.size()) || !o.picture) return;
pushUndo();
Object only = o;
only.layers = {o.layers[size_t(i)]};
o.picture = std::make_shared<Image>(composePicture(only));
o.layers.erase(o.layers.begin() + i);
ledit_ = {};
}
// The handles of a sticker being moved, in the world: its corners (top-left, top-right,
// bottom-right, bottom-left), its turning handle, its middle — on its face's plane.
auto App::stickerHandles(const Object& o, const PictureLayer& l, std::array<glm::vec3, 6>& out) -> bool {
glm::vec3 right, up, n;
if (!stickerFrame(o.mesh, l, right, up, n)) return false;
const int f = stickerFace(o.mesh, l);
const glm::vec3 c = l.centre - n * glm::dot(l.centre - faceCenter(o.mesh, f), n);
const float hw = l.width * 0.5f, hh = l.height * 0.5f;
const glm::mat4 m = objectMatrix(o);
auto w = [&](const glm::vec3& p) { return glm::vec3(m * glm::vec4(p, 1.0f)); };
out = {w(c - right * hw + up * hh), w(c + right * hw + up * hh), w(c + right * hw - up * hh), w(c - right * hw - up * hh), w(c + up * (hh + std::max(hw, hh) * 0.4f)), w(c)};
return true;
}
auto App::stickerHandle(const Object& o, const PictureLayer& l, ImVec2 mouse) -> int {
std::array<glm::vec3, 6> h;
if (!stickerHandles(o, l, h)) return -1;
ImVec2 s[6];
for (int i = 0; i < 6; ++i)
if (!project(h[size_t(i)], s[i])) return -1;
for (int i = 0; i < 4; ++i)
if (std::abs(mouse.x - s[i].x) < 10 && std::abs(mouse.y - s[i].y) < 10) return i;
if (std::hypot(mouse.x - s[4].x, mouse.y - s[4].y) < 11) return 5;
return insidePolygon(mouse, {s[0], s[1], s[2], s[3]}) ? 4 : -1;
}
// Moving a sticker on the model: it slides over the surface (onto other faces too), its
// corners size it, its handle turns it.
auto App::stickerMouse(const ImGuiIO& io) -> void {
Object* o = find(ledit_.object);
PictureLayer* l = editLayer();
if (!o || !l) return;
const glm::mat4 inv = glm::inverse(objectMatrix(*o));
const Ray ray = mouseRay(io.MousePos);
if (ledit_.drag >= 0) {
if (!ImGui::IsMouseDown(ImGuiMouseButton_Left)) return void(ledit_.drag = -1);
const PictureLayer& s = ledit_.start;
glm::vec3 right, up, n;
stickerFrame(o->mesh, s, right, up, n);
if (ledit_.drag == 4) {
const Hit h = pickObject(ray, o->id, true);
if (!h.object) return;
const glm::vec3 p(inv * glm::vec4(ray.origin + ray.dir * h.t, 1.0f));
l->face = h.face, l->up = s.up;
glm::vec3 r2, u2, n2;
stickerFrame(o->mesh, *l, r2, u2, n2);
l->up = u2; // (its top carried over onto the new face)
l->centre = p - r2 * ledit_.offset.x - u2 * ledit_.offset.y;
settleSticker(o->mesh, *l);
return;
}
const Ray local{glm::vec3(inv * glm::vec4(ray.origin, 1.0f)), glm::normalize(glm::vec3(inv * glm::vec4(ray.dir, 0.0f)))};
const auto hp = hitPlane(local, s.centre, n);
if (!hp) return;
const glm::vec3 d = *hp - s.centre;
const float dx = glm::dot(d, right), dy = glm::dot(d, up);
if (ledit_.drag < 4) {
if (io.KeyShift) l->width = std::max(2 * std::abs(dx), 0.002f), l->height = std::max(2 * std::abs(dy), 0.002f);
else {
const float k = std::max(std::abs(dx) / (s.width * 0.5f), std::abs(dy) / (s.height * 0.5f));
l->width = std::max(s.width * k, 0.002f), l->height = std::max(s.height * k, 0.002f);
}
message_ = std::format("{:.0f} x {:.0f} mm", l->width * 1000, l->height * 1000), messageAt_ = now();
} else {
float a = std::atan2(dx, dy);
if (snap_ != io.KeyCtrl) a = glm::radians(snapTo(glm::degrees(a), 15.0f));
l->up = up * std::cos(a) + right * std::sin(a);
message_ = std::format("Turned {:.0f} degrees", glm::degrees(a)), messageAt_ = now();
}
return;
}
if (!mouseInView(io) || !ImGui::IsMouseClicked(ImGuiMouseButton_Left)) return;
const int h = stickerHandle(*o, *l, io.MousePos);
if (h < 0 && !pickObject(ray, o->id, true).object) return;
pushUndo();
ledit_.start = *l, ledit_.drag = h < 0 ? 4 : h, ledit_.offset = {0, 0};
if (h == 4) { // (held where it was grabbed)
glm::vec3 right, up, n;
stickerFrame(o->mesh, *l, right, up, n);
const Ray local{glm::vec3(inv * glm::vec4(ray.origin, 1.0f)), glm::normalize(glm::vec3(inv * glm::vec4(ray.dir, 0.0f)))};
if (auto hp = hitPlane(local, l->centre, n)) ledit_.offset = {glm::dot(*hp - l->centre, right), glm::dot(*hp - l->centre, up)};
}
}
// A cheap fingerprint of what the object's picture shows (its painting, its layers, its shape).
auto App::pictureStamp(const Object& o) -> uint64_t {
uint64_t h = 1469598103934665603ull;
auto mix = [&](const void* p, size_t n) {
for (size_t i = 0; i < n; ++i) h = (h ^ static_cast<const uint8_t*>(p)[i]) * 1099511628211ull;
};
const void* base = o.picture.get();
mix(&base, sizeof base);
const uint64_t version = versions_[o.id];
mix(&version, sizeof version);
for (const PictureLayer& l : o.layers) {
const void* im = l.image.get();
mix(&im, sizeof im), mix(&l.sticker, 1), mix(&l.visible, 1), mix(&l.opacity, 4), mix(l.corners.data(), 32);
mix(&l.face, 4), mix(&l.centre, 12), mix(&l.up, 12), mix(&l.width, 4), mix(&l.height, 4);
}
return h;
}
// The object's picture on the graphics card (its painting with its layers on top): sent again
// when it changes — while painting or dragging, at most 20 times a second.
auto App::uploadPicture(const Object& o, bool now_) -> void {
if (!o.picture) return;
Uploaded& u = uploaded_[o.id];
const bool checker = tab_ == Tab::Layout && layChecker_;
const uint64_t stamp = pictureStamp(o) ^ (checker ? 0x9E3779B97F4A7C15ull : 0);
const bool first = !paintTex_.count(o.id);
if (!first && u.stamp == stamp && !u.dirty) return;
if (!first && !now_ && now() - u.at < 0.05) return;
if (checker) { // (squares that show where the layout stretches or squashes the picture)
static Image board;
if (board.empty()) {
board = makeImage(512, 512, {1, 1, 1, 1});
for (int y = 0; y < 512; ++y)
for (int x = 0; x < 512; ++x) {
const bool dark = ((x / 32) + (y / 32)) % 2;
const glm::vec3 c = glm::mix(glm::vec3(0.95f, 0.55f, 0.25f), glm::vec3(0.3f, 0.55f, 0.9f), float(y) / 511.0f) * (dark ? 0.55f : 1.0f);
uint8_t* p = board.at(x, y);
p[0] = uint8_t(c.r * 255), p[1] = uint8_t(c.g * 255), p[2] = uint8_t(c.b * 255);
}
}
paintTex_[o.id] = r_->createTexture(std::format("fry:paint:{}", o.id), 512, 512, board.px.data());
} else if (o.layers.empty()) {
owner_.erase(o.id);
paintTex_[o.id] = r_->createTexture(std::format("fry:paint:{}", o.id), uint32_t(o.picture->w), uint32_t(o.picture->h), o.picture->px.data());
} else {
const Image shown = composePicture(o, &owner_[o.id]);
paintTex_[o.id] = r_->createTexture(std::format("fry:paint:{}", o.id), uint32_t(shown.w), uint32_t(shown.h), shown.px.data());
}
u = {stamp, now(), false};
}
// ───────────────────────────── the Layout tab ─────────────────────────────
// The frames an object's faces are laid out in on its picture, moved, turned and sized by
// hand (whole frames, or their corners); what's painted goes with them unless you say not.
// What's picked, as (face, corner) pairs of its layout.
auto App::layoutPicked(const Object& o, const std::vector<std::vector<int>>& islands) const -> std::vector<std::pair<int, int>> {
std::vector<std::pair<int, int>> out;
if (lay_.object != o.id) return out;
if (lay_.corners) return {lay_.points.begin(), lay_.points.end()};
for (int i : lay_.frames)
if (i < int(islands.size()))
for (int f : islands[size_t(i)])
for (size_t c = 0; c < o.mesh.faces[size_t(f)].size(); ++c) out.push_back({f, int(c)});
return out;
}
// A corner and every other on the same point of the model at the same place on the picture.
auto App::layoutCorner(const Object& o, int f, int c) const -> std::set<std::pair<int, int>> {
std::set<std::pair<int, int>> out;
const int v = o.mesh.faces[size_t(f)][size_t(c)];
const glm::vec2 at = o.uvs[size_t(f)][size_t(c)];
for (size_t g = 0; g < o.mesh.faces.size(); ++g)
for (size_t k = 0; k < o.mesh.faces[g].size(); ++k)
if (o.mesh.faces[g][k] == v && glm::distance(o.uvs[g][k], at) < 1e-5f) out.insert({int(g), int(k)});
return out;
}
auto App::layoutSelectAll() -> void {
Object* o = selected_.empty() ? nullptr : find(active_);
if (!o || !o->uvsFit()) return;
if (lay_.object != o->id) lay_ = {}, lay_.object = o->id;
lay_.frames.clear(), lay_.points.clear();
if (lay_.corners)
for (size_t f = 0; f < o->uvs.size(); ++f)
for (size_t c = 0; c < o->uvs[f].size(); ++c) lay_.points.insert({int(f), int(c)});
else
for (size_t i = 0; i < uvIslands(o->mesh, o->uvs).size(); ++i) lay_.frames.insert(int(i));
}
// The picked corners moved by `to` (picture pixels), painting carried along if wanted.
auto App::layoutChange(Object& o, const std::vector<std::vector<glm::vec2>>& from, const std::shared_ptr<const Image>& picture, const std::vector<std::pair<int, int>>& picked, const std::function<glm::vec2(glm::vec2)>& to, bool final) -> void {
const glm::vec2 size(float(o.picture->w), float(o.picture->h));
o.uvs = from;
std::set<int> faces;
for (auto [f, c] : picked) o.uvs[size_t(f)][size_t(c)] = to(from[size_t(f)][size_t(c)] * size) / size, faces.insert(f);
touched(o.id);
if (layKeepPaint_ && picture && (final || now() - lay_.remapAt > 0.06)) {
Image img = *picture;
remapPicture(img, o.mesh, from, o.uvs, {faces.begin(), faces.end()});
o.picture = std::make_shared<Image>(std::move(img));
lay_.remapAt = now();
}
}
// Buttons' changes (turn, flip, straighten): about the middle of what's picked.
auto App::layoutApply(const std::function<glm::vec2(glm::vec2, glm::vec2)>& to) -> void {
Object* o = selected_.empty() ? nullptr : find(active_);
if (!o || !o->picture || !o->uvsFit()) return;
const auto picked = layoutPicked(*o, uvIslands(o->mesh, o->uvs));
if (picked.empty()) return say("Pick some frames first (click them in the Picture panel or on the model)");
pushUndo();
const glm::vec2 size(float(o->picture->w), float(o->picture->h));
glm::vec2 lo(1e30f), hi(-1e30f);
for (auto [f, c] : picked) lo = glm::min(lo, o->uvs[size_t(f)][size_t(c)] * size), hi = glm::max(hi, o->uvs[size_t(f)][size_t(c)] * size);
const glm::vec2 mid = (lo + hi) * 0.5f;
layoutChange(*o, o->uvs, o->picture, picked, [&](glm::vec2 p) { return to(p, mid); }, true);
}
auto App::layoutStraighten() -> void {
Object* o = selected_.empty() ? nullptr : find(active_);
if (!o || !o->picture || !o->uvsFit()) return;
const auto picked = layoutPicked(*o, uvIslands(o->mesh, o->uvs));
std::set<int> faces;
for (auto [f, c] : picked) faces.insert(f);
float best = -1, angle = 0; // (its longest edge, laid level or upright)
for (int f : faces)
for (size_t i = 0; i < o->uvs[size_t(f)].size(); ++i) {
const glm::vec2 d = (o->uvs[size_t(f)][(i + 1) % o->uvs[size_t(f)].size()] - o->uvs[size_t(f)][i]) * glm::vec2(o->picture->w, o->picture->h);
if (const float l = glm::length(d); l > best) best = l, angle = std::atan2(d.y, d.x);
}
const float quarter = glm::half_pi<float>(), turn = std::round(angle / quarter) * quarter - angle;
layoutApply([&](glm::vec2 p, glm::vec2 m) { return m + glm::vec2(std::cos(turn) * (p - m).x - std::sin(turn) * (p - m).y, std::sin(turn) * (p - m).x + std::cos(turn) * (p - m).y); });
}
// Laid out again by itself, what's painted carried over onto the new layout.
auto App::layoutAgain(Object& o) -> void {
pushUndo();
const auto before = o.uvs;
o.uvs = autoUnwrap(o.mesh);
if (layKeepPaint_ && o.picture && before.size() == o.uvs.size()) {
Image img = *o.picture;
std::vector<int> all(o.mesh.faces.size());
for (size_t f = 0; f < all.size(); ++f) all[f] = int(f);
Image fresh = makeImage(img.w, img.h, glm::vec4(glm::vec3(o.color), 1.0f));
fresh.px = img.px;
remapPicture(fresh, o.mesh, before, o.uvs, all);
o.picture = std::make_shared<Image>(std::move(fresh));
}
lay_.frames.clear(), lay_.points.clear();
touched(o.id);
say("Laid out again" + std::string(layKeepPaint_ ? ", what's painted carried over" : ""));
}
// In the 3D view: clicking the model picks the frame its face is in (Shift: add).
auto App::layoutViewport(const ImGuiIO& io) -> void {
if (!mouseInView(io) || !ImGui::IsMouseClicked(ImGuiMouseButton_Left)) return;
const Hit h = pickObject(mouseRay(io.MousePos), 0, true);
if (!h.object) {
if (!io.KeyShift) lay_.frames.clear(), lay_.points.clear();
return;
}
if (h.object != active_ || selected_.empty()) selected_ = {h.object}, active_ = h.object, lay_ = {}, lay_.object = h.object;
Object* o = find(h.object);
if (!o || !o->uvsFit()) return;
const auto islands = uvIslands(o->mesh, o->uvs);
for (size_t i = 0; i < islands.size(); ++i)
if (std::find(islands[i].begin(), islands[i].end(), h.face) != islands[i].end()) {
if (lay_.corners) lay_.corners = false, lay_.points.clear();
if (!io.KeyShift) lay_.frames.clear();
if (io.KeyShift && lay_.frames.count(int(i))) lay_.frames.erase(int(i));
else lay_.frames.insert(int(i));
}
}
auto App::layoutOverlay(ImDrawList* dl) -> void {
Object* o = selected_.empty() ? nullptr : find(active_);
if (!o || !o->uvsFit() || lay_.object != o->id) return;
const auto picked = layoutPicked(*o, uvIslands(o->mesh, o->uvs));
std::set<int> faces;
for (auto [f, c] : picked) faces.insert(f);
const glm::mat4 m = objectMatrix(*o);
for (int f : faces) {
std::vector<ImVec2> pts;
bool ok = true;
for (int v : o->mesh.faces[size_t(f)]) {
ImVec2 q;
ok &= project(glm::vec3(m * glm::vec4(o->mesh.verts[size_t(v)], 1.0f)), q);
pts.push_back(q);
}
if (ok) dl->AddPolyline(pts.data(), int(pts.size()), kAccent, ImDrawFlags_Closed, 2.0f);
}
}
auto App::layoutToolbox(float w) -> void {
grid(int(std::size(kLayTools)), 38.0f, [&](int i) {
const LayToolInfo& t = kLayTools[i];
const std::string id = std::format("##{}", t.name), tip = std::format("{} ({})\n{}", t.name, t.key, t.hint);
if (iconButton(id.c_str(), t.icon, layTool_ == t.tool, tip.c_str())) layTool_ = t.tool;
});
ImGui::Spacing();
ImGui::TextDisabled("Pick");
if (wideButton("##frames", Icon::Faces, "Frames", !lay_.corners, w, "Whole frames: faces laid out together (Tab)")) lay_.corners = false, lay_.points.clear();
if (wideButton("##corners", Icon::Points, "Corners", lay_.corners, w, "Corners of the frames, to bend them (Tab)")) lay_.corners = true, lay_.frames.clear();
}
auto App::layoutOptions(float w) -> void {
for (const LayToolInfo& t : kLayTools)
if (t.tool == layTool_) ImGui::SeparatorText(t.name);
ImGui::TextDisabled("What's painted");
int keep = layKeepPaint_ ? 0 : 1;
ImGui::SetNextItemWidth(w);
if (ImGui::Combo("##keep", &keep, "Stays on the model (moves too)\0Stays on the picture\0")) layKeepPaint_ = keep == 0;
if (ImGui::IsItemHovered())
ImGui::SetTooltip("Stays on the model: the model looks the same, frames just get more or less of the picture.\n"
"Stays on the picture: the model shows whatever the frame is moved over (to fit a frame onto something drawn).");
ImGui::Checkbox("Show squares on the model", &layChecker_);
if (ImGui::IsItemHovered()) ImGui::SetTooltip("Squares instead of the painting: where they're stretched or squashed, so is the picture");
ImGui::SeparatorText("What's picked");
const float half = (w - ImGui::GetStyle().ItemSpacing.x) * 0.5f;
auto turn = [&](float deg) {
const float a = glm::radians(deg);
layoutApply([a](glm::vec2 p, glm::vec2 m) { return m + glm::vec2(std::cos(a) * (p - m).x - std::sin(a) * (p - m).y, std::sin(a) * (p - m).x + std::cos(a) * (p - m).y); });
};
if (ImGui::Button("Turn left", ImVec2(half, 0))) turn(-90);
ImGui::SameLine();
if (ImGui::Button("Turn right", ImVec2(half, 0))) turn(90);
if (ImGui::Button("Flip across", ImVec2(half, 0))) layoutApply([](glm::vec2 p, glm::vec2 m) { return glm::vec2(2 * m.x - p.x, p.y); });
ImGui::SameLine();
if (ImGui::Button("Flip up-down", ImVec2(half, 0))) layoutApply([](glm::vec2 p, glm::vec2 m) { return glm::vec2(p.x, 2 * m.y - p.y); });
if (ImGui::Button("Bigger", ImVec2(half, 0))) layoutApply([](glm::vec2 p, glm::vec2 m) { return m + (p - m) * 1.25f; });
ImGui::SameLine();
if (ImGui::Button("Smaller", ImVec2(half, 0))) layoutApply([](glm::vec2 p, glm::vec2 m) { return m + (p - m) * 0.8f; });
if (ImGui::Button("Straighten", ImVec2(w, 0))) layoutStraighten();
if (ImGui::IsItemHovered()) ImGui::SetTooltip("Turns it so its longest edge is level or upright");
}
auto App::layoutProperties(float w) -> void {
Object* o = selected_.empty() ? nullptr : find(active_);
if (!o) return void(ImGui::TextWrapped("Click an object: its picture's layout shows in the Picture panel."));
ImGui::TextColored(accentText(), "%s", o->name.c_str());
if (!o->picture || !o->uvsFit()) {
if (ImGui::Button("Lay it out", ImVec2(w, 0))) readyToPaint(o->id);
return;
}
const auto islands = uvIslands(o->mesh, o->uvs);
ImGui::TextDisabled("%zu frames, %zu faces, picture %d x %d", islands.size(), o->mesh.faces.size(), o->picture->w, o->picture->h);
if (ImGui::Button("Pick all (Ctrl+A)", ImVec2(w, 0))) layoutSelectAll();
if (ImGui::Button("Lay it all out again", ImVec2(w, 0))) layoutAgain(*o);
if (ImGui::IsItemHovered()) ImGui::SetTooltip("Fry3DX lays it out by itself again (what's painted carried over, if it stays on the model)");
ImGui::Spacing();
ImGui::TextWrapped("Each frame is a part of the model laid flat on its picture. Make a frame bigger to give that part more detail, "
"turn it to run a pattern the way you want, or move corners to bend it.");
}
} // namespace fryapp

File diff suppressed because it is too large Load diff

View file

@ -1,489 +0,0 @@
// Fry3DX: the Print tab: the plate, checks (overhangs, thin walls), the slice preview, sizes.
#include "app.h"
using namespace fry;
namespace stdfs = std::filesystem;
namespace fryapp {
// ───────────────────────────── the Print tab ─────────────────────────────
auto App::bedSize() const -> glm::vec3 { // (metres)
const int i = std::clamp(fry_printer.i(), 0, int(std::size(kPrinters)) - 1);
const PrinterInfo& p = kPrinters[i];
return p.x > 0 ? glm::vec3(p.x, p.y, p.z) * 0.001f : glm::vec3(fry_bed_x.f(), fry_bed_y.f(), fry_bed_z.f()) * 0.001f;
}
auto App::printInfo(const Object& o) -> const PrintInfo& {
PrintInfo& p = printInfos_[o.id];
uint64_t key = versions_[o.id] * 1000003ull + uint64_t(overhangDeg_ * 10) + uint64_t(minWall_ * 1e5f) * 7919ull;
for (float f : {o.position.x, o.position.y, o.position.z, o.rotation.x, o.rotation.y, o.rotation.z, o.rotation.w, o.scale.x, o.scale.y, o.scale.z}) {
uint32_t b;
std::memcpy(&b, &f, 4);
key = (key ^ b) * 1099511628211ull;
}
if (p.key == key) return p;
p.key = key;
p.world = cutKeys_.count(o.id) ? fry::cutMesh(scene_, o) : o.hasFinish() ? finishedMesh(o) : o.mesh;
transform(p.world, objectMatrix(o));
p.lo = boundsMin(p.world), p.hi = boundsMax(p.world);
p.check = checkForPrint(p.world, overhangDeg_, 0.0f);
// Overhangs: those faces again a hair above the surface, to show red.
keel::KmdlModel km;
keel::KmdlMaterial mat{};
mat.baseColor[0] = mat.baseColor[1] = mat.baseColor[2] = mat.baseColor[3] = 1.0f;
km.materials.push_back(mat);
for (size_t f = 0; f < p.world.faces.size(); ++f) {
if (!overhangs(p.world, int(f), overhangDeg_, 0.0f)) continue;
const glm::vec3 n = faceNormal(p.world, int(f));
for (const auto& t : triangulate(p.world, int(f)))
for (int k : t) {
const glm::vec3 q = p.world.verts[size_t(k)] + n * 0.0004f;
km.indices.push_back(uint32_t(km.vertices.size()));
km.vertices.push_back({{q.x, q.y, q.z}, {n.x, n.y, n.z}, {0, 0}, {0, 0}});
}
}
p.hasOverhang = !km.indices.empty();
if (p.hasOverhang) {
km.submeshes.push_back({0, uint32_t(km.indices.size()), 0});
p.overhang = r_->updateModel(std::format("fry:overhang:{}", o.id), km);
}
// Thin walls (only of a closed shape: an open one has no inside to be thick).
uint64_t thickKey = shapeKey(o) * 1000003ull;
for (float f : {o.scale.x, o.scale.y, o.scale.z}) {
uint32_t b;
std::memcpy(&b, &f, 4);
thickKey = (thickKey ^ b) * 1099511628211ull;
}
if (p.thickKey != thickKey || p.thick.size() != p.world.faces.size())
p.thickKey = thickKey, p.thick = p.check.closed() ? fry::wallThickness(p.world) : std::vector<float>(p.world.faces.size(), 1e30f);
p.thinFaces = 0, p.thinnest = 1e30f;
km.vertices.clear(), km.indices.clear(), km.submeshes.clear();
for (size_t f = 0; f < p.world.faces.size(); ++f) {
p.thinnest = std::min(p.thinnest, p.thick[f]);
if (p.thick[f] >= minWall_) continue;
++p.thinFaces;
const glm::vec3 n = faceNormal(p.world, int(f));
for (const auto& t : triangulate(p.world, int(f)))
for (int k : t) {
const glm::vec3 q = p.world.verts[size_t(k)] + n * 0.0005f;
km.indices.push_back(uint32_t(km.vertices.size()));
km.vertices.push_back({{q.x, q.y, q.z}, {n.x, n.y, n.z}, {0, 0}, {0, 0}});
}
}
if (p.thinFaces) {
km.submeshes.push_back({0, uint32_t(km.indices.size()), 0});
p.thin = r_->updateModel(std::format("fry:thin:{}", o.id), km);
}
return p;
}
auto App::printTargets() -> std::vector<Object*> { // (what's picked, or everything shown)
std::vector<Object*> out;
for (Object& o : scene_.objects)
if (o.visible && !isCutter(o) && (selected_.empty() || selected_.count(o.id))) out.push_back(&o);
return out;
}
auto App::drawPrintBed(keel::Renderer& r, const keel::ModelLighting& light) -> void {
const glm::vec3 b = bedSize();
const glm::vec3 lo(-b.x * 0.5f, -b.y * 0.5f, 0.0f), hi(b.x * 0.5f, b.y * 0.5f, b.z);
const glm::vec4 plate(0.1f, 0.1f, 0.12f, 1.0f);
if (bedFor_ != b) {
bedFor_ = b;
keel::KmdlModel km;
keel::KmdlMaterial mat{};
mat.baseColor[0] = mat.baseColor[1] = mat.baseColor[2] = mat.baseColor[3] = 1.0f;
km.materials.push_back(mat);
for (glm::vec2 c : {glm::vec2(lo.x, lo.y), glm::vec2(hi.x, lo.y), glm::vec2(hi.x, hi.y), glm::vec2(lo.x, hi.y)}) km.vertices.push_back({{c.x, c.y, -0.0006f}, {0, 0, 1}, {0, 0}, {0, 0}});
km.indices = {0, 1, 2, 0, 2, 3};
km.submeshes.push_back({0, 6, 0});
bed_ = r.updateModel("fry:bed", km);
}
r.drawModel(bed_, glm::scale(glm::mat4(1.0f), glm::vec3(kUnits)), plate, nullptr, &light, false);
// Its outline, and the space the printer reaches (fainter).
const glm::vec3 c[8] = {{lo.x, lo.y, 0}, {hi.x, lo.y, 0}, {hi.x, hi.y, 0}, {lo.x, hi.y, 0}, {lo.x, lo.y, hi.z}, {hi.x, lo.y, hi.z}, {hi.x, hi.y, hi.z}, {lo.x, hi.y, hi.z}};
const int e[12][2] = {{0, 1}, {1, 2}, {2, 3}, {3, 0}, {4, 5}, {5, 6}, {6, 7}, {7, 4}, {0, 4}, {1, 5}, {2, 6}, {3, 7}};
bool fits = true;
for (Object* o : printTargets()) {
const PrintInfo& p = printInfo(*o);
fits &= glm::all(glm::greaterThanEqual(p.lo, lo - 1e-4f)) && glm::all(glm::lessThanEqual(p.hi, hi + 1e-4f));
}
const glm::vec4 col = fits ? glm::vec4(0.9f, 0.9f, 0.95f, 0.5f) : glm::vec4(1.0f, 0.3f, 0.2f, 0.9f);
for (int i = 0; i < 12; ++i)
r.drawStreak(c[e[i][0]] * kUnits, c[e[i][1]] * kUnits, pixelSize(target_) * (i < 4 ? 2.0f : 1.0f) * kUnits, i < 4 ? col : col * glm::vec4(1, 1, 1, 0.5f),
keel::kWhiteTexture);
if (showOverhangs_) {
const keel::ModelLighting flat{{glm::vec3(1.0f), glm::vec3(1.0f), glm::vec3(1.0f), glm::vec3(1.0f), glm::vec3(1.0f), glm::vec3(1.0f)}, 0.0f};
for (const Object& o : scene_.objects)
if (o.visible && !isCutter(o))
if (const PrintInfo& p = printInfo(o); p.hasOverhang)
r.drawModel(p.overhang, glm::scale(glm::mat4(1.0f), glm::vec3(kUnits)), glm::vec4(0.95f, 0.15f, 0.1f, 0.55f), nullptr, &flat, false);
}
if (showThin_) {
const keel::ModelLighting flat{{glm::vec3(1.0f), glm::vec3(1.0f), glm::vec3(1.0f), glm::vec3(1.0f), glm::vec3(1.0f), glm::vec3(1.0f)}, 0.0f};
for (const Object& o : scene_.objects)
if (o.visible && !isCutter(o))
if (const PrintInfo& p = printInfo(o); p.thinFaces)
r.drawModel(p.thin, glm::scale(glm::mat4(1.0f), glm::vec3(kUnits)), glm::vec4(1.0f, 0.8f, 0.1f, 0.7f), nullptr, &flat, false);
}
}
auto App::putOnBed() -> void {
const auto targets = printTargets();
if (targets.empty()) return;
pushUndo();
for (Object* o : targets) o->position.z -= printInfo(*o).lo.z, touched(o->id);
say("Put on the bed");
}
auto App::middleOfBed() -> void {
const auto targets = printTargets();
if (targets.empty()) return;
glm::vec3 lo(1e30f), hi(-1e30f);
for (Object* o : targets) lo = glm::min(lo, printInfo(*o).lo), hi = glm::max(hi, printInfo(*o).hi);
pushUndo();
const glm::vec2 d = -(glm::vec2(lo) + glm::vec2(hi)) * 0.5f;
for (Object* o : targets) o->position += glm::vec3(d, 0.0f), touched(o->id);
}
// Everything picked made `mm` big along `axis`, keeping its shape, standing where it stood.
auto App::printResize(int axis, float mm) -> void {
const auto targets = printTargets();
glm::vec3 lo(1e30f), hi(-1e30f);
for (Object* o : targets) lo = glm::min(lo, printInfo(*o).lo), hi = glm::max(hi, printInfo(*o).hi);
const float now_ = (hi - lo)[axis] * 1000.0f;
if (targets.empty() || now_ < 1e-3f || mm <= 0.01f) return;
const float k = mm / now_;
const glm::vec3 pivot((lo.x + hi.x) * 0.5f, (lo.y + hi.y) * 0.5f, lo.z);
for (Object* o : targets) o->position = pivot + (o->position - pivot) * k, o->scale *= k, touched(o->id);
}
// Everything picked made `k` times as big, standing where it stood.
auto App::printScale(float k) -> void {
const auto targets = printTargets();
if (targets.empty() || !(k > 0) || std::abs(k - 1) < 1e-6f) return;
glm::vec3 lo(1e30f), hi(-1e30f);
for (Object* o : targets) lo = glm::min(lo, printInfo(*o).lo), hi = glm::max(hi, printInfo(*o).hi);
const glm::vec3 pivot((lo.x + hi.x) * 0.5f, (lo.y + hi.y) * 0.5f, lo.z);
for (Object* o : targets) o->position = pivot + (o->position - pivot) * k, o->scale *= k, touched(o->id);
}
auto App::printScaler(float w) -> void {
const auto targets = printTargets();
if (targets.empty()) return;
glm::vec3 lo(1e30f), hi(-1e30f);
for (Object* o : targets) lo = glm::min(lo, printInfo(*o).lo), hi = glm::max(hi, printInfo(*o).hi);
const glm::vec3 size = (hi - lo) * 1000.0f, bed = bedSize() * 1000.0f;
auto scale = [&](float k, const char* what) {
pushUndo();
printScale(k);
say(std::format("{}: now {:.1f} x {:.1f} x {:.1f} mm", what, size.x * k, size.y * k, size.z * k));
};
const float bw = (w - ImGui::GetStyle().ItemSpacing.x * 3) / 4;
ImGui::SetNextItemWidth(w - bw - ImGui::GetStyle().ItemSpacing.x);
ImGui::InputFloat("##pct", &scalePercent_, 0, 0, "%.1f %%");
scalePercent_ = std::clamp(scalePercent_, 0.1f, 100000.0f);
ImGui::SameLine();
if (ImGui::Button("Scale", ImVec2(bw, 0))) scale(scalePercent_ / 100, std::format("Scaled to {:g}%", scalePercent_).c_str()), scalePercent_ = 100;
if (ImGui::IsItemHovered()) ImGui::SetTooltip("Make it this share of the size it is now (50%%: half, 200%%: twice)");
const struct {
const char* label;
float k;
const char* tip;
} quick[] = {{"x 0.5", 0.5f, "Half the size"}, {"x 2", 2.0f, "Twice the size"}, {"in > mm", 25.4f, "It was made in inches: 1 became 1 mm, should be 25.4"},
{"cm > mm", 10.0f, "It was made in centimetres: 1 became 1 mm, should be 10"}};
for (int i = 0; i < 4; ++i) {
if (i) ImGui::SameLine();
if (ImGui::Button(quick[i].label, ImVec2(bw, 0))) scale(quick[i].k, quick[i].tip);
if (ImGui::IsItemHovered()) ImGui::SetTooltip("%s", quick[i].tip);
}
const float fit = std::min({bed.x / std::max(size.x, 1e-3f), bed.y / std::max(size.y, 1e-3f), bed.z / std::max(size.z, 1e-3f)}) * 0.95f;
if (ImGui::Button(std::format("Fit the plate ({:.0f}%)", fit * 100).c_str(), ImVec2(w * 0.5f - ImGui::GetStyle().ItemSpacing.x * 0.5f, 0))) {
scale(fit, "Made as big as fits the plate");
middleOfBed();
}
if (ImGui::IsItemHovered()) ImGui::SetTooltip("As big as fits on this printer (a little room left round it), in the middle");
ImGui::SameLine();
if (ImGui::Button("m > mm", ImVec2(w * 0.5f - ImGui::GetStyle().ItemSpacing.x * 0.5f, 0))) scale(1000.0f, "It was made in metres: 1 became 1 mm");
if (ImGui::IsItemHovered()) ImGui::SetTooltip("It came in a thousand times too small (made in metres, read as millimetres)");
}
auto App::printSizeWindow() -> void {
if (!showPrintSize_) return;
ImGui::SetNextWindowPos(ImGui::GetMainViewport()->GetCenter(), ImGuiCond_Appearing, ImVec2(0.5f, 0.5f));
if (!ImGui::Begin("Print size", &showPrintSize_, ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoDocking)) return ImGui::End();
const float w = 320;
const auto targets = printTargets();
if (targets.empty()) ImGui::TextWrapped("Nothing to size: add something in the Model tab.");
else {
glm::vec3 lo(1e30f), hi(-1e30f);
for (Object* o : targets) lo = glm::min(lo, printInfo(*o).lo), hi = glm::max(hi, printInfo(*o).hi);
const glm::vec3 size = (hi - lo) * 1000.0f;
ImGui::TextDisabled("%s", selected_.empty() ? "Everything" : targets.size() == 1 ? targets[0]->name.c_str() : "What's picked");
const char* axes[] = {"across", "deep", "high"};
for (int a = 0; a < 3; ++a) {
float v = size[a];
ImGui::SetNextItemWidth(w * 0.7f);
ImGui::PushID(a);
if (ImGui::InputFloat("##size", &v, 0, 0, "%.2f mm", ImGuiInputTextFlags_EnterReturnsTrue)) pushUndo(), printResize(a, v);
ImGui::PopID();
ImGui::SameLine();
ImGui::TextDisabled("%s", axes[a]);
}
ImGui::Spacing();
printScaler(w);
}
ImGui::End();
}
auto App::printToolbox(float w) -> void {
ImGui::Spacing();
if (wideButton("##measure", Icon::Ruler, "Measure", printTool_ == PrintTool::Measure, w, "Click two points to see how far apart they are (M)"))
printTool_ = printTool_ == PrintTool::Measure ? PrintTool::None : PrintTool::Measure, measure_.clear();
if (wideButton("##layflat", Icon::LayFlat, "Lay flat", printTool_ == PrintTool::LayFlat, w, "Click the face it should stand on (L)"))
printTool_ = printTool_ == PrintTool::LayFlat ? PrintTool::None : PrintTool::LayFlat;
if (wideButton("##bed", Icon::Move, "Put on the bed", false, w, "Down (or up) so it stands on the printer's plate (B)")) putOnBed();
if (wideButton("##mid", Icon::Box, "Middle of the bed", false, w, "To the middle of the printer's plate")) middleOfBed();
ImGui::Spacing();
if (wideButton("##3mf", Icon::Print, "Save for the slicer", false, w, "3MF: opens in PrusaSlicer, Bambu Studio, OrcaSlicer, Cura")) dialog(8);
if (wideButton("##stl", Icon::Print, "Save as STL", false, w, "STL, in millimetres")) dialog(9);
}
auto App::printOptions(float w) -> void {
if (printTool_ == PrintTool::Measure) {
ImGui::SeparatorText("Measure");
ImGui::Checkbox("Catch corners", &measureSnap_);
if (measure_.size() == 2) {
const glm::vec3 d = (measure_[1] - measure_[0]) * 1000.0f;
ImGui::Text("%.2f mm", glm::length(d));
ImGui::TextDisabled("across %.2f deep %.2f up %.2f", std::abs(d.x), std::abs(d.y), std::abs(d.z));
} else
ImGui::TextWrapped("Click a point on the model, then another.");
if (ImGui::Button("Clear", ImVec2(w, 0))) measure_.clear();
} else {
ImGui::SeparatorText("Lay flat");
ImGui::TextWrapped("Click the face it should stand on: it turns so that face is down, and sits on the bed. A big flat face down prints best, with least support.");
}
}
auto App::printViewport(const ImGuiIO& io) -> void {
if (!mouseInView(io) || !ImGui::IsMouseClicked(ImGuiMouseButton_Left)) return;
if (printTool_ == PrintTool::Measure) {
if (const auto p = measurePoint(io.MousePos)) {
if (measure_.size() >= 2) measure_.clear();
measure_.push_back(*p);
if (measure_.size() == 2) say(std::format("{:.2f} mm", glm::distance(measure_[0], measure_[1]) * 1000.0f));
}
return;
}
const Hit h = pickObject(mouseRay(io.MousePos));
Object* o = h.object ? find(h.object) : nullptr;
if (!o) return;
const Mesh& m = printInfo(*o).world;
if (h.face < 0 || h.face >= int(m.faces.size())) return;
pushUndo();
const glm::vec3 n = faceNormal(m, h.face);
o->rotation = glm::normalize(glm::rotation(n, glm::vec3(0, 0, -1)) * o->rotation);
touched(o->id);
o->position.z -= printInfo(*o).lo.z;
touched(o->id);
selected_ = {o->id}, active_ = o->id;
say("Laid flat on that face");
}
// Where the mouse is on the model (a corner if near one), or on the bed.
auto App::measurePoint(ImVec2 mouse) -> std::optional<glm::vec3> {
const Ray ray = mouseRay(mouse);
const Hit h = pickObject(ray);
if (h.object) {
glm::vec3 p = ray.origin + ray.dir * h.t;
if (measureSnap_)
if (const Object* o = find(h.object)) {
float best = 12.0f;
for (const glm::vec3& v : printInfo(*o).world.verts) {
ImVec2 q;
if (project(v, q))
if (const float d = std::hypot(q.x - mouse.x, q.y - mouse.y); d < best) best = d, p = v;
}
}
return p;
}
if (std::abs(ray.dir.z) > 1e-5f)
if (const float t = -ray.origin.z / ray.dir.z; t > 0) return ray.origin + ray.dir * t;
return std::nullopt;
}
// The slice preview: where the printer goes round at the chosen layer, over the model.
auto App::layerOverlay(ImDrawList* dl) -> void {
const float z = (float(layer_) - 0.5f) * layerHeight_;
for (Object* o : printTargets()) {
const PrintInfo& p = printInfo(*o);
if (p.sliceKey != p.key || p.sliceZ != z) p.sliceKey = p.key, p.sliceZ = z, p.slice = fry::sliceAt(p.world, z);
for (const auto& [a, b] : p.slice) {
ImVec2 sa, sb;
if (project(a, sa) && project(b, sb)) dl->AddLine(sa, sb, IM_COL32(60, 220, 255, 255), 2.5f);
}
}
}
auto App::printOverlay(ImDrawList* dl, const ImGuiIO& io) -> void {
if (printTool_ != PrintTool::Measure) return;
std::vector<glm::vec3> pts = measure_;
if (pts.size() == 1 && mouseInView(io))
if (auto p = measurePoint(io.MousePos)) pts.push_back(*p);
ImVec2 q[2];
for (size_t i = 0; i < pts.size(); ++i)
if (project(pts[i], q[i])) dl->AddCircleFilled(q[i], 5, kAccent);
if (pts.size() == 2 && project(pts[0], q[0]) && project(pts[1], q[1])) {
dl->AddLine(q[0], q[1], kAccent, 2.0f);
const std::string t = std::format("{:.2f} mm", glm::distance(pts[0], pts[1]) * 1000.0f);
const ImVec2 mid((q[0].x + q[1].x) * 0.5f, (q[0].y + q[1].y) * 0.5f), ts = ImGui::CalcTextSize(t.c_str());
dl->AddRectFilled(ImVec2(mid.x - ts.x * 0.5f - 6, mid.y - ts.y - 10), ImVec2(mid.x + ts.x * 0.5f + 6, mid.y - 4), IM_COL32(20, 20, 24, 230), 4.0f);
dl->AddText(ImVec2(mid.x - ts.x * 0.5f, mid.y - ts.y - 7), IM_COL32(255, 255, 255, 255), t.c_str());
}
}
auto App::printProperties(float w) -> void {
auto good = [](const char* t) { ImGui::TextColored(roleColor(keel::ui::Role::SuccessText), "%s", t); };
auto bad = [](const std::string& t) { ImGui::PushStyleColor(ImGuiCol_Text, roleColor(keel::ui::Role::DangerText)), ImGui::TextWrapped("%s", t.c_str()), ImGui::PopStyleColor(); };
ImGui::SeparatorText("Printer");
int printer = std::clamp(fry_printer.i(), 0, int(std::size(kPrinters)) - 1);
ImGui::SetNextItemWidth(w);
if (ImGui::BeginCombo("##printer", kPrinters[printer].name)) {
for (int i = 0; i < int(std::size(kPrinters)); ++i)
if (ImGui::Selectable(kPrinters[i].name, i == printer)) keel::execute(std::format("fry_printer {}", i));
ImGui::EndCombo();
}
const glm::vec3 bed = bedSize() * 1000.0f;
if (kPrinters[printer].x <= 0) {
float b[3] = {bed.x, bed.y, bed.z};
ImGui::SetNextItemWidth(w);
if (ImGui::DragFloat3("##bed", b, 1.0f, 50.0f, 2000.0f, "%.0f mm"))
keel::execute(std::format("fry_bed_x {}; fry_bed_y {}; fry_bed_z {}", b[0], b[1], b[2]));
} else
ImGui::TextDisabled("Plate %.0f x %.0f mm, %.0f mm high", bed.x, bed.y, bed.z);
const auto targets = printTargets();
if (targets.empty()) return void(ImGui::TextWrapped("Nothing to print: add something in the Model tab."));
glm::vec3 lo(1e30f), hi(-1e30f);
PrintCheck all;
for (Object* o : targets) {
const PrintInfo& p = printInfo(*o);
lo = glm::min(lo, p.lo), hi = glm::max(hi, p.hi);
all.openEdges += p.check.openEdges, all.crowdedEdges += p.check.crowdedEdges, all.flippedEdges += p.check.flippedEdges;
all.tinyFaces += p.check.tinyFaces, all.pieces += p.check.pieces, all.insideOut |= p.check.insideOut;
all.volume += std::abs(p.check.volume), all.area += p.check.area, all.overhangArea += p.check.overhangArea;
}
const glm::vec3 size = (hi - lo) * 1000.0f;
ImGui::SeparatorText(selected_.empty() ? "Everything" : targets.size() == 1 ? targets[0]->name.c_str() : "What's picked");
// Its size, typed in millimetres (keeps its shape).
const char* axes[] = {"across", "deep", "high"};
for (int a = 0; a < 3; ++a) {
float v = size[a];
ImGui::SetNextItemWidth(w * 0.62f);
ImGui::PushID(a);
if (ImGui::DragFloat("##size", &v, 0.5f, 0.1f, 100000.0f, "%.2f mm")) {
if (ImGui::IsItemActivated() || !sizing_) pushUndo(), sizing_ = true;
printResize(a, v);
}
if (ImGui::IsItemDeactivated()) sizing_ = false;
ImGui::PopID();
ImGui::SameLine();
ImGui::TextDisabled("%s", axes[a]);
}
if (ImGui::IsItemHovered()) ImGui::SetTooltip("Type a size: it keeps its shape");
printScaler(w);
const glm::vec3 b = bedSize();
const bool fits = size.x <= bed.x + 0.05f && size.y <= bed.y + 0.05f && size.z <= bed.z + 0.05f;
const bool onPlate = lo.x >= -b.x * 0.5f - 1e-4f && hi.x <= b.x * 0.5f + 1e-4f && lo.y >= -b.y * 0.5f - 1e-4f && hi.y <= b.y * 0.5f + 1e-4f && hi.z <= b.z + 1e-4f;
if (!fits) {
const float k = std::min({bed.x / std::max(size.x, 1e-3f), bed.y / std::max(size.y, 1e-3f), bed.z / std::max(size.z, 1e-3f)});
bad(std::format("Too big for this printer: it would fit at {:.0f}% of this size (or in parts).", std::floor(k * 1000) / 10));
} else if (!onPlate) {
bad("It fits, but it's off the plate.");
if (ImGui::Button("Middle of the bed", ImVec2(w, 0))) middleOfBed();
} else
good("Fits on the plate");
if (std::abs(lo.z) > 1e-4f) {
bad(lo.z > 0 ? std::format("It floats {:.2f} mm above the bed.", lo.z * 1000) : std::format("It goes {:.2f} mm into the bed (that part won't print).", -lo.z * 1000));
if (ImGui::Button("Put it on the bed (B)", ImVec2(w, 0))) putOnBed();
}
ImGui::SeparatorText("Ready to print?");
if (!all.closed() || all.insideOut || all.tinyFaces) {
pushAccent();
if (ImGui::Button("Repair it (all at once)", ImVec2(w, 0))) repairSelected();
popAccent();
if (ImGui::IsItemHovered()) ImGui::SetTooltip("Joins loose corners, takes away empty and doubled faces, turns faces outwards, closes holes");
}
if (all.openEdges) {
bad(std::format("Not closed: {} edges have a face on one side only (holes). A slicer may fill them wrongly or not print it.", all.openEdges));
if (ImGui::Button("Close the holes", ImVec2(w, 0))) {
pushUndo();
int n = 0;
for (Object* o : targets) n += closeHoles(o->mesh), touched(o->id);
say(std::format("Closed {} hole{}", n, n == 1 ? "" : "s"));
}
} else
good("Closed all round");
if (all.flippedEdges || all.insideOut) {
bad("Some faces face inwards: the slicer can't tell inside from out.");
if (ImGui::Button("Turn them the right way", ImVec2(w, 0))) {
pushUndo();
int n = 0;
for (Object* o : targets) n += faceOutward(o->mesh), touched(o->id);
say(std::format("Turned {} face{}", n, n == 1 ? "" : "s"));
}
} else
good("Faces all face outwards");
if (all.crowdedEdges) bad(std::format("{} edges have more than two faces on them (shapes stuck together there): separate or rebuild that part.", all.crowdedEdges));
if (all.tinyFaces) bad(std::format("{} faces have no size: Merge by distance in the Model tab cleans them up.", all.tinyFaces));
if (all.pieces > int(targets.size())) ImGui::TextDisabled("%d separate pieces", all.pieces);
const float hang = all.overhangArea * 1e4f;
if (hang > 0.01f) bad(std::format("{:.1f} cm2 hangs over the air (red): it needs supports, or turn it (Lay flat).", hang));
else good("Nothing hangs over the air");
ImGui::Checkbox("Show overhangs in red", &showOverhangs_);
ImGui::SetNextItemWidth(w);
ImGui::SliderFloat("##hang", &overhangDeg_, 20.0f, 80.0f, "steeper than %.0f degrees");
if (ImGui::IsItemHovered()) ImGui::SetTooltip("How far from upright a face can lean over before it needs support (45 for most printers)");
int thin = 0;
float thinnest = 1e30f;
for (Object* o : targets) thin += printInfo(*o).thinFaces, thinnest = std::min(thinnest, printInfo(*o).thinnest);
if (thin) bad(std::format("{} face{} on walls thinner than {:.1f} mm (yellow), the thinnest {:.2f} mm: it may not print there, or break.", thin, thin == 1 ? "" : "s", minWall_ * 1000, thinnest * 1000));
else if (all.openEdges == 0) good(std::format("Walls {:.1f} mm thick or more", minWall_ * 1000).c_str());
ImGui::Checkbox("Show thin walls in yellow", &showThin_);
float mm = minWall_ * 1000;
ImGui::SetNextItemWidth(w);
if (ImGui::SliderFloat("##minwall", &mm, 0.2f, 5.0f, "thinner than %.1f mm")) minWall_ = mm / 1000;
if (ImGui::IsItemHovered()) ImGui::SetTooltip("The thinnest wall your printer makes well: about twice the nozzle (0.8 mm for a 0.4 mm nozzle)");
ImGui::SeparatorText("Layers");
{
const int layers = std::max(1, int(std::ceil((hi.z - std::max(lo.z, 0.0f)) / layerHeight_ - 1e-4f)));
ImGui::Checkbox("Show one layer (as the printer makes it)", &showLayer_);
if (showLayer_) {
layer_ = std::clamp(layer_, 1, layers);
ImGui::SetNextItemWidth(w);
ImGui::SliderInt("##layer", &layer_, 1, layers, std::format("layer %d of {}", layers).c_str());
float mm = layerHeight_ * 1000;
ImGui::SetNextItemWidth(w);
if (ImGui::SliderFloat("##lh", &mm, 0.04f, 0.6f, "%.2f mm a layer")) layerHeight_ = std::max(mm, 0.01f) / 1000;
ImGui::TextDisabled("At %.2f mm up", (float(layer_) - 0.5f) * layerHeight_ * 1000);
} else
ImGui::TextDisabled("%d layers of %.2f mm", layers, layerHeight_ * 1000);
}
ImGui::SeparatorText("Material");
ImGui::SetNextItemWidth(w);
ImGui::Combo("##material", &material_, [](void*, int i) { return kMaterials[i].name; }, nullptr, int(std::size(kMaterials)));
ImGui::SetNextItemWidth(w);
ImGui::SliderFloat("##infill", &infill_, 0.0f, 100.0f, "infill %.0f%%");
// (walls about 1.2 mm thick all round, the inside filled as much as the infill says)
const float vol = all.volume * 1e6f, walls = std::min(vol, all.area * 1e4f * 0.12f);
const float used = walls + (vol - walls) * infill_ / 100.0f, grams = used * kMaterials[material_].density;
ImGui::Text("Holds %.1f cm3", vol);
ImGui::Text("About %.0f g, %.1f m of 1.75 mm filament", grams, used / (3.14159f * 0.0875f * 0.0875f) / 100.0f);
ImGui::TextDisabled("(a guess: the slicer knows best)");
}
} // namespace fryapp

View file

@ -1,821 +0,0 @@
// Fry3DX: scripts, your own materials, plug-ins, the windows that go with them.
#include "app.h"
#include "fry_project.h"
using namespace fry;
namespace stdfs = std::filesystem;
namespace fryapp {
// ───────────────────────────── scripts ─────────────────────────────
auto App::scriptLine(std::string text, bool error) -> void {
size_t at = 0;
for (size_t nl; (nl = text.find('\n', at)) != std::string::npos; at = nl + 1) scriptLog_.push_back({text.substr(at, nl - at), error});
scriptLog_.push_back({text.substr(at), error});
if (scriptLog_.size() > 2000) scriptLog_.erase(scriptLog_.begin(), scriptLog_.begin() + 500);
scriptLogToBottom_ = true;
}
// "little_town.lua" -> "little town" (a script's name in the list).
auto App::scriptName(const std::string& path) -> std::string {
std::string n = stdfs::path(fileName(path)).stem().string();
std::replace(n.begin(), n.end(), '_', ' ');
return n.empty() ? "Script" : n;
}
// Adds a script to the file's list and shows it (the same one again: just shows it).
auto App::addScript(std::string name, std::string code) -> void {
for (size_t i = 0; i < scene_.scripts.size(); ++i)
if (scene_.scripts[i].name == name && scene_.scripts[i].code == code) return void((script_ = i, showScript_ = true));
const std::string base = name;
for (int n = 2; std::any_of(scene_.scripts.begin(), scene_.scripts.end(), [&](const fry::SceneScript& s) { return s.name == name; }); ++n)
name = std::format("{} {}", base, n);
scene_.scripts.push_back({name, std::move(code)});
script_ = scene_.scripts.size() - 1;
showScript_ = true, dirty_ = true;
}
// Runs the Script panel's script: one undo step; if it stops with an error, everything it did
// is taken back.
auto App::runScriptNow() -> void {
const fry::SceneScript* sc = currentScript();
if (!sc) return say("No script to run: Script > New script, or open one");
if (mode_ == Mode::Edit) leaveEdit();
const std::string name = sc->name, code = sc->code; // (the list itself may change while it runs)
pushUndo();
bool frame = false;
fry::ScriptHost host{&scene_, &selected_, &active_, [this](const std::string& t) { scriptLine(t); }, [&] { frame = true; }};
const fry::ScriptResult r = fry::runScript(host, code, name);
finishRun(r, name, frame);
}
// After a script or plug-in command: kept as one undo step, or all taken back on an error.
auto App::finishRun(const fry::ScriptResult& r, const std::string& name, bool frame) -> void {
touchAll();
if (r.ok && frame) frameAll();
if (!r.ok) {
takeBack();
scriptLine(r.error, true);
showScript_ = true;
return say(name + " stopped (nothing it did was kept): " + r.error.substr(0, r.error.find('\n')));
}
dirty_ = true;
say(r.objectsMade ? std::format("{} ran: {} object{} made (Ctrl+Z takes it all back)", name, r.objectsMade, r.objectsMade == 1 ? "" : "s")
: std::format("{} ran (Ctrl+Z takes it back)", name));
}
auto App::openScript(const std::string& path, const std::string& shownName) -> void {
std::ifstream in(path, std::ios::binary);
if (!in) return say("Couldn't open " + shownName);
addScript(scriptName(shownName), std::string(std::istreambuf_iterator<char>(in), {}));
scriptPath_ = isUri(path) ? std::string() : path;
say("Opened " + shownName + ": it's kept in this file now. F5 runs it");
}
auto App::saveScript(std::string file) -> void {
const fry::SceneScript* sc = currentScript();
if (!sc) return;
if (isUri(file)) {
const std::string temp = tempFile("script.lua");
std::string error;
const bool ok = writeFileSafely(temp, sc->code.data(), sc->code.size(), &error) && toUri(temp, file);
return say(ok ? "Saved " + fileName(file) : "Couldn't write the file you picked");
}
if (stdfs::path(file).extension() != ".lua") file += ".lua";
std::string error;
if (!writeFileSafely(file, sc->code.data(), sc->code.size(), &error)) return say("Couldn't save the script: " + error);
scriptPath_ = file;
say("Saved " + fileName(file));
}
// The scripts that come with it (content/Fry3DX/scripts), as menu items that add one.
auto App::exampleScripts() -> void {
if (!ImGui::BeginMenu("Examples")) return;
exampleItems(false);
ImGui::EndMenu();
}
auto App::exampleItems(bool selectable) -> void {
for (const std::string& f : keel::fs::list("scripts", ".lua")) {
const std::string name = scriptName(f);
if (selectable ? ImGui::Selectable(name.c_str()) : ImGui::MenuItem(name.c_str()))
if (auto text = keel::fs::readText(f)) addScript(name, *text);
}
}
auto App::loadOwnMaterials() -> void {
std::ifstream in(ownMaterialsPath(), std::ios::binary);
if (in) parseOwnMaterials(std::string(std::istreambuf_iterator<char>(in), {}));
}
auto App::saveOwnMaterials() -> void {
const std::string text = ownMaterialsText();
std::string error;
if (!writeFileSafely(ownMaterialsPath(), text.data(), text.size(), &error)) say("Couldn't keep your materials: " + error);
}
auto App::pluginsOff() const -> std::set<std::string> {
std::set<std::string> off;
std::istringstream in(fry_pluginsoff.str());
for (std::string f; std::getline(in, f, '|');)
if (!f.empty()) off.insert(f);
return off;
}
auto App::setPluginOn(const std::string& file, bool on) -> void {
std::set<std::string> off = pluginsOff();
if (on) off.erase(file);
else off.insert(file);
std::string text;
for (const std::string& f : off) text += (text.empty() ? "" : "|") + f;
fry_pluginsoff.set(text);
loadPlugins();
}
auto App::loadPlugins() -> void {
for (const PluginEntry& e : plugins_) fry::clearOwnMaterials(e.file);
plugins_.clear();
std::error_code ec;
stdfs::create_directories(pluginsFolder(), ec);
std::vector<std::pair<PluginEntry, std::string>> found; // (and its code)
for (const std::string& f : keel::fs::list("plugins", ".lua"))
if (auto text = keel::fs::readText(f)) found.push_back({PluginEntry{f, fileName(f), "", true}, *text});
std::vector<stdfs::path> mine;
for (const auto& d : stdfs::directory_iterator(pluginsFolder(), ec))
if (d.path().extension() == ".lua") mine.push_back(d.path());
std::sort(mine.begin(), mine.end());
for (const stdfs::path& f : mine) {
std::ifstream in(f, std::ios::binary);
found.push_back({PluginEntry{f.string(), f.filename().string()}, std::string(std::istreambuf_iterator<char>(in), {})});
}
const std::set<std::string> off = pluginsOff();
fry::Scene scratch; // (loading a plug-in can't touch the open file: only its commands, when run, do)
std::set<uint32_t> none;
uint32_t noActive = 0;
for (auto& [e, code] : found) {
e.on = !off.count(e.file);
if (e.on) {
e.p = std::make_unique<fry::Plugin>(e.file);
fry::ScriptHost host{&scratch, &none, &noActive, [this, f = e.file](const std::string& t) { scriptLine(f + ": " + t); }, {}};
if (!e.p->load(host, code, &e.error)) {
fry::clearOwnMaterials(e.file);
scriptLine(e.error, true);
keel::log::warn("Plug-in {} didn't load: {}", e.file, e.error.substr(0, e.error.find('\n')));
}
}
plugins_.push_back(std::move(e));
}
size_t loaded = 0;
for (const PluginEntry& e : plugins_) loaded += e.p && e.error.empty();
if (loaded) keel::log::info("Plug-ins: {} loaded", loaded);
}
auto App::copyPlugin(const PluginEntry& e) -> void {
const auto text = keel::fs::readText(e.path);
if (!text) return say("Couldn't read " + e.file);
std::error_code ec;
stdfs::create_directories(pluginsFolder(), ec);
const stdfs::path to = stdfs::path(pluginsFolder()) / ("my_" + e.file);
if (!stdfs::exists(to)) std::ofstream(to, std::ios::binary) << *text;
setPluginOn(e.file, false); // (loads them again)
say("Your copy: " + to.string() + " (Open the plug-ins folder, change it, Load them again)");
}
auto App::openPluginsFolder() -> void {
std::error_code ec;
stdfs::create_directories(pluginsFolder(), ec);
SDL_OpenURL(("file://" + pluginsFolder()).c_str());
}
auto App::pluginMenuItems(const char* menu) -> void {
bool first = true;
for (size_t i = 0; i < plugins_.size(); ++i)
if (pluginUsable(i))
for (size_t c = 0; c < plugins_[i].p->commands.size(); ++c)
if (plugins_[i].p->commands[c].menu == menu) {
if (std::exchange(first, false)) ImGui::SeparatorText("Plug-ins");
pluginMenuItem(i, c);
}
}
auto App::pluginMenuItem(size_t i, size_t c) -> void {
const fry::PluginCommand& cmd = plugins_[i].p->commands[c];
if (ImGui::MenuItem((cmd.name + (cmd.settings.empty() ? "" : "...")).c_str())) {
if (cmd.settings.empty()) runPluginCommand(i, c, {});
else {
pendingCommand_ = PendingCommand{i, c, {}};
for (const auto& [n, v] : cmd.settings) pendingCommand_->values.push_back(v);
}
}
if (!cmd.help.empty() && ImGui::IsItemHovered()) ImGui::SetTooltip("%s (%s)", cmd.help.c_str(), plugins_[i].p->name.c_str());
}
auto App::runPluginCommand(size_t i, size_t c, const std::vector<double>& values) -> void {
if (!pluginUsable(i) || c >= plugins_[i].p->commands.size()) return;
if (mode_ == Mode::Edit) leaveEdit();
pushUndo();
bool frame = false;
fry::ScriptHost host{&scene_, &selected_, &active_, [this](const std::string& t) { scriptLine(t); }, [&] { frame = true; }};
const fry::ScriptResult r = plugins_[i].p->run(host, c, values);
finishRun(r, plugins_[i].p->commands[c].name, frame);
}
// A plug-in command's settings, asked for before it runs.
auto App::commandForm() -> void {
if (!pendingCommand_) return;
if (!pluginUsable(pendingCommand_->plugin)) return pendingCommand_.reset();
const fry::PluginCommand& cmd = plugins_[pendingCommand_->plugin].p->commands[pendingCommand_->command];
const std::string title = cmd.name + "###plugincommand";
if (!ImGui::IsPopupOpen("###plugincommand")) ImGui::OpenPopup(title.c_str());
ImGui::SetNextWindowPos(ImGui::GetMainViewport()->GetCenter(), ImGuiCond_Appearing, ImVec2(0.5f, 0.5f));
ImGui::SetNextWindowSize(ImVec2(340, 0));
if (!ImGui::BeginPopupModal(title.c_str(), nullptr, ImGuiWindowFlags_NoResize)) return;
if (!cmd.help.empty()) ImGui::TextWrapped("%s", cmd.help.c_str());
ImGui::TextDisabled("From %s", plugins_[pendingCommand_->plugin].p->name.c_str());
for (size_t k = 0; k < cmd.settings.size(); ++k) {
ImGui::SetNextItemWidth(ImGui::GetContentRegionAvail().x * 0.55f);
std::string label = cmd.settings[k].first;
if (!label.empty()) label[0] = char(std::toupper(static_cast<unsigned char>(label[0])));
std::replace(label.begin(), label.end(), '_', ' ');
ImGui::InputDouble(label.c_str(), &pendingCommand_->values[k], 0, 0, "%.4g");
}
ImGui::Spacing();
pushAccent();
const bool go = ImGui::Button("Make it", ImVec2(120, 0)) || ImGui::IsKeyPressed(ImGuiKey_Enter, false);
popAccent();
ImGui::SameLine();
const bool cancel = ImGui::Button("Cancel", ImVec2(120, 0));
if (go || cancel) {
ImGui::CloseCurrentPopup();
const PendingCommand p = *pendingCommand_;
pendingCommand_.reset();
if (go) runPluginCommand(p.plugin, p.command, p.values);
}
ImGui::EndPopup();
}
auto App::pluginsWindow() -> void {
using keel::ui::Role;
ImGui::SetNextWindowSize(ImVec2(520, 520), ImGuiCond_FirstUseEver);
ImGui::SetNextWindowPos(ImGui::GetMainViewport()->GetCenter(), ImGuiCond_FirstUseEver, ImVec2(0.5f, 0.5f));
if (!ImGui::Begin("Plug-ins", &showPlugins_, ImGuiWindowFlags_NoDocking)) return ImGui::End();
ImGui::PushStyleColor(ImGuiCol_Text, roleColor(Role::TextSecondary));
ImGui::TextWrapped("Plug-ins are scripts that stay loaded: they add commands to the menus and materials to the picker. "
"Put a .lua file in the plug-ins folder and load them again.");
ImGui::PopStyleColor();
if (ImGui::Button("Open the plug-ins folder")) openPluginsFolder();
ImGui::SameLine();
if (ImGui::Button("Load them again")) loadPlugins();
ImGui::SameLine();
if (ImGui::Button("How to write one")) showScript_ = showScriptHelp_ = true;
ImGui::Spacing();
if (plugins_.empty()) ImGui::TextDisabled("None yet.");
else {
ImGui::SetNextItemWidth(-1);
inputString("##filter", pluginFilter_, "Look for a plug-in...");
}
std::optional<std::pair<std::string, bool>> toggle;
for (size_t i = 0; i < plugins_.size(); ++i) {
const PluginEntry& e = plugins_[i];
if (!pluginFilter_.empty()) {
std::string all = lower(e.file + " " + (e.p ? e.p->name + " " + e.p->about : std::string()));
if (e.p)
for (const fry::PluginCommand& c : e.p->commands) all += " " + lower(c.name + " " + c.help);
if (e.p)
for (const fry::PluginTool& t : e.p->tools) all += " " + lower(t.name + " " + t.help);
if (all.find(lower(pluginFilter_)) == std::string::npos) continue;
}
ImGui::PushID(int(i));
ImGui::BeginChild("plugin", ImVec2(0, 0), ImGuiChildFlags_FrameStyle | ImGuiChildFlags_AutoResizeY);
bool on = e.on;
if (ImGui::Checkbox("##on", &on)) toggle = {e.file, on};
if (ImGui::IsItemHovered()) ImGui::SetTooltip(on ? "On: turn it off" : "Off: turn it on");
ImGui::SameLine();
ImGui::PushFont(keel::ui::font(keel::ui::Font::Bold), 0.0f);
ImGui::TextUnformatted(e.p ? e.p->name.c_str() : scriptName(e.file).c_str());
ImGui::PopFont();
ImGui::SameLine();
ImGui::TextDisabled("%s%s", e.file.c_str(), e.bundled ? " (comes with Fry3DX)" : "");
if (e.p && !e.p->about.empty()) ImGui::TextWrapped("%s", e.p->about.c_str());
if (!e.error.empty()) {
ImGui::PushStyleColor(ImGuiCol_Text, roleColor(Role::DangerText));
ImGui::TextWrapped("Didn't load: %s", e.error.c_str());
ImGui::PopStyleColor();
} else if (e.p) {
std::string adds;
for (const fry::PluginCommand& c : e.p->commands) adds += std::format("{}{} ({} menu)", adds.empty() ? "" : ", ", c.name, c.menu == "add" ? "Add" : c.menu == "object" ? "Object" : "Script > Plug-ins");
for (const fry::PluginTool& t : e.p->tools) adds += std::format("{}{} (a tool in the Toolbox)", adds.empty() ? "" : ", ", t.name);
size_t mats = 0;
for (const MaterialPreset& m : ownMaterials()) mats += m.from == e.file;
if (mats) adds += std::format("{}{} material{}", adds.empty() ? "" : ", ", mats, mats == 1 ? "" : "s");
ImGui::TextColored(roleColor(Role::TextTertiary), "Adds: %s", adds.empty() ? "nothing yet" : adds.c_str());
} else ImGui::TextDisabled("Turned off");
if (e.bundled) { // (to change one that comes with Fry3DX: your own copy, in your folder)
if (ImGui::SmallButton("Make my own copy to change")) copyPlugin(e);
if (ImGui::IsItemHovered()) ImGui::SetTooltip("Copies it into your plug-ins folder (as my_%s) and turns this one off", e.file.c_str());
}
ImGui::EndChild();
ImGui::PopID();
}
ImGui::End();
if (toggle) setPluginOn(toggle->first, toggle->second);
}
auto App::growString(ImGuiInputTextCallbackData* d) -> int {
if (d->EventFlag == ImGuiInputTextFlags_CallbackResize) {
auto* str = static_cast<std::string*>(d->UserData);
str->resize(size_t(d->BufTextLen));
d->Buf = str->data();
}
return 0;
}
auto App::scriptPanel() -> void {
using keel::ui::Role;
const ImGuiViewport* vp = ImGui::GetMainViewport();
ImGui::SetNextWindowPos(ImVec2(vp->WorkPos.x + vp->WorkSize.x * 0.5f, vp->WorkPos.y + vp->WorkSize.y * 0.5f), ImGuiCond_FirstUseEver, ImVec2(0.5f, 0.5f));
ImGui::SetNextWindowSize(ImVec2(std::min(780.0f, vp->WorkSize.x * 0.55f), vp->WorkSize.y * 0.7f), ImGuiCond_FirstUseEver);
ImGui::SetNextWindowSizeConstraints(panelMin("Script"), ImVec2(FLT_MAX, FLT_MAX));
if (!ImGui::Begin("Script", &showScript_)) return ImGui::End();
// The file's scripts (saved in the .fry with it).
const float listW = std::min(180.0f, ImGui::GetContentRegionAvail().x * 0.3f);
ImGui::BeginChild("scripts", ImVec2(listW, 0));
ImGui::TextColored(roleColor(Role::TextTertiary), "In this file");
std::optional<size_t> remove, copy;
for (size_t i = 0; i < scene_.scripts.size(); ++i) {
ImGui::PushID(int(i));
if (ImGui::Selectable(scene_.scripts[i].name.c_str(), i == script_)) script_ = i;
if (ImGui::BeginPopupContextItem()) {
if (ImGui::MenuItem("Run it")) script_ = i, runScriptNow();
if (ImGui::MenuItem("Duplicate")) copy = i;
if (ImGui::MenuItem("Remove from the file")) remove = i;
ImGui::EndPopup();
}
ImGui::PopID();
}
if (scene_.scripts.empty()) ImGui::TextDisabled("None yet");
ImGui::Spacing();
if (ImGui::Button("+ New", ImVec2(-FLT_MIN, 0))) addScript("Script", kFirstScript);
if (ImGui::Button("Examples", ImVec2(-FLT_MIN, 0))) ImGui::OpenPopup("examples");
if (ImGui::BeginPopup("examples")) {
exampleItems(true);
ImGui::EndPopup();
}
if (ImGui::Button("Open...", ImVec2(-FLT_MIN, 0))) dialog(10);
ImGui::EndChild();
if (copy) addScript(scene_.scripts[*copy].name, scene_.scripts[*copy].code + "\n");
if (remove) {
scene_.scripts.erase(scene_.scripts.begin() + long(*remove));
if (script_ >= scene_.scripts.size()) script_ = scene_.scripts.empty() ? 0 : scene_.scripts.size() - 1;
dirty_ = true;
}
ImGui::SameLine();
ImGui::BeginGroup();
fry::SceneScript* sc = currentScript();
if (!sc) {
ImGui::Dummy(ImVec2(0, 40));
ImGui::TextWrapped("This file has no scripts yet. A script is Lua that builds or changes the scene; it's saved in the .fry "
"with the model.");
pushAccent();
if (ImGui::Button("+ New script")) addScript("Script", kFirstScript);
popAccent();
ImGui::SameLine();
if (ImGui::Button("What scripts can do")) addScript("Script", kFirstScript), showScriptHelp_ = true;
ImGui::EndGroup();
return ImGui::End();
}
pushAccent();
if (ImGui::Button("Run (F5)")) runScriptNow();
popAccent();
ImGui::SameLine();
ImGui::SetNextItemWidth(std::max(80.0f, ImGui::GetContentRegionAvail().x - 190));
if (inputString("##name", sc->name)) dirty_ = true;
if (ImGui::IsItemHovered()) ImGui::SetTooltip("Its name (click to change)");
ImGui::SameLine();
if (ImGui::Button("Save as .lua")) dialog(11);
ImGui::SameLine();
if (ImGui::Button(showScriptHelp_ ? "Hide help" : "?")) showScriptHelp_ = !showScriptHelp_;
if (ImGui::IsItemHovered()) ImGui::SetTooltip("Everything a script can use");
const float logH = std::max(90.0f, ImGui::GetContentRegionAvail().y * 0.22f);
ImGui::PushFont(keel::ui::font(keel::ui::Font::Mono), 0.0f);
if (showScriptHelp_) {
ImGui::BeginChild("help", ImVec2(0, ImGui::GetContentRegionAvail().y * 0.45f), ImGuiChildFlags_FrameStyle);
ImGui::TextUnformatted(fry::scriptHelp());
ImGui::EndChild();
}
const ImVec2 editor(-FLT_MIN, std::max(120.0f, ImGui::GetContentRegionAvail().y - logH - ImGui::GetStyle().ItemSpacing.y));
if (ImGui::InputTextMultiline("##code", sc->code.data(), sc->code.capacity() + 1, editor,
ImGuiInputTextFlags_AllowTabInput | ImGuiInputTextFlags_CallbackResize, growString, &sc->code))
dirty_ = true;
const bool typing = ImGui::IsItemActive();
ImGui::BeginChild("log", ImVec2(-FLT_MIN, 0), ImGuiChildFlags_FrameStyle);
if (scriptLog_.empty()) ImGui::TextDisabled("What it prints shows here.");
for (const auto& [line, error] : scriptLog_) {
if (error) ImGui::PushStyleColor(ImGuiCol_Text, roleColor(keel::ui::Role::DangerText));
ImGui::TextUnformatted(line.c_str());
if (error) ImGui::PopStyleColor();
}
if (std::exchange(scriptLogToBottom_, false)) ImGui::SetScrollHereY(1.0f);
ImGui::EndChild();
ImGui::PopFont();
ImGui::EndGroup();
if (typing && ImGui::GetIO().KeyCtrl && (ImGui::IsKeyPressed(ImGuiKey_Enter, false) || ImGui::IsKeyPressed(ImGuiKey_KeypadEnter, false))) runScriptNow();
if (typing && ImGui::IsKeyPressed(ImGuiKey_F5, false)) runScriptNow();
ImGui::End();
}
auto App::helpWindow() -> void {
ImGui::SetNextWindowSize(ImVec2(580, 620), ImGuiCond_FirstUseEver);
if (!ImGui::Begin("Keys and mouse", &showHelp_)) return ImGui::End();
auto rows = [](const char* title, std::initializer_list<std::pair<const char*, const char*>> list) {
ImGui::SeparatorText(title);
if (!ImGui::BeginTable(title, 2)) return;
ImGui::TableSetupColumn("k", ImGuiTableColumnFlags_WidthFixed, 180);
for (auto [k, what] : list) {
ImGui::TableNextRow();
ImGui::TableNextColumn();
ImGui::TextColored(accentText(), "%s", k);
ImGui::TableNextColumn();
ImGui::TextWrapped("%s", what);
}
ImGui::EndTable();
};
rows("Finding things", {{"F3 or Ctrl+Space", "Search for any command by name and run it"}, {"Space (held)", "The tools round the mouse: point at one, let go"},
{"Shift+Space (held)", "The views round the mouse"}});
myKeysTable();
rows("Looking around", {{"Right drag", "Turn the camera where it stands"}, {"Right held + W A S D", "Move where you look (Q / E: down and up)"},
{"Arrow keys", "Move where you look, any time (Page Up / Down: down and up)"}, {"Wheel", "Forward and back"},
{"Shift + wheel", "How fast every way of moving goes"}, {"Shift (while moving)", "Faster"},
{"Alt + right drag", "Orbit round what's picked (or what you look at)"},
{"Shift+C", "Switch the camera: fly / orbit (right drag round what you look at, the wheel zooms)"}, {"Middle drag / Shift+Right drag", "Pan"},
{"Orbit / Pan / Zoom buttons", "Drag them (touchpads)"}, {"F", "Frame the selection"}, {"Home", "Frame everything"},
{"Numpad 1 / 3 / 7", "Front / side / top (Shift: the opposite)"}, {"Numpad 5", "Flat view / 3D"},
{"Z / Shift+Z", "Solid or Rendered / Wireframe"}, {"Alt+Z", "X-ray: see and pick through"}});
rows("Tools", {{"Q", "Select"}, {"W", "Move"}, {"E", "Rotate"}, {"R", "Scale"}, {"D", "Draw shape"}, {"X", "Extrude (shape editing)"}, {"I", "Inset (shape editing)"},
{"B", "Round edges (shape editing)"}, {"C", "Loop cut (shape editing)"}, {"K", "Knife (shape editing)"}, {"O", "Hole"},
{"Ctrl (held)", "Snap while dragging (or don't, if Snap is on)"}});
rows("Tabs", {{"Ctrl+1", "Model"}, {"Ctrl+2", "Paint"}, {"Ctrl+3", "Layout"}, {"Ctrl+4", "Render"}, {"Ctrl+5", "Print"}});
rows("Layout", {{"Q W E R", "select, move, turn, size frames"}, {"Tab", "frames or corners"}, {"Ctrl+A", "all frames"}});
rows("Print", {{"M", "measure"}, {"L", "lay a face flat on the bed"}, {"B", "put it on the bed"}});
rows("Painting", {{"B / E / G / I", "Brush / eraser / fill / pick a colour"}, {"Alt+click", "Pick a colour"}, {"[ / ]", "Smaller / bigger brush"},
{"Ctrl+V", "Paste a picture onto it"}, {"Enter / Esc", "Place the picture / cancel"}, {"Shift + corner drag", "Resize keeping its shape"},
{"Wheel, middle or right drag", "Zoom and move the Picture panel"}});
rows("Sculpting", {{"Tab (Paint tab)", "Colour or Sculpt"}, {"1 - 6", "Raise, puff out, smooth, flatten, pinch, grab"}, {"Ctrl + brush", "The other way (dig, shrink, push apart)"},
{"Shift + brush", "Smooth"}, {"X", "The same on both sides"}, {"[ / ]", "Smaller / bigger brush"}});
rows("Selecting", {{"Click", "Select"}, {"Drag", "Select with a box, brush or lasso (Tool options)"}, {"Shift + click", "Objects: add to the selection. Shape: a whole loop"},
{"Ctrl + click", "Objects: take away. Shape: add or take one"}, {"Shift+Ctrl + click an edge", "The ring across it (shape)"}, {"Alt+click / double-click", "A whole loop (shape editing)"}, {"Ctrl+A / Alt+A", "All / none"},
{"Ctrl+I", "Invert"}, {"Ctrl + / Ctrl -", "More / less"}, {"L", "Everything joined to it"}, {"Esc", "Let go / cancel / leave shape editing"}});
rows("Objects and shape", {{"Tab / double-click", "Edit an object's shape"}, {"1 / 2 / 3", "Points / edges / faces (shape editing)"}, {"M", "Merge points"},
{"P", "Selected faces into their own object"}, {"Shift + Scale drag", "Scale both sides (from the middle)"}, {"Ctrl+G", "Group into a folder"},
{"Ctrl+Shift + / -", "Add together / take the others out (last picked kept)"}, {"Del", "Delete"}, {"Ctrl+D", "Duplicate"}, {"Ctrl+J", "Join objects into one"},
{"H / Alt+H", "Hide / show all"}, {"/ or Shift+H", "Show only the selected (again: all)"},
{"Shift+O", "Soft selection: near points follow (wheel while dragging: how far)"}, {"Alt+S", "Push points out / pull in (shape)"}});
rows("Files", {{"Ctrl+N / O / S", "New / open / save"}, {"Ctrl+Shift+S", "Save as"}, {"Ctrl+E", "Export (GLB, glTF, FBX, OBJ, 3MF, STL, PLY)"}, {"F12", "Save a picture of the view"},
{"Ctrl+Z / Ctrl+Y", "Undo / redo"}});
ImGui::End();
}
// Keys of your own: any command on any key (they come before the usual ones).
auto App::myKeysTable() -> void {
static char filter[64] = {};
if (!ImGui::CollapsingHeader("Your own keys")) return;
if (commands_.empty()) buildCommands();
ImGui::TextWrapped("Give any command a key: click Set, then press the key (with Ctrl, Shift or Alt if you like). Your keys come before the usual ones.");
ImGui::SetNextItemWidth(-FLT_MIN);
ImGui::InputTextWithHint("##kf", "Find a command", filter, sizeof filter);
if (!settingKey_.empty()) {
ImGui::TextColored(accentText(), "Press the key for it now (Esc: never mind)");
if (ImGui::IsKeyPressed(ImGuiKey_Escape)) settingKey_.clear();
else if (const auto c = fry::pressedChord()) {
std::erase_if(myKeys_, [&](const auto& kv) { return kv.second == *c; }); // (a key does one thing)
myKeys_[settingKey_] = *c;
saveMyKeys();
settingKey_.clear();
}
}
if (!ImGui::BeginTable("mykeys", 3, ImGuiTableFlags_RowBg | ImGuiTableFlags_ScrollY, ImVec2(0, 260))) return;
ImGui::TableSetupColumn("what");
ImGui::TableSetupColumn("key", ImGuiTableColumnFlags_WidthFixed, 130);
ImGui::TableSetupColumn("do", ImGuiTableColumnFlags_WidthFixed, 96);
for (const fry::Command& c : commands_) {
if (filter[0] && !fry::commandScore(c, filter)) continue;
ImGui::PushID(c.id.c_str());
ImGui::TableNextRow();
ImGui::TableNextColumn();
ImGui::TextUnformatted(c.name.c_str());
ImGui::TableNextColumn();
const auto mine = myKeys_.find(c.id);
if (mine != myKeys_.end()) ImGui::TextColored(accentText(), "%s", fry::chordName(mine->second).c_str());
else ImGui::TextDisabled("%s", c.keys.c_str());
ImGui::TableNextColumn();
if (ImGui::SmallButton(settingKey_ == c.id ? "..." : "Set")) settingKey_ = c.id;
if (mine != myKeys_.end()) {
ImGui::SameLine();
if (ImGui::SmallButton("Clear")) myKeys_.erase(c.id), saveMyKeys();
}
ImGui::PopID();
}
ImGui::EndTable();
}
// Each picked object (or all, in the Print tab with none picked) made ready to print.
auto App::repairSelected() -> void {
std::vector<Object*> targets;
if (tab_ == Tab::Print) targets = printTargets();
else
for (Object& o : scene_.objects)
if (selected_.count(o.id)) targets.push_back(&o);
std::erase_if(targets, [](const Object* o) { return o->locked; });
if (targets.empty()) return say("Pick what to repair first");
if (mode_ == Mode::Edit) leaveEdit();
pushUndo();
fry::Repair all;
int still = 0;
for (Object* o : targets) {
const fry::Repair r = fry::repairForPrint(o->mesh);
all.joinedPoints += r.joinedPoints, all.emptyFaces += r.emptyFaces, all.doubledFaces += r.doubledFaces, all.turnedFaces += r.turnedFaces, all.closedHoles += r.closedHoles;
if (r.total()) handMade(*o), touched(o->id);
still += !checkForPrint(o->mesh).closed();
}
if (!all.total()) return takeBack(), say(still ? "Nothing it can repair by itself: look at what the Print tab says" : "Nothing to repair: ready to print");
std::vector<std::string> did;
if (all.joinedPoints) did.push_back(std::format("{} points joined", all.joinedPoints));
if (all.emptyFaces) did.push_back(std::format("{} empty faces gone", all.emptyFaces));
if (all.doubledFaces) did.push_back(std::format("{} doubled faces gone", all.doubledFaces));
if (all.turnedFaces) did.push_back(std::format("{} faces turned out", all.turnedFaces));
if (all.closedHoles) did.push_back(std::format("{} holes closed", all.closedHoles));
std::string text;
for (const std::string& d : did) text += (text.empty() ? "" : ", ") + d;
say("Repaired: " + text + (still ? std::format(". {} still not closed (see the Print tab)", still) : ". Ready to print"));
}
// Crease (Shift+E): the picked edges (or those between picked points / round picked faces)
// kept sharp when the shape's smoothed; again: not.
auto App::creaseSelected() -> void {
Object* o = editing();
if (!o) return say("Crease works on a shape being edited (Tab)");
std::set<std::pair<int, int>> picked;
if (elem_ == Elem::Edge)
for (auto [a, b] : selEdges_) picked.insert({std::min(a, b), std::max(a, b)});
else {
const std::set<int> pts = selectedPoints();
for (const auto& e : adjacencyOf(*o, o->mesh).edges)
if (pts.count(e[0]) && pts.count(e[1])) picked.insert({e[0], e[1]});
}
if (picked.empty()) return say("Pick the edges to crease first (Edges, 2)");
pushUndo();
const bool all = std::all_of(picked.begin(), picked.end(), [&](const auto& e) { return o->creases.count(e) > 0; });
for (const auto& e : picked) all ? (void)o->creases.erase(e) : (void)o->creases.insert(e);
touched(o->id);
say(all ? std::format("{} edge{} smooth again", picked.size(), picked.size() == 1 ? "" : "s")
: std::format("{} edge{} creased: smoothing keeps {} sharp{}", picked.size(), picked.size() == 1 ? "" : "s", picked.size() == 1 ? "it" : "them",
o->smoothLevels ? "" : " (turn on Smooth out to see)"));
}
auto App::startSlide() -> void {
Object* o = editing();
if (!o) return say("Slide works on a shape being edited (Tab)");
const std::set<int> pts = selectedPoints();
if (pts.empty()) return say("Pick points, edges or faces to slide first");
std::vector<std::set<int>> near(o->mesh.verts.size());
for (const auto& e : adjacencyOf(*o, o->mesh).edges)
if (pts.count(e[0]) != pts.count(e[1])) near[size_t(e[0])].insert(e[1]), near[size_t(e[1])].insert(e[0]);
slide_ = {};
for (int v : pts)
if (!near[size_t(v)].empty()) slide_.pts.push_back(v), slide_.rails.emplace_back(near[size_t(v)].begin(), near[size_t(v)].end());
if (slide_.pts.empty()) return say("Nothing for those to slide along (every edge of theirs is picked too)");
pushUndo();
slide_.active = true, slide_.object = o->id, slide_.from = o->mesh, slide_.start = ImGui::GetIO().MousePos;
say("Sliding: move the mouse along the edges, click to keep it (Esc: as it was)");
}
auto App::cancelSlide() -> void {
if (!slide_.active) return;
slide_.active = false;
if (Object* o = find(slide_.object)) o->mesh = slide_.from, touched(o->id);
takeBack();
say("Slide put back");
}
auto App::slideFrame(const ImGuiIO& io) -> void {
Object* o = find(slide_.object);
if (!o || mode_ != Mode::Edit) {
if (o) o->mesh = slide_.from, touched(o->id);
return void(slide_.active = false);
}
if (ImGui::IsMouseClicked(ImGuiMouseButton_Right)) return cancelSlide(); // (Esc: onEscape)
const glm::mat4 m = objectMatrix(*o);
const ImVec2 d(io.MousePos.x - slide_.start.x, io.MousePos.y - slide_.start.y);
const float dl = std::hypot(d.x, d.y);
float shown = 0;
o->mesh.verts = slide_.from.verts;
if (dl > 1) {
for (size_t i = 0; i < slide_.pts.size(); ++i) {
const glm::vec3 p = slide_.from.verts[size_t(slide_.pts[i])];
ImVec2 sp;
if (!project(glm::vec3(m * glm::vec4(p, 1.0f)), sp)) continue;
// (the edge whose way on the screen is most the mouse's, and how far along it the mouse has gone)
float best = 0.2f, t = 0;
int to = -1;
for (int r : slide_.rails[i]) {
ImVec2 sr;
if (!project(glm::vec3(m * glm::vec4(slide_.from.verts[size_t(r)], 1.0f)), sr)) continue;
const ImVec2 e(sr.x - sp.x, sr.y - sp.y);
const float el = std::hypot(e.x, e.y);
if (el < 1) continue;
const float along = (e.x * d.x + e.y * d.y) / el; // (pixels the mouse went that way)
if (const float k = along / dl; k > best) best = k, to = r, t = std::clamp(along / el, 0.0f, 0.98f);
}
if (to >= 0) o->mesh.verts[size_t(slide_.pts[i])] = glm::mix(p, slide_.from.verts[size_t(to)], t), shown = std::max(shown, t);
}
}
if (liveMirror_) mirrorEdits(*o, slide_.from);
touched(o->id);
message_ = std::format("Sliding {:.0f}% of the way along (click: keep, Esc: as it was)", shown * 100), messageAt_ = now();
if (ImGui::IsMouseClicked(ImGuiMouseButton_Left)) {
slide_.active = false;
changedShape(*o, true);
say(std::format("Slid {} point{}", slide_.pts.size(), slide_.pts.size() == 1 ? "" : "s"));
}
}
// Looking straight at a reference picture's plane (the front picture from the front or the back...).
auto App::lookingAt(Axis a) const -> bool {
switch (a) {
case Axis::Front: return view_ == View::Front || view_ == View::Back;
case Axis::Top: return view_ == View::Top || view_ == View::Bottom;
default: return view_ == View::Left || view_ == View::Right;
}
}
auto App::resetLayout() -> void {
layoutAgain_ = true, layoutDone_ = false;
say("The panels are back where they started");
}
auto App::workspacesWindow() -> void {
ImGui::SetNextWindowSize(ImVec2(360, 0), ImGuiCond_FirstUseEver);
if (!ImGui::Begin("Workspaces", &showWorkspaces_, ImGuiWindowFlags_NoDocking | ImGuiWindowFlags_AlwaysAutoResize)) return ImGui::End();
ImGui::TextWrapped("The panels as they are now, kept under a name: one for modelling, one for painting, one for a small screen...");
ImGui::SetNextItemWidth(220);
ImGui::InputText("##wname", workspaceName_, sizeof workspaceName_);
ImGui::SameLine();
if (ImGui::Button("Keep") && workspaceName_[0]) {
std::error_code ec;
stdfs::create_directories(workspaceDir(), ec);
size_t size = 0;
const char* ini = ImGui::SaveIniSettingsToMemory(&size);
std::ofstream(workspaceDir() + fileSafe(workspaceName_) + ".ini", std::ios::binary).write(ini, std::streamsize(size));
say(std::format("Workspace \"{}\" kept", workspaceName_));
}
ImGui::SeparatorText("Kept");
if (ImGui::Selectable("As it started (the usual)")) resetLayout();
std::error_code ec;
std::vector<stdfs::path> kept;
for (const auto& e : stdfs::directory_iterator(workspaceDir(), ec))
if (e.path().extension() == ".ini") kept.push_back(e.path());
std::sort(kept.begin(), kept.end());
for (const stdfs::path& f : kept) {
ImGui::PushID(f.string().c_str());
if (ImGui::Selectable(f.stem().string().c_str(), false, ImGuiSelectableFlags_AllowOverlap, ImVec2(250, 0))) {
std::ifstream in(f, std::ios::binary);
pendingLayout_.assign(std::istreambuf_iterator<char>(in), {});
say("Workspace: " + f.stem().string());
}
ImGui::SameLine(270);
if (ImGui::SmallButton("Delete")) stdfs::remove(f, ec);
ImGui::PopID();
}
ImGui::End();
}
auto App::fileSafe(std::string n) -> std::string {
for (char& c : n)
if (std::strchr("\\/:*?\"<>|", c)) c = '_';
return n;
}
// The steps that can be undone, oldest first: a click goes back (or on) to just after it.
auto App::historyWindow() -> void {
ImGui::SetNextWindowSize(ImVec2(340, 420), ImGuiCond_FirstUseEver);
if (!ImGui::Begin("Undo history", &showHistory_)) return ImGui::End();
int back = 0, on = 0; // (how many undos / redos the click asks for)
if (ImGui::Selectable("The start", undo_.empty())) back = int(undo_.size());
for (size_t i = 0; i < undo_.size(); ++i) {
const std::string& l = undo_[i].label;
ImGui::PushID(int(i));
if (ImGui::Selectable(l.empty() ? "A change" : l.c_str(), i + 1 == undo_.size())) back = int(undo_.size() - 1 - i);
ImGui::PopID();
}
for (size_t i = redo_.size(); i-- > 0;) { // (what redo would bring back, greyed)
ImGui::PushID(int(10000 + i));
ImGui::PushStyleColor(ImGuiCol_Text, ImGui::GetStyleColorVec4(ImGuiCol_TextDisabled));
const std::string& l = redo_[i].label;
if (ImGui::Selectable(l.empty() ? "A change (undone)" : (l + " (undone)").c_str())) on = int(redo_.size() - i);
ImGui::PopStyleColor();
ImGui::PopID();
}
if (undo_.size() >= 200) ImGui::TextDisabled("(the 200 latest steps are kept)");
ImGui::End();
for (int i = 0; i < back; ++i) undo();
for (int i = 0; i < on; ++i) redo();
}
auto App::aboutWindow() -> void {
using keel::ui::Role;
const float w = 460.0f;
ImGui::SetNextWindowSize(ImVec2(w, 0), ImGuiCond_Always);
ImGui::SetNextWindowPos(ImGui::GetMainViewport()->GetCenter(), ImGuiCond_Appearing, ImVec2(0.5f, 0.5f));
ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(28, 24));
const bool open = ImGui::Begin("About Fry3DX", &showAbout_, ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoDocking);
ImGui::PopStyleVar();
if (!open) return ImGui::End();
auto centred = [](const char* text, ImVec4 colour) {
ImGui::SetCursorPosX((ImGui::GetWindowWidth() - ImGui::CalcTextSize(text).x) * 0.5f);
ImGui::TextColored(colour, "%s", text);
};
// The logo, warmed from behind by the stove.
const float logo = 128.0f;
const ImVec2 at(ImGui::GetWindowPos().x + (ImGui::GetWindowWidth() - logo) * 0.5f, ImGui::GetCursorScreenPos().y + 4);
ImDrawList* dl = ImGui::GetWindowDrawList();
const ImVec2 mid(at.x + logo * 0.5f, at.y + logo * 0.62f);
for (int i = 16; i >= 1; --i) dl->AddCircleFilled(mid, logo * (0.3f + 0.04f * float(i)), keel::ui::accent(0.018f), 64);
ImGui::SetCursorScreenPos(at);
logoImage(logo);
ImGui::Dummy(ImVec2(0, 6));
ImGui::PushFont(keel::ui::font(keel::ui::Font::Title), 30.0f);
centred(project::name, roleColor(Role::Text));
ImGui::PopFont();
centred(project::long_, accentText());
centred(std::format("Version {}", project::version).c_str(), roleColor(Role::TextSecondary));
ImGui::Dummy(ImVec2(0, 8));
ImGui::PushStyleColor(ImGuiCol_Text, roleColor(Role::TextSecondary));
ImGui::TextWrapped("%s", project::description);
ImGui::PopStyleColor();
ImGui::Dummy(ImVec2(0, 4));
// What it's running on (for a bug report: Copy).
const keel::GpuInfo gpu = r_->gpuInfo();
const auto& machine = keel::sys::machine();
const std::vector<std::pair<const char*, std::string>> rows = {
{"Version", project::version}, {"Drawn with", r_->apiName()}, {"Graphics", gpu.name.empty() ? "?" : gpu.name}, {"System", machine.os}, {"Processor", machine.cpu},
{"Plug-ins", std::format("{} loaded", std::count_if(plugins_.begin(), plugins_.end(), [](const PluginEntry& e) { return e.p && e.error.empty(); }))}};
ImGui::BeginChild("details", ImVec2(0, 0), ImGuiChildFlags_FrameStyle | ImGuiChildFlags_AutoResizeY);
if (ImGui::BeginTable("details", 2, ImGuiTableFlags_SizingFixedFit)) {
ImGui::TableSetupColumn("k", ImGuiTableColumnFlags_WidthFixed, 90);
ImGui::TableSetupColumn("v", ImGuiTableColumnFlags_WidthStretch);
for (const auto& [k, v] : rows) {
ImGui::TableNextRow();
ImGui::TableNextColumn();
ImGui::TextColored(roleColor(Role::TextTertiary), "%s", k);
ImGui::TableNextColumn();
ImGui::TextWrapped("%s", v.c_str());
}
ImGui::EndTable();
}
ImGui::EndChild();
ImGui::Dummy(ImVec2(0, 4));
const float bw = (ImGui::GetContentRegionAvail().x - ImGui::GetStyle().ItemSpacing.x * 2) / 3;
pushAccent();
if (ImGui::Button("Website", ImVec2(bw, 0))) SDL_OpenURL(project::website);
popAccent();
ImGui::SameLine();
if (ImGui::Button("Source on GitHub", ImVec2(bw, 0))) SDL_OpenURL(project::repo);
ImGui::SameLine();
if (ImGui::Button("Copy details", ImVec2(bw, 0))) {
std::string text = project::name;
for (const auto& [k, v] : rows) text += std::format("\n{}: {}", k, v);
SDL_SetClipboardText(text.c_str());
say("The details are copied: paste them into a bug report");
}
// Who made it (project.json's creators: a name with a link opens it).
ImGui::Dummy(ImVec2(0, 6));
ImGui::TextColored(roleColor(Role::TextTertiary), "Made by");
for (const project::Creator& c : project::creators) {
ImGui::PushID(c.name);
if (*c.url) {
ImGui::PushStyleColor(ImGuiCol_Text, accentText());
if (ImGui::Selectable(c.name, false, ImGuiSelectableFlags_None, ImVec2(ImGui::CalcTextSize(c.name).x, 0))) SDL_OpenURL(c.url);
ImGui::PopStyleColor();
if (ImGui::IsItemHovered()) ImGui::SetTooltip("%s", c.url);
} else {
ImGui::TextUnformatted(c.name);
}
if (*c.role) {
ImGui::SameLine();
ImGui::TextColored(roleColor(Role::TextSecondary), " %s", c.role);
}
ImGui::PopID();
}
ImGui::Dummy(ImVec2(0, 4));
ImGui::PushStyleColor(ImGuiCol_Text, roleColor(Role::TextTertiary));
ImGui::TextWrapped("Open source (%s). Built on %s; the look is %s.", project::license, project::builtWith, project::look);
ImGui::PopStyleColor();
ImGui::End();
}
} // namespace fryapp

File diff suppressed because it is too large Load diff

Some files were not shown because too many files have changed in this diff Show more