Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1ee38c966c | ||
|
|
ee56ed4005 | ||
|
|
b2fc0437bc | ||
|
|
9d9557117a | ||
|
|
d313a9452a | ||
|
|
415f8fa352 | ||
|
|
de749dba11 | ||
|
|
9a7ed67d80 | ||
|
|
3c819146af | ||
|
|
ad6a7c072b | ||
|
|
830876a18e | ||
|
|
1cda87b19a | ||
|
|
93c65638d8 | ||
|
|
9918d9a770 | ||
|
|
b95e436d10 | ||
|
|
5394eeb3d8 | ||
|
|
f9ba30ab3d | ||
|
|
b9b6932d0e | ||
|
|
e0831563b7 | ||
|
|
2f68430354 | ||
|
|
ee7abc2b1f | ||
|
|
ab554f277f | ||
|
|
67866ed0fb | ||
|
|
14e35cceb1 | ||
|
|
4f32540d62 | ||
|
|
e6b3ffeda9 | ||
|
|
e8d634f15c | ||
|
|
445628b0d8 | ||
|
|
c742c937c6 | ||
|
|
f5d1b79756 | ||
|
|
1a09c17155 | ||
|
|
6dd7f976c3 | ||
|
|
f1f7a3fecc | ||
|
|
c2bbafd40c | ||
|
|
ecd7c2cfda | ||
|
|
48a9dbe918 | ||
|
|
a79a6c9020 | ||
|
|
c1991d9f73 | ||
|
|
ec67538be0 | ||
|
|
707e0b62e5 | ||
|
|
3f23371eac | ||
|
|
3bcd50033d | ||
|
|
ffaa5e8001 | ||
|
|
4cc6d9e043 | ||
|
|
5d3121b335 | ||
|
|
e18c7f6bce | ||
|
|
e22cd2e3e6 | ||
|
|
5c28e3da4e | ||
|
|
e93ae44684 | ||
|
|
87436019b3 | ||
|
|
62afbf2b1e | ||
|
|
04c8985374 | ||
|
|
53d8ecbfcd | ||
|
|
0f059c37be | ||
|
|
a5e0d2a9eb | ||
|
|
5d4ff6338a | ||
|
|
39a11e401e |
37
.github/workflows/build.yml
vendored
|
|
@ -1,4 +1,5 @@
|
|||
# Builds Fry3DX for Linux (a portable .tar.gz and an AppImage), Windows (.zip) and Android (.apk).
|
||||
# Builds Fry3DX for Linux (a portable .tar.gz and an AppImage), Windows (.zip), macOS (.zip with one
|
||||
# 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;
|
||||
# otherwise they're the run's artifacts (Actions tab).
|
||||
# Android signing: secrets FRY_KEYSTORE_B64 (the keystore, base64) and FRY_KEYSTORE_PASS. Keep
|
||||
|
|
@ -31,6 +32,11 @@ jobs:
|
|||
run: |
|
||||
cmake --preset portable -DGLSLC=$ANDROID_NDK_HOME/shader-tools/linux-x86_64/glslc
|
||||
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
|
||||
run: APPIMAGETOOL=$PWD/appimagetool python3 tools/package.py linux appimage
|
||||
- uses: actions/upload-artifact@v4
|
||||
|
|
@ -53,6 +59,31 @@ jobs:
|
|||
- uses: actions/upload-artifact@v4
|
||||
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:
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
|
|
@ -62,7 +93,7 @@ jobs:
|
|||
- name: SDK
|
||||
run: |
|
||||
yes | "$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" "ndk;27.2.12479018" "build-tools;35.0.0" "platforms;android-35" > /dev/null
|
||||
sudo apt-get install -y ninja-build
|
||||
sudo apt-get install -y ninja-build glslc
|
||||
- name: Build
|
||||
env:
|
||||
ANDROID_NDK_ROOT: ${{ env.ANDROID_HOME }}/ndk/27.2.12479018
|
||||
|
|
@ -77,7 +108,7 @@ jobs:
|
|||
|
||||
release:
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
needs: [linux, windows, android]
|
||||
needs: [linux, windows, macos, android]
|
||||
runs-on: ubuntu-latest
|
||||
permissions: { contents: write }
|
||||
steps:
|
||||
|
|
|
|||
4
.gitignore
vendored
|
|
@ -4,3 +4,7 @@ build/
|
|||
__pycache__/
|
||||
*.fry~
|
||||
dist/
|
||||
|
||||
# crash dumps and stray test programs
|
||||
core.[0-9]*
|
||||
a.out
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
cmake_minimum_required(VERSION 3.25)
|
||||
project(Fry3DX VERSION 0.5.0 LANGUAGES C CXX)
|
||||
include(cmake/Project.cmake) # project.json: the version, links, creators
|
||||
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)
|
||||
if(UNIX AND NOT APPLE)
|
||||
add_link_options(-rdynamic) # function names in crash reports
|
||||
|
|
@ -30,21 +31,32 @@ set(CMAKE_CXX_EXTENSIONS OFF)
|
|||
set(CMAKE_EXPORT_COMPILE_COMMANDS ON)
|
||||
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/Shaders.cmake)
|
||||
|
||||
add_subdirectory(engine)
|
||||
add_subdirectory(src)
|
||||
|
||||
# Content next to the program: bin/engine/ (the engine's: fonts, sky, config); bin/Fry3DX/ (the app's own, none yet).
|
||||
# Content next to the program: engine/ (the engine's: fonts, sky, config); Fry3DX/ (the app's own: scripts, branding).
|
||||
add_custom_target(content ALL
|
||||
COMMAND ${CMAKE_COMMAND} -E copy_directory ${CMAKE_SOURCE_DIR}/content ${CMAKE_RUNTIME_OUTPUT_DIRECTORY}
|
||||
COMMAND ${CMAKE_COMMAND} -E make_directory ${CMAKE_RUNTIME_OUTPUT_DIRECTORY}/engine/fonts ${CMAKE_RUNTIME_OUTPUT_DIRECTORY}/Fry3DX
|
||||
COMMAND ${CMAKE_COMMAND} -E copy_directory ${CMAKE_SOURCE_DIR}/content ${FRY_CONTENT_DIR}
|
||||
COMMAND ${CMAKE_COMMAND} -E make_directory ${FRY_CONTENT_DIR}/engine/fonts ${FRY_CONTENT_DIR}/Fry3DX
|
||||
COMMAND ${CMAKE_COMMAND} -E copy_if_different ${dejavu_SOURCE_DIR}/ttf/DejaVuSans.ttf ${dejavu_SOURCE_DIR}/ttf/DejaVuSans-Bold.ttf
|
||||
${dejavu_SOURCE_DIR}/LICENSE ${CMAKE_RUNTIME_OUTPUT_DIRECTORY}/engine/fonts/
|
||||
${dejavu_SOURCE_DIR}/LICENSE ${FRY_CONTENT_DIR}/engine/fonts/
|
||||
COMMENT "Copying content" VERBATIM)
|
||||
|
||||
enable_testing()
|
||||
add_subdirectory(tests EXCLUDE_FROM_ALL)
|
||||
add_custom_target(check COMMAND ${CMAKE_CTEST_COMMAND} --test-dir ${CMAKE_BINARY_DIR} --output-on-failure
|
||||
DEPENDS mesh_test USES_TERMINAL VERBATIM)
|
||||
DEPENDS mesh_test script_test fuzz_test USES_TERMINAL VERBATIM)
|
||||
|
|
|
|||
|
|
@ -7,6 +7,10 @@
|
|||
{ "name": "portable", "inherits": "base", "cacheVariables": { "CMAKE_BUILD_TYPE": "Release", "FRY_PORTABLE": "ON" } },
|
||||
{ "name": "windows", "inherits": "base", "toolchainFile": "${sourceDir}/cmake/toolchains/windows-msvc.cmake",
|
||||
"cacheVariables": { "CMAKE_BUILD_TYPE": "Release", "CMAKE_MSVC_RUNTIME_LIBRARY": "MultiThreaded" } },
|
||||
{ "name": "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",
|
||||
"cacheVariables": { "CMAKE_BUILD_TYPE": "Release", "ANDROID_ABI": "arm64-v8a", "ANDROID_PLATFORM": "android-28", "ANDROID_STL": "c++_static" } }
|
||||
],
|
||||
|
|
@ -15,6 +19,8 @@
|
|||
{ "name": "release", "configurePreset": "release" },
|
||||
{ "name": "portable", "configurePreset": "portable" },
|
||||
{ "name": "windows", "configurePreset": "windows" },
|
||||
{ "name": "macos-arm64", "configurePreset": "macos-arm64" },
|
||||
{ "name": "macos-x64", "configurePreset": "macos-x64" },
|
||||
{ "name": "android", "configurePreset": "android" }
|
||||
]
|
||||
}
|
||||
|
|
|
|||
120
README.md
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
<h1 align="center">Fry3DX</h1>
|
||||
<p align="center"><b>Frying 3D Extreme — a 3D modeller that does what you'd expect.</b><br>
|
||||
Pick a tool, click, drag. On Windows, Linux and your phone.</p>
|
||||
Pick a tool, click, drag. On Windows, macOS, Linux and your phone.</p>
|
||||
<p align="center">
|
||||
<a href="https://nexoniarz.github.io/Fry3DX/">Website</a> ·
|
||||
<a href="https://github.com/Nexoniarz/Fry3DX/releases">Download</a> ·
|
||||
|
|
@ -28,15 +28,23 @@ From [Releases](https://github.com/Nexoniarz/Fry3DX/releases):
|
|||
| | |
|
||||
|---|---|
|
||||
| **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` |
|
||||
| **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.
|
||||
Needs a graphics card (or phone) with Vulkan; on a PC, OpenGL 4.6 works too. On a Mac, Vulkan
|
||||
comes inside the app (MoltenVK, on Metal), and OpenGL 4.1 is the fallback.
|
||||
|
||||
## What it does
|
||||
|
||||
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**
|
||||
- **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,
|
||||
|
|
@ -44,58 +52,126 @@ 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
|
||||
face of another object: a new part standing on it, added to it, or cut into it.
|
||||
- **Move, turn, scale** (W, E, R): the arrows turn with the object. Scale keeps the other side
|
||||
where it is; Shift scales both sides at once. Ctrl snaps.
|
||||
where it is; Shift scales both sides at once. Ctrl snaps: to steps, or to points and the middles
|
||||
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.
|
||||
- **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)
|
||||
- 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).
|
||||
cut in four, smooth, merge (M), make a face, flip, separate (P), smooth points, push out /
|
||||
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
|
||||
sizes, with room for prints); all the way through, a pocket, or only its outline as a new face.
|
||||
- **Selecting**: click, box, brush or lasso. Shift+click a whole loop, Shift+Ctrl+click an
|
||||
edge's ring, Ctrl+click adds or takes one.
|
||||
|
||||
**Whole objects** (the Object menu)
|
||||
- **Add together, take one out of another, keep only where they overlap**: always closed, so
|
||||
always printable (Ctrl+Shift+ and Ctrl+Shift−).
|
||||
- **Copies** in a row or round a circle; **make it thick** (a surface gets a back, a solid goes
|
||||
- **Add together, take one out of another, keep only where they overlap**: exact
|
||||
([Manifold](https://github.com/elalish/manifold)) and always closed, so always printable
|
||||
(Ctrl+Shift+ and Ctrl+Shift−).
|
||||
- **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.
|
||||
- **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.
|
||||
on every row that brings the camera to it. **Show only the selected** (/ or Shift+H; again:
|
||||
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**
|
||||
- Paint on the model or on its flat picture. **Paste any picture** (Ctrl+V): it goes on like a
|
||||
- Paint on the model or on its flat picture, on **both sides** at once, or only inside 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
|
||||
Picture panel unlocks it again.
|
||||
- **Layout tab**: move, turn and size the flat frames of the surface yourself; the paint follows.
|
||||
|
||||
**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
|
||||
(Shift+Z), X-ray (Alt+Z). The Render tab: the sun, sky, ground, brightness, shadows, glow;
|
||||
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)
|
||||
- Your printer's plate in the view (Prusa, Bambu, Creality, Elegoo, Anycubic, or your own),
|
||||
sizes in millimetres, checks for holes and flipped faces with buttons that fix them, overhangs
|
||||
in red, weight and filament, measuring, lay flat on a face, save 3MF or STL.
|
||||
sizes in millimetres, checks for holes and flipped faces with **one repair button**, overhangs
|
||||
in red, **walls too thin** in yellow, weight and filament, measuring, lay flat on a face, save
|
||||
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**
|
||||
- `.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.
|
||||
offered back after a crash; Save / Don't save / Cancel when closing. Drop a `.fry`, a script or
|
||||
a picture on the window to open it.
|
||||
- **Export**: GLB, glTF, FBX (Blender, Unity, Unreal, Maya), OBJ, 3MF, STL, PLY.
|
||||
|
||||
**On a phone or tablet**: everything bigger, the side panels when you want them, a bar of big
|
||||
buttons (undo, redo, Shift and Ctrl that stay down, edit the shape, delete, frame). One finger
|
||||
turns the view (or drags the arrows), two fingers move it and pinch to zoom, holding a finger
|
||||
still is a right-click. Files open and save through the phone's own file picker.
|
||||
still is a right-click. Files open and save through the phone's own file picker. It stays
|
||||
sideways, and going to another app and back keeps your work.
|
||||
|
||||
F1 in the app lists every key.
|
||||
|
||||
## Build it yourself
|
||||
|
||||
Everything it uses is downloaded while building; you need CMake 3.25+, Ninja, a C++20 compiler
|
||||
(GCC 13+, Clang 17+ or clang-cl) and glslc (from shaderc or the Vulkan SDK). On NixOS,
|
||||
`shell.nix` has it all.
|
||||
(GCC 13+, Clang 17+, Xcode 15+ 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`.
|
||||
|
||||
```sh
|
||||
git clone https://github.com/Nexoniarz/Fry3DX.git && cd Fry3DX
|
||||
|
|
@ -109,9 +185,10 @@ cmake --build build/debug --target check # the tests (after
|
|||
| `debug`, `release` | for working on it |
|
||||
| `portable` | a Linux build to hand out: SDL and the C++ library inside the program |
|
||||
| `windows` | Windows, cross-built from Linux with clang-cl ([how](cmake/toolchains/windows-msvc.cmake)) |
|
||||
| `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)) |
|
||||
|
||||
`tools/package.py` packs builds into the downloads (zip, tar.gz, AppImage). Pushing a tag
|
||||
`tools/package.py` packs builds into the downloads (zip, tar.gz, AppImage, the Mac app). Pushing a tag
|
||||
`v*` makes GitHub build all of them and put them in a release (`.github/workflows/build.yml`).
|
||||
|
||||
## What's where
|
||||
|
|
@ -122,19 +199,22 @@ 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/boolean.cpp`, `modify.cpp` | combining shapes, thickness, joining flat faces |
|
||||
| `src/shapes.*` | the shape library and starter models |
|
||||
| `src/scene.*`, `package.cpp` | objects, folders, the `.fry` file |
|
||||
| `src/scene.*`, `package.cpp` | objects, folders, materials, 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/uv.cpp`, `sticker.cpp`, `image.cpp` | flat layouts, stickers, pictures |
|
||||
| `engine/` | window, Vulkan/OpenGL renderer, UI, files |
|
||||
| `android/`, `tools/`, `.github/` | builds and packages |
|
||||
| `docs/` | the website (GitHub Pages); its words are all in `docs/content.js` |
|
||||
| `tests/` | `mesh_test` |
|
||||
| `tests/` | `mesh_test`, `script_test` (every example script runs) |
|
||||
|
||||
## Coming
|
||||
|
||||
Sculpting, materials shown in the view, scripting; animation later.
|
||||
Animation; textures for materials (wood grain, brushed metal); dynamic detail while sculpting.
|
||||
|
||||
## Licence
|
||||
|
||||
[Apache License 2.0](LICENSE). Copyright 2026 Nexoniarz.
|
||||
Sky pictures: Poly Haven, CC0. DejaVu fonts: their own free licence (`engine/fonts/LICENSE` in builds).
|
||||
Sky pictures: Poly Haven, CC0. Fonts: Instrument Sans and JetBrains Mono (SIL Open Font
|
||||
License), DejaVu (its own free licence). Lua: MIT. MoltenVK (in the Mac app): Apache 2.0.
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ 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
|
||||
updates an app signed with the key it was installed with.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import secrets
|
||||
import shutil
|
||||
|
|
@ -30,11 +31,8 @@ def run(*cmd, **kw):
|
|||
|
||||
|
||||
def app_version():
|
||||
with open(os.path.join(ROOT, "CMakeLists.txt")) as f:
|
||||
for line in f:
|
||||
if line.startswith("project(Fry3DX VERSION "):
|
||||
return line.split()[2]
|
||||
return "0"
|
||||
with open(os.path.join(ROOT, "project.json")) as f: # (the version and the rest live there)
|
||||
return json.load(f)["version"]
|
||||
|
||||
|
||||
def main():
|
||||
|
|
@ -44,8 +42,12 @@ def main():
|
|||
llvm = os.path.join(ndk, "toolchains", "llvm", "prebuilt", "linux-x86_64", "bin")
|
||||
|
||||
if not os.path.isdir(BUILD):
|
||||
glslc = os.environ.get("GLSLC") or os.path.join(ndk, "shader-tools", "linux-x86_64", "glslc") # (the NDK's own, if none's given)
|
||||
run("cmake", "--preset", "android", *([f"-DGLSLC={glslc}"] if os.path.exists(glslc) else []), cwd=ROOT)
|
||||
# A current glslc: $GLSLC, or the one on PATH. (Not the NDK's: its 2022 shaderc makes compute
|
||||
# shaders some phone drivers can't build, e.g. the Galaxy S22's.)
|
||||
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)
|
||||
|
||||
stage = os.path.join(BUILD, "apk")
|
||||
|
|
|
|||
|
|
@ -1,5 +1,9 @@
|
|||
package io.github.nexoniarz.fry3dx;
|
||||
|
||||
import android.os.Build;
|
||||
import android.view.WindowInsets;
|
||||
import android.view.WindowInsetsController;
|
||||
|
||||
import org.libsdl.app.SDLActivity;
|
||||
|
||||
// Fry3DX's activity: SDL's, loading SDL and the app (libmain.so, whose SDL_main it runs).
|
||||
|
|
@ -8,4 +12,40 @@ public class FryActivity extends SDLActivity {
|
|||
protected String[] getLibraries() {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 5.8 KiB After Width: | Height: | Size: 6.1 KiB |
|
Before Width: | Height: | Size: 3.6 KiB After Width: | Height: | Size: 3.5 KiB |
|
Before Width: | Height: | Size: 8 KiB After Width: | Height: | Size: 9.1 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 24 KiB |
BIN
branding/fry3dx.icns
Normal file
|
Before Width: | Height: | Size: 37 KiB After Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 3.1 KiB After Width: | Height: | Size: 5.2 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 35 KiB |
|
|
@ -1,34 +1,144 @@
|
|||
<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>
|
||||
<linearGradient id="bg" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0" stop-color="#2a2a2f"/><stop offset="1" stop-color="#151518"/>
|
||||
<!-- the tile -->
|
||||
<linearGradient id="tile" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0" stop-color="#3d3b38"/><stop offset="0.55" stop-color="#262523"/><stop offset="1" stop-color="#181716"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="pan" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0" stop-color="#3b3b42"/><stop offset="1" stop-color="#1d1d21"/>
|
||||
<linearGradient id="tileEdge" x1="0" y1="0" x2="0" 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.85" stop-color="#000" stop-opacity="0"/><stop offset="1" stop-color="#000" stop-opacity="0.55"/>
|
||||
</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">
|
||||
<stop offset="0" stop-color="#ff4a1c"/><stop offset="1" stop-color="#ffc24a"/>
|
||||
<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="1" stop-color="#ffe7a6" stop-opacity="0.9"/>
|
||||
</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>
|
||||
<rect x="16" y="16" width="480" height="480" rx="96" fill="url(#bg)"/>
|
||||
<!-- flames under the pan -->
|
||||
<path d="M164 462 c -34 -14 -30 -60 0 -96 c 30 36 34 82 0 96 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="M308 462 c -34 -14 -30 -60 0 -96 c 30 36 34 82 0 96 z" fill="url(#flame)"/>
|
||||
|
||||
<!-- 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 -->
|
||||
<g opacity="0.95">
|
||||
<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="M150 472 C 140 472, 140 448, 151 433 C 161 448, 160 472, 150 472 z" fill="#ffe9b0" opacity="0.85"/>
|
||||
<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 -->
|
||||
<path d="M372 300 L470 250" stroke="#ff7828" stroke-width="30" stroke-linecap="round"/>
|
||||
<!-- the pan, seen a little from above -->
|
||||
<ellipse cx="236" cy="318" rx="170" ry="72" fill="#ff7828"/>
|
||||
<ellipse cx="236" cy="306" rx="150" ry="58" fill="url(#pan)"/>
|
||||
<!-- the cube frying in it -->
|
||||
<g stroke="#1a1a1d" stroke-width="8" stroke-linejoin="round">
|
||||
<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 transform="translate(392 302) rotate(-31)">
|
||||
<rect x="-6" y="-13" width="48" height="26" rx="6" fill="url(#neck)"/>
|
||||
<circle cx="10" cy="0" r="4" fill="#d9d9de"/><circle cx="10" cy="0" r="4" fill="none" stroke="#1a1a1c" stroke-width="1.5"/>
|
||||
<rect x="34" y="-17" width="66" height="34" rx="17" fill="url(#grip)"/>
|
||||
<rect x="40" y="-13" width="52" height="7" rx="3.5" fill="#fff" opacity="0.38"/>
|
||||
<rect x="34" y="-17" width="66" height="34" rx="17" fill="none" stroke="#5a1d07" stroke-opacity="0.6" stroke-width="2"/>
|
||||
<circle cx="85" cy="0" r="6" fill="#3b1406"/><circle cx="85" cy="1.5" r="5" fill="#1a0a04"/>
|
||||
</g>
|
||||
<!-- sizzle -->
|
||||
<g stroke="#ffb070" stroke-width="10" stroke-linecap="round" fill="none" opacity="0.9">
|
||||
<path d="M100 210 q-14 -18 0 -36 q14 -18 0 -36"/>
|
||||
<path d="M378 200 q-14 -18 0 -36 q14 -18 0 -36"/>
|
||||
|
||||
<ellipse cx="242" cy="314" rx="180" ry="78" fill="url(#rim)"/>
|
||||
<ellipse cx="242" cy="314" rx="180" ry="78" fill="none" stroke="#fff" stroke-opacity="0.22" stroke-width="2"/>
|
||||
<ellipse cx="242" cy="318" rx="160" ry="64" fill="url(#panInside)"/>
|
||||
<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>
|
||||
<!-- 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>
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 9.1 KiB |
|
|
@ -37,6 +37,10 @@ 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
|
||||
URL_HASH SHA256=7576310b219e04159d35ff61dd4a4ec4cdba4f35c00e002a136f00e96a908b0a
|
||||
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.
|
||||
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)
|
||||
|
|
@ -79,13 +83,26 @@ target_link_libraries(volk PUBLIC Vulkan::Headers)
|
|||
|
||||
# Dear ImGui and YAPF have no CMake of their own; build them here.
|
||||
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
|
||||
${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_demo.cpp
|
||||
${imgui_SOURCE_DIR}/backends/imgui_impl_sdl3.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_compile_definitions(imgui PUBLIC IMGUI_IMPL_VULKAN_USE_VOLK)
|
||||
target_link_libraries(imgui PUBLIC volk SDL3::SDL3)
|
||||
|
|
@ -101,6 +118,24 @@ if(NOT MSVC)
|
|||
target_compile_options(yapf PRIVATE -O2) # its codecs and the BC7 encoder, even in debug builds
|
||||
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).
|
||||
set(OPUS_BUILD_TESTING OFF)
|
||||
set(OPUS_BUILD_PROGRAMS OFF)
|
||||
|
|
|
|||
42
cmake/Info.plist.in
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
<?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>
|
||||
81
cmake/Project.cmake
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
# 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()
|
||||
|
Before Width: | Height: | Size: 3.1 KiB After Width: | Height: | Size: 5.2 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 35 KiB |
13
content/Fry3DX/plugins/more_materials.lua
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
-- 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
|
||||
27
content/Fry3DX/plugins/scatter.lua
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
-- 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,
|
||||
}
|
||||
40
content/Fry3DX/plugins/stairs.lua
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
-- 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,
|
||||
}
|
||||
22
content/Fry3DX/plugins/stamp.lua
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
-- 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,
|
||||
}
|
||||
16
content/Fry3DX/scripts/box_with_lid.lua
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
-- 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()
|
||||
14
content/Fry3DX/scripts/gear.lua
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
-- 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()
|
||||
21
content/Fry3DX/scripts/little_town.lua
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
-- 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()
|
||||
15
content/Fry3DX/scripts/spiral_stairs.lua
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
-- 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()
|
||||
15
content/Fry3DX/scripts/vase.lua
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
-- 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()
|
||||
12
content/Fry3DX/scripts/waves.lua
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
-- 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()
|
||||
BIN
content/engine/fonts/InstrumentSans-Bold.ttf
Normal file
BIN
content/engine/fonts/InstrumentSans-Regular.ttf
Normal file
BIN
content/engine/fonts/InstrumentSans-SemiBold.ttf
Normal file
BIN
content/engine/fonts/JetBrainsMono-Regular.ttf
Normal file
93
content/engine/fonts/OFL-InstrumentSans.txt
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
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.
|
||||
|
|
@ -1,8 +1,9 @@
|
|||
Copyright (c) 2016 The Inter Project Authors (https://github.com/rsms/inter)
|
||||
Copyright 2020 The JetBrains Mono Project Authors (https://github.com/JetBrains/JetBrainsMono)
|
||||
|
||||
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:
|
||||
http://scripts.sil.org/OFL
|
||||
|
||||
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
|
||||
|
|
@ -43,7 +44,7 @@ new environment.
|
|||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION AND CONDITIONS
|
||||
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
|
||||
|
|
@ -7,7 +7,7 @@ window.FRY = {
|
|||
authorUrl: "https://github.com/Nexoniarz",
|
||||
repo: "https://github.com/Nexoniarz/Fry3DX",
|
||||
tagline: "A 3D modeller that does what you'd expect.",
|
||||
intro: "Pick a tool, click, drag. Plain words instead of jargon, a toolbox like GIMP's, and the useful stuff from the big modellers without the hundred menus around it. On Windows, 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, macOS, Linux and your phone.",
|
||||
releases: "https://github.com/Nexoniarz/Fry3DX/releases/latest",
|
||||
|
||||
why: {
|
||||
|
|
@ -18,31 +18,80 @@ 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: [
|
||||
{ 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." },
|
||||
{ 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." },
|
||||
{ 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." },
|
||||
{ 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." },
|
||||
{ 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." },
|
||||
{ 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." },
|
||||
{ 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." },
|
||||
{ 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." },
|
||||
{ icon: "⎙", title: "Made for 3D printing", text: "Your printer's plate in the view, sizes in millimetres, checks for holes and flipped faces with fix buttons, overhangs shown red, weight and filament." },
|
||||
{ icon: "☀", title: "See it rendered", text: "Solid view for modelling, a real renderer with sky, sun and soft shadows when you want to see it properly. Save a picture with F12." },
|
||||
{ icon: "▭", title: "Folders and groups", text: "Layers with folders inside folders, drag and drop, show/hide and lock whole groups, and a button on every row that brings the camera to it." },
|
||||
{ icon: "▯", title: "On your phone too", text: "Not a viewer: the whole modeller. Big buttons, one finger turns the view, two move and pinch to zoom, holding still is a right-click. Files through the phone's own picker." },
|
||||
{ icon: "⟲", title: "Never lose work", text: "Its own packed .fry file, autosave in the background, recovery after a crash, and a Save / Don't save / Cancel when closing." },
|
||||
{ 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." },
|
||||
{ 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." },
|
||||
{ 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." },
|
||||
{ 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." },
|
||||
{ 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." },
|
||||
{ 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." },
|
||||
{ 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)." },
|
||||
{ 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." },
|
||||
{ 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." },
|
||||
{ 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." },
|
||||
{ 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." },
|
||||
{ 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." },
|
||||
{ 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." },
|
||||
{ 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: [
|
||||
{ img: "img/model.webp", title: "Model", text: "Starter models, a folder of parts, a shape drawn onto a cube's face, rendered with sky and shadows." },
|
||||
{ img: "img/paint.webp", title: "Paint", text: "A picture pasted on as a sticker: it bends over the cube's edge like a real one." },
|
||||
{ img: "img/print.webp", title: "Print", text: "The printer's plate, sizes in millimetres, checks and overhangs before you slice." },
|
||||
{ img: "img/phone.webp", title: "Phone", text: "The phone layout: big buttons along the bottom, panels when you want them, fingers for the view." },
|
||||
{ img: "img/combine.webp", title: "Combine", text: "A cylinder taken out of a cube, copies in a row in a folder, a plane made thick." },
|
||||
{ img: "img/crash.webp", title: "Even the crash screen", text: "If something does get burnt, your work comes back from the last autosave." },
|
||||
{ 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/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/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/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/layout.webp", title: "Layout", text: "The box's surface laid out flat: move, turn and size the frames, and the paint follows." },
|
||||
{ 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/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: {
|
||||
open: [".fry"],
|
||||
save: [".fry (packed, with pictures)"],
|
||||
|
|
@ -63,6 +112,7 @@ window.FRY = {
|
|||
|
||||
downloads: [
|
||||
{ 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: "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)." },
|
||||
],
|
||||
|
|
@ -75,10 +125,14 @@ window.FRY = {
|
|||
"cmake --preset release && cmake --build build/release",
|
||||
"build/release/bin/fry3dx",
|
||||
],
|
||||
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.",
|
||||
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.",
|
||||
},
|
||||
|
||||
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.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"] },
|
||||
|
|
|
|||
BIN
docs/fonts/InstrumentSans-Italic.woff2
Normal file
BIN
docs/fonts/InstrumentSans.woff2
Normal file
BIN
docs/fonts/JetBrainsMono.woff2
Normal file
93
docs/fonts/OFL-InstrumentSans.txt
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
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.
|
||||
|
Before Width: | Height: | Size: 52 KiB After Width: | Height: | Size: 104 KiB |
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 3.1 KiB After Width: | Height: | Size: 5.2 KiB |
BIN
docs/img/layout.webp
Normal file
|
After Width: | Height: | Size: 85 KiB |
BIN
docs/img/light.webp
Normal file
|
After Width: | Height: | Size: 102 KiB |
BIN
docs/img/logo.png
Normal file
|
After Width: | Height: | Size: 90 KiB |
|
|
@ -1,34 +1,144 @@
|
|||
<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>
|
||||
<linearGradient id="bg" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0" stop-color="#2a2a2f"/><stop offset="1" stop-color="#151518"/>
|
||||
<!-- the tile -->
|
||||
<linearGradient id="tile" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0" stop-color="#3d3b38"/><stop offset="0.55" stop-color="#262523"/><stop offset="1" stop-color="#181716"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="pan" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0" stop-color="#3b3b42"/><stop offset="1" stop-color="#1d1d21"/>
|
||||
<linearGradient id="tileEdge" x1="0" y1="0" x2="0" 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.85" stop-color="#000" stop-opacity="0"/><stop offset="1" stop-color="#000" stop-opacity="0.55"/>
|
||||
</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">
|
||||
<stop offset="0" stop-color="#ff4a1c"/><stop offset="1" stop-color="#ffc24a"/>
|
||||
<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="1" stop-color="#ffe7a6" stop-opacity="0.9"/>
|
||||
</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>
|
||||
<rect x="16" y="16" width="480" height="480" rx="96" fill="url(#bg)"/>
|
||||
<!-- flames under the pan -->
|
||||
<path d="M164 462 c -34 -14 -30 -60 0 -96 c 30 36 34 82 0 96 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="M308 462 c -34 -14 -30 -60 0 -96 c 30 36 34 82 0 96 z" fill="url(#flame)"/>
|
||||
|
||||
<!-- 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 -->
|
||||
<g opacity="0.95">
|
||||
<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="M150 472 C 140 472, 140 448, 151 433 C 161 448, 160 472, 150 472 z" fill="#ffe9b0" opacity="0.85"/>
|
||||
<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 -->
|
||||
<path d="M372 300 L470 250" stroke="#ff7828" stroke-width="30" stroke-linecap="round"/>
|
||||
<!-- the pan, seen a little from above -->
|
||||
<ellipse cx="236" cy="318" rx="170" ry="72" fill="#ff7828"/>
|
||||
<ellipse cx="236" cy="306" rx="150" ry="58" fill="url(#pan)"/>
|
||||
<!-- the cube frying in it -->
|
||||
<g stroke="#1a1a1d" stroke-width="8" stroke-linejoin="round">
|
||||
<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 transform="translate(392 302) rotate(-31)">
|
||||
<rect x="-6" y="-13" width="48" height="26" rx="6" fill="url(#neck)"/>
|
||||
<circle cx="10" cy="0" r="4" fill="#d9d9de"/><circle cx="10" cy="0" r="4" fill="none" stroke="#1a1a1c" stroke-width="1.5"/>
|
||||
<rect x="34" y="-17" width="66" height="34" rx="17" fill="url(#grip)"/>
|
||||
<rect x="40" y="-13" width="52" height="7" rx="3.5" fill="#fff" opacity="0.38"/>
|
||||
<rect x="34" y="-17" width="66" height="34" rx="17" fill="none" stroke="#5a1d07" stroke-opacity="0.6" stroke-width="2"/>
|
||||
<circle cx="85" cy="0" r="6" fill="#3b1406"/><circle cx="85" cy="1.5" r="5" fill="#1a0a04"/>
|
||||
</g>
|
||||
<!-- sizzle -->
|
||||
<g stroke="#ffb070" stroke-width="10" stroke-linecap="round" fill="none" opacity="0.9">
|
||||
<path d="M100 210 q-14 -18 0 -36 q14 -18 0 -36"/>
|
||||
<path d="M378 200 q-14 -18 0 -36 q14 -18 0 -36"/>
|
||||
|
||||
<ellipse cx="242" cy="314" rx="180" ry="78" fill="url(#rim)"/>
|
||||
<ellipse cx="242" cy="314" rx="180" ry="78" fill="none" stroke="#fff" stroke-opacity="0.22" stroke-width="2"/>
|
||||
<ellipse cx="242" cy="318" rx="160" ry="64" fill="url(#panInside)"/>
|
||||
<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>
|
||||
<!-- 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>
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 9.1 KiB |
BIN
docs/img/materials.webp
Normal file
|
After Width: | Height: | Size: 52 KiB |
|
Before Width: | Height: | Size: 49 KiB After Width: | Height: | Size: 66 KiB |
|
Before Width: | Height: | Size: 46 KiB After Width: | Height: | Size: 70 KiB |
|
Before Width: | Height: | Size: 43 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 109 KiB After Width: | Height: | Size: 99 KiB |
BIN
docs/img/render.webp
Normal file
|
After Width: | Height: | Size: 46 KiB |
BIN
docs/img/scripts.webp
Normal file
|
After Width: | Height: | Size: 81 KiB |
BIN
docs/img/sculpt.webp
Normal file
|
After Width: | Height: | Size: 56 KiB |
195
docs/index.html
|
|
@ -9,76 +9,163 @@
|
|||
<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">
|
||||
<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>
|
||||
<body>
|
||||
<!-- The page is built from content.js by site.js: edit content.js to update it. -->
|
||||
<header class="top">
|
||||
<a class="brand" href="#"><img src="img/logo.svg" alt=""><span>Fry3DX</span></a>
|
||||
<nav>
|
||||
<a href="#features">Features</a>
|
||||
<a href="#look">Look</a>
|
||||
<a href="#get">Get it</a>
|
||||
<a href="#versions">Versions</a>
|
||||
<a class="gh" id="gh-top" href="#">GitHub</a>
|
||||
</nav>
|
||||
</header>
|
||||
<nav class="nv-nav" id="top-nav" aria-label="Main">
|
||||
<a class="nv-nav__brand" href="#top"><img class="brand-logo" src="img/logo.svg" alt=""> Fry3DX</a>
|
||||
<ul class="nv-nav__list nav-links" id="nav-list" style="--nv-selection-name: nv-nav-selection">
|
||||
<li><a class="nv-nav__item" href="#features">Features</a></li>
|
||||
<li><a class="nv-nav__item" href="#look">Look</a></li>
|
||||
<li><a class="nv-nav__item" href="#scripting">Scripting</a></li>
|
||||
<li><a class="nv-nav__item" href="#get">Get it</a></li>
|
||||
<li><a class="nv-nav__item" href="#versions">Versions</a></li>
|
||||
</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>
|
||||
|
||||
<main>
|
||||
<section class="hero">
|
||||
<div class="hero-text">
|
||||
<p class="kicker" id="long"></p>
|
||||
<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>
|
||||
<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>
|
||||
<figure class="hero-shot"><img src="img/model.webp" alt="Fry3DX with a table, chair, barrel and crate"></figure>
|
||||
</section>
|
||||
</header>
|
||||
|
||||
<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>
|
||||
<section class="shell section">
|
||||
<div class="nv-card nv-card--inset why">
|
||||
<div class="nv-card__body nv-stack">
|
||||
<h2 class="nv-heading" id="why-title"></h2>
|
||||
<div class="nv-prose nv-stack" id="why-text"></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="get">
|
||||
<h2>Get it</h2>
|
||||
<section class="shell section" id="features">
|
||||
<div class="section__head">
|
||||
<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>
|
||||
<p id="build-text"></p>
|
||||
<div class="code"><button class="copy" id="copy">Copy</button><pre id="build-steps"></pre></div>
|
||||
<p class="muted" id="build-needs"></p>
|
||||
<div class="nv-card nv-card--inset build">
|
||||
<div class="nv-card__body nv-stack">
|
||||
<p class="nv-body" id="build-text"></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 id="versions">
|
||||
<h2>Versions</h2>
|
||||
<div id="version-list"></div>
|
||||
<section class="shell section" id="versions">
|
||||
<div class="section__head">
|
||||
<span class="nv-overline">Versions</span>
|
||||
<h2 class="nv-heading">What's new</h2>
|
||||
</div>
|
||||
<div class="nv-disclosure-group" id="version-list"></div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
<p>Fry3DX, made by <a id="author" href="#"></a>.</p>
|
||||
<p class="muted">Open source under the Apache License 2.0 · Sky pictures from Poly Haven (CC0) · Fonts: Inter, JetBrains Mono (OFL).</p>
|
||||
<footer class="shell footer">
|
||||
<img class="footer__logo" src="img/logo.svg" alt="">
|
||||
<div class="nv-stack nv-stack--tight">
|
||||
<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>
|
||||
|
||||
<script src="content.js"></script>
|
||||
|
|
|
|||
4163
docs/niveus/niveus.css
Normal file
353
docs/niveus/niveus.js
Normal file
|
|
@ -0,0 +1,353 @@
|
|||
/* ==========================================================================
|
||||
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);
|
||||
148
docs/site.css
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
/* 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); }
|
||||
245
docs/site.js
|
|
@ -9,91 +9,220 @@
|
|||
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;
|
||||
$("tagline").textContent = F.tagline;
|
||||
$("intro").textContent = F.intro;
|
||||
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").href = F.authorUrl;
|
||||
|
||||
const why = $("why");
|
||||
why.append(el("h2", null, F.why.title));
|
||||
for (const p of F.why.text) why.append(el("p", null, p));
|
||||
$("why-title").textContent = F.why.title;
|
||||
for (const p of F.why.text) $("why-text").append(el("p", "nv-body", p));
|
||||
|
||||
const grid = $("feature-grid");
|
||||
for (const f of F.features) {
|
||||
const card = el("article", "card");
|
||||
card.append(el("div", "icon", f.icon), el("h3", null, f.title), el("p", null, f.text));
|
||||
grid.append(card);
|
||||
// ── Features: a tab each group, the cards in its panel ──
|
||||
F.featureGroups.forEach((g, i) => {
|
||||
const tab = el("button", "nv-nav__item", g.title);
|
||||
tab.id = `feature-tab-${g.id}`;
|
||||
tab.tabIndex = i ? -1 : 0;
|
||||
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 ──
|
||||
F.shots.forEach((s, i) => {
|
||||
const tab = el("button", "nv-nav__item", s.title);
|
||||
Object.assign(tab, { id: `shot-tab-${i}`, role: "tab", tabIndex: i ? -1 : 0 });
|
||||
tab.setAttribute("role", "tab");
|
||||
tab.setAttribute("aria-controls", `shot-${i}`);
|
||||
tab.setAttribute("aria-selected", String(i === 0));
|
||||
$("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);
|
||||
});
|
||||
|
||||
// ── Scripting ──
|
||||
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);
|
||||
}
|
||||
|
||||
// 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) => {
|
||||
const b = el("button", "tab", s.title);
|
||||
b.setAttribute("role", "tab");
|
||||
b.onclick = () => show(i);
|
||||
tabs.append(b);
|
||||
new Image().src = s.img; // (loaded ahead, so switching is instant)
|
||||
});
|
||||
show(0);
|
||||
|
||||
const fm = $("formats");
|
||||
// ── Files and keys ──
|
||||
const row = (label, items) => {
|
||||
const d = el("div", "fmt");
|
||||
d.append(el("span", "fmt-label", label));
|
||||
for (const x of items) d.append(el("span", "chip", x));
|
||||
fm.append(d);
|
||||
d.append(el("span", "nv-overline", label));
|
||||
const c = el("div", "nv-cluster nv-cluster--tight");
|
||||
for (const x of items) c.append(el("span", "nv-badge", x));
|
||||
d.append(c);
|
||||
$("formats").append(d);
|
||||
};
|
||||
row("Save", F.formats.save);
|
||||
row("Export", F.formats.export);
|
||||
fm.append(el("p", "muted", F.formats.note));
|
||||
|
||||
const keys = $("keys");
|
||||
$("formats").append(el("p", "nv-caption nv-text-tertiary", F.formats.note));
|
||||
for (const [k, what] of F.keys) {
|
||||
const tr = el("tr");
|
||||
const td = el("td");
|
||||
td.append(el("kbd", null, k));
|
||||
tr.append(td, el("td", null, what));
|
||||
keys.append(tr);
|
||||
tr.append(td, el("td", "nv-body", what));
|
||||
$("keys").append(tr);
|
||||
}
|
||||
|
||||
const dl = $("downloads");
|
||||
// ── Downloads and building ──
|
||||
for (const d of F.downloads) {
|
||||
const a = el("a", "download");
|
||||
const a = el("a", "nv-card nv-card--interactive download");
|
||||
a.href = F.releases;
|
||||
a.append(el("strong", null, d.os), el("span", "muted", d.what), el("span", "file", "Fry3DX-…-" + d.file), el("span", null, d.how));
|
||||
dl.append(a);
|
||||
const b = el("div", "nv-card__body");
|
||||
b.append(el("h3", "nv-card__title", d.os), el("p", "nv-caption nv-text-tertiary", d.what),
|
||||
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-steps").textContent = F.build.steps.join("\n");
|
||||
$("build-needs").textContent = F.build.needs;
|
||||
$("copy").onclick = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(F.build.steps.join("\n"));
|
||||
$("copy").textContent = "Copied";
|
||||
} catch {
|
||||
$("copy").textContent = "Select and copy";
|
||||
}
|
||||
setTimeout(() => ($("copy").textContent = "Copy"), 1800);
|
||||
};
|
||||
for (const btn of document.querySelectorAll("[data-copy]")) {
|
||||
btn.onclick = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText($(btn.dataset.copy).textContent);
|
||||
btn.textContent = "Copied";
|
||||
} catch {
|
||||
btn.textContent = "Select and copy";
|
||||
}
|
||||
setTimeout(() => (btn.textContent = "Copy"), 1800);
|
||||
};
|
||||
}
|
||||
|
||||
const vl = $("version-list");
|
||||
for (const v of F.versions) {
|
||||
const d = el("div", "version");
|
||||
const h = el("h3");
|
||||
h.append(el("span", "v", "v" + v.v), el("span", "muted", " " + v.date));
|
||||
const ul = el("ul");
|
||||
for (const it of v.items) ul.append(el("li", null, it));
|
||||
d.append(h, ul);
|
||||
vl.append(d);
|
||||
// ── Versions ──
|
||||
F.versions.forEach((v, i) => {
|
||||
const d = el("details", "nv-disclosure");
|
||||
d.open = i === 0;
|
||||
const s = el("summary", "nv-disclosure__summary");
|
||||
s.append(el("span", "nv-title", "v" + v.v), el("span", "nv-caption nv-text-tertiary", " " + v.date));
|
||||
const ch = el("span", "nv-disclosure__chevron");
|
||||
ch.setAttribute("aria-hidden", "true");
|
||||
s.append(ch);
|
||||
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, "&").replace(/</g, "<");
|
||||
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
|
|
@ -1,111 +0,0 @@
|
|||
/* 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; }
|
||||
}
|
||||
|
|
@ -31,7 +31,9 @@ Cvar::~Cvar() { std::erase(cvars(), this); }
|
|||
|
||||
void Cvar::set(std::string_view value) {
|
||||
value_ = value;
|
||||
#if defined(__ANDROID__) // (the NDK's library has no float from_chars; bionic's locale is always "C")
|
||||
// (The NDK's library, and Apple's before macOS 26, have no float from_chars. strtof reads the
|
||||
// "C" locale's way: the program never sets another.)
|
||||
#if defined(__ANDROID__) || defined(__APPLE__)
|
||||
const std::string text(value);
|
||||
char* end = nullptr;
|
||||
const float parsed = std::strtof(text.c_str(), &end);
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <thread>
|
||||
|
||||
|
|
@ -15,6 +16,11 @@
|
|||
#include <sys/utsname.h>
|
||||
#include <unistd.h>
|
||||
#endif
|
||||
#if defined(__APPLE__)
|
||||
#include <mach-o/dyld.h>
|
||||
#include <mach/mach.h>
|
||||
#include <sys/sysctl.h>
|
||||
#endif
|
||||
#if defined(_MSC_VER)
|
||||
#include <intrin.h>
|
||||
#elif defined(__x86_64__) || defined(__i386__)
|
||||
|
|
@ -30,6 +36,14 @@ std::string trim(std::string s) {
|
|||
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).
|
||||
std::string cpuName() {
|
||||
#if defined(_MSC_VER) || defined(__x86_64__) || defined(__i386__)
|
||||
|
|
@ -46,6 +60,9 @@ std::string cpuName() {
|
|||
char brand[49] = {};
|
||||
std::memcpy(brand, regs, 48);
|
||||
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
|
||||
std::ifstream in("/proc/cpuinfo");
|
||||
for (std::string line; std::getline(in, line);)
|
||||
|
|
@ -64,6 +81,11 @@ std::string osName() {
|
|||
fn(&v);
|
||||
const char* name = v.dwMajorVersion == 10 ? (v.dwBuildNumber >= 22000 ? "Windows 11" : "Windows 10") : "Windows";
|
||||
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
|
||||
utsname u{};
|
||||
uname(&u);
|
||||
|
|
@ -95,6 +117,19 @@ Memory memory() {
|
|||
if (GlobalMemoryStatusEx(&s)) m.total = s.ullTotalPhys, m.used = s.ullTotalPhys - s.ullAvailPhys;
|
||||
PROCESS_MEMORY_COUNTERS pmc{sizeof pmc};
|
||||
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
|
||||
uint64_t available = 0;
|
||||
std::ifstream in("/proc/meminfo");
|
||||
|
|
@ -114,4 +149,39 @@ Memory memory() {
|
|||
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
|
||||
|
|
|
|||
|
|
@ -18,4 +18,9 @@ struct 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
|
||||
|
|
|
|||
345
engine/crash.cpp
|
|
@ -1,6 +1,14 @@
|
|||
#include "crash.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>
|
||||
|
||||
|
|
@ -122,17 +130,6 @@ void onSignal(int sig) {
|
|||
}
|
||||
#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() {
|
||||
constexpr double kTimeout = 45.0;
|
||||
for (;;) {
|
||||
|
|
@ -141,7 +138,7 @@ void watchdog() {
|
|||
g_beat = seconds();
|
||||
continue;
|
||||
}
|
||||
if (seconds() - g_beat < kTimeout || debuggerAttached()) continue;
|
||||
if (seconds() - g_beat < kTimeout || sys::debuggerAttached()) continue;
|
||||
#ifndef _WIN32
|
||||
pthread_kill(g_main, SIGUSR2); // its handler reports from the stuck thread (its backtrace)
|
||||
std::this_thread::sleep_for(std::chrono::seconds(3));
|
||||
|
|
@ -153,12 +150,8 @@ void watchdog() {
|
|||
|
||||
void install(const std::string& prefPath, const std::vector<std::string>& args, bool dog) {
|
||||
g_report = prefPath + "crash.txt";
|
||||
#ifdef _WIN32
|
||||
char exe[1024] = {};
|
||||
GetModuleFileNameA(nullptr, exe, sizeof exe);
|
||||
g_exe = exe;
|
||||
#else
|
||||
g_exe = std::filesystem::read_symlink("/proc/self/exe").string();
|
||||
g_exe = sys::executablePath();
|
||||
#ifndef _WIN32
|
||||
g_main = pthread_self();
|
||||
#endif
|
||||
g_args = {g_exe, "-crashreport", g_report, "--"};
|
||||
|
|
@ -204,6 +197,9 @@ Busy::~Busy() {
|
|||
}
|
||||
|
||||
// ── 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) {
|
||||
if (argc < 3 || std::strcmp(argv[1], "-crashreport") != 0) return -1;
|
||||
std::string report;
|
||||
|
|
@ -222,173 +218,200 @@ int runReporter(int argc, char** argv) {
|
|||
for (std::string l; std::getline(ss, l);) lines.push_back(l);
|
||||
}
|
||||
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;
|
||||
SDL_Window* win = nullptr;
|
||||
SDL_Renderer* ren = nullptr;
|
||||
if (!SDL_CreateWindowAndRenderer("Fry3DX - something got burnt", 1000, 620, SDL_WINDOW_RESIZABLE | SDL_WINDOW_HIGH_PIXEL_DENSITY, &win, &ren)) return 1;
|
||||
if (!SDL_CreateWindowAndRenderer("Fry3DX - something got burnt", 940, 640, SDL_WINDOW_RESIZABLE | SDL_WINDOW_HIGH_PIXEL_DENSITY, &win, &ren)) return 1;
|
||||
SDL_SetRenderVSync(ren, 1);
|
||||
SDL_SetRenderDrawBlendMode(ren, SDL_BLENDMODE_BLEND);
|
||||
auto text = [&](float x, float y, float scale, const std::string& s, SDL_Color c) {
|
||||
SDL_SetRenderScale(ren, scale, scale);
|
||||
SDL_SetRenderDrawColor(ren, c.r, c.g, c.b, c.a);
|
||||
SDL_RenderDebugText(ren, x / scale, y / scale, s.c_str());
|
||||
SDL_SetRenderScale(ren, 1, 1);
|
||||
};
|
||||
// Shapes for the picture: an ellipse (filled), a box.
|
||||
auto ellipse = [&](float cx, float cy, float rx, float ry, SDL_FColor c) {
|
||||
std::vector<SDL_Vertex> v;
|
||||
const int n = 48;
|
||||
for (int i = 0; i < n; ++i) {
|
||||
const float a0 = 6.2831853f * i / n, a1 = 6.2831853f * (i + 1) / n;
|
||||
v.push_back({{cx, cy}, c, {}});
|
||||
v.push_back({{cx + std::cos(a0) * rx, cy + std::sin(a0) * ry}, c, {}});
|
||||
v.push_back({{cx + std::cos(a1) * rx, cy + std::sin(a1) * ry}, c, {}});
|
||||
const char* base = SDL_GetBasePath();
|
||||
const std::string exeDir = base ? base : "./";
|
||||
fs::mountGameDir(exeDir + "engine"); // (the fonts)
|
||||
fs::mountGameDir(exeDir + "Fry3DX"); // (the logo)
|
||||
|
||||
IMGUI_CHECKVERSION();
|
||||
ImGui::CreateContext();
|
||||
ImGui::GetIO().IniFilename = nullptr;
|
||||
ImGui_ImplSDL3_InitForSDLRenderer(win, ren);
|
||||
ImGui_ImplSDLRenderer3_Init(ren);
|
||||
ui::init();
|
||||
const float density = SDL_GetWindowDisplayScale(win);
|
||||
ImGui::GetStyle().ScaleAllSizes(density);
|
||||
ImGui::GetStyle().FontScaleDpi = density;
|
||||
|
||||
SDL_Texture* logo = nullptr;
|
||||
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;
|
||||
};
|
||||
const SDL_Color ink{236, 232, 226, 255}, dim{150, 146, 140, 255}, orange{255, 140, 60, 255};
|
||||
int scroll = 0, result = 0;
|
||||
}
|
||||
|
||||
using ui::Role;
|
||||
auto col = [](Role r, float a = 1.0f) { return ImGui::ColorConvertU32ToFloat4(ui::color(r, a)); };
|
||||
std::string toast;
|
||||
double toastUntil = 0;
|
||||
int frame = 0, choice = -1;
|
||||
for (bool running = true; running;) {
|
||||
int w = 0, h = 0;
|
||||
SDL_GetRenderOutputSize(ren, &w, &h);
|
||||
const float s = std::min(h / 620.0f, w / 1000.0f);
|
||||
const double t = SDL_GetTicks() / 1000.0;
|
||||
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;
|
||||
float mx = 0, my = 0;
|
||||
SDL_GetMouseState(&mx, &my);
|
||||
float px = 1, py = 1; // window → pixels
|
||||
{
|
||||
int ww, wh;
|
||||
SDL_GetWindowSize(win, &ww, &wh);
|
||||
px = float(w) / ww, py = float(h) / wh;
|
||||
}
|
||||
mx *= px, my *= py;
|
||||
int clicked = -1;
|
||||
for (SDL_Event e; SDL_PollEvent(&e);) {
|
||||
ImGui_ImplSDL3_ProcessEvent(&e);
|
||||
if (e.type == SDL_EVENT_QUIT) running = false;
|
||||
if (e.type == SDL_EVENT_KEY_DOWN) {
|
||||
if (e.key.key == SDLK_ESCAPE || e.key.key == SDLK_Q) running = false;
|
||||
if (e.key.key == SDLK_R) clicked = 0;
|
||||
if (e.key.key == SDLK_C) clicked = 1;
|
||||
}
|
||||
if (e.type == SDL_EVENT_MOUSE_WHEEL) scroll = std::max(0, scroll - int(e.wheel.y * 3));
|
||||
if (e.type == SDL_EVENT_MOUSE_BUTTON_DOWN && e.button.button == SDL_BUTTON_LEFT)
|
||||
for (int i = 0; i < 4; ++i)
|
||||
if (mx >= buttons[i].x && mx < buttons[i].x + bw && my >= by && my < by + bh) clicked = i;
|
||||
}
|
||||
if (clicked == 0) { // opened again as it was started (it offers the autosave back)
|
||||
std::vector<char*> a;
|
||||
std::string exe = argv[0];
|
||||
a.push_back(exe.data());
|
||||
for (std::string& r : restart) a.push_back(r.data());
|
||||
a.push_back(nullptr);
|
||||
SDL_DestroyRenderer(ren);
|
||||
SDL_DestroyWindow(win);
|
||||
SDL_Quit();
|
||||
#ifdef _WIN32
|
||||
_spawnv(_P_NOWAIT, exe.c_str(), a.data());
|
||||
return 0;
|
||||
#else
|
||||
execv(std::filesystem::read_symlink("/proc/self/exe").c_str(), a.data());
|
||||
return 1;
|
||||
#endif
|
||||
}
|
||||
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;
|
||||
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 float u = density; // (one point)
|
||||
|
||||
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 ImGuiViewport* vp = ImGui::GetMainViewport();
|
||||
ImGui::SetNextWindowPos(vp->Pos);
|
||||
ImGui::SetNextWindowSize(vp->Size);
|
||||
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 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))));
|
||||
}
|
||||
const ImVec2 a = ImGui::GetCursorScreenPos();
|
||||
const float w = ImGui::GetContentRegionAvail().x, h = ImGui::GetTextLineHeight() + 26 * u;
|
||||
dl->AddRectFilled(a, ImVec2(a.x + w, a.y + h), ui::color(Role::Success, 0.14f), 8 * u);
|
||||
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));
|
||||
}
|
||||
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);
|
||||
|
||||
// The report: what was going on when it happened, for a bug report.
|
||||
ImGui::PushStyleColor(ImGuiCol_Text, col(Role::TextTertiary));
|
||||
ImGui::PushTextWrapPos(0.0f);
|
||||
ImGui::Text("The report, saved to %s", argv[2]);
|
||||
ImGui::PopTextWrapPos();
|
||||
ImGui::PopStyleColor();
|
||||
const float buttonsH = ImGui::GetFrameHeight() + 26 * u + ImGui::GetTextLineHeight();
|
||||
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;
|
||||
}
|
||||
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
|
||||
ImGui::PushStyleColor(ImGuiCol_Text, col(t < toastUntil ? Role::AccentText : Role::TextTertiary));
|
||||
ImGui::TextUnformatted(t < toastUntil ? toast.c_str() : "R open it again C copy Esc close");
|
||||
ImGui::PopStyleColor();
|
||||
ImGui::End();
|
||||
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;
|
||||
}
|
||||
|
||||
ImGui::Render();
|
||||
const ImVec4 bg = col(Role::Background);
|
||||
SDL_SetRenderDrawColorFloat(ren, bg.x, bg.y, bg.z, 1.0f);
|
||||
SDL_RenderClear(ren);
|
||||
ImGui_ImplSDLRenderer3_RenderDrawData(ImGui::GetDrawData(), ren);
|
||||
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_DestroyWindow(win);
|
||||
SDL_Quit();
|
||||
return result;
|
||||
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
|
||||
_spawnv(_P_NOWAIT, exe.c_str(), a.data());
|
||||
return 0;
|
||||
#else
|
||||
execv(exe.c_str(), a.data());
|
||||
return 1;
|
||||
#endif
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
} // namespace keel::crash
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
#include "core/console.h"
|
||||
#include "core/cvar.h"
|
||||
#include "core/log.h"
|
||||
#include "core/sysinfo.h"
|
||||
#include "core/time.h"
|
||||
#include "fs/vfs.h"
|
||||
#include "platform/input.h"
|
||||
|
|
@ -289,6 +290,10 @@ int runClient(Game& game) {
|
|||
#if defined(__ANDROID__)
|
||||
if (first != RenderApi::Vulkan) first = 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
|
||||
const std::initializer_list<RenderApi> all{RenderApi::Vulkan, RenderApi::OpenGL, RenderApi::OpenGL40, RenderApi::OpenGL31, RenderApi::GLES30};
|
||||
#endif
|
||||
|
|
@ -356,16 +361,26 @@ int runClient(Game& game) {
|
|||
else game.onEscape();
|
||||
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.
|
||||
if (ev.type == SDL_EVENT_TEXT_INPUT && ev.text.text[0] == '`') continue;
|
||||
input::handleEvent(ev);
|
||||
if (!input::captured()) ImGui_ImplSDL3_ProcessEvent(&ev);
|
||||
if (ev.type == SDL_EVENT_QUIT && game.allowQuit()) g_quit = true;
|
||||
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;
|
||||
audio::setFocused(focused);
|
||||
if (vid_mode.consumeModified() | vid_width.consumeModified() | vid_height.consumeModified())
|
||||
const bool mode = vid_mode.consumeModified(), width = vid_width.consumeModified(), height = vid_height.consumeModified(); // (each taken, none skipped)
|
||||
if (mode || width || height)
|
||||
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
|
||||
// the GPU and make coil whine (often heard in headphones on the onboard jack).
|
||||
|
|
@ -443,20 +458,17 @@ const char* buildVersion() { return KEEL_COMMIT ", " KEEL_COMMIT_DATE; }
|
|||
// Replaces this process with a fresh one (Windows: starts it, then this one exits).
|
||||
void relaunch(const std::vector<std::string>& args) {
|
||||
std::vector<std::string> a = args;
|
||||
const std::string exe = sys::executablePath();
|
||||
#ifdef _WIN32
|
||||
char exe[1024] = {};
|
||||
GetModuleFileNameA(nullptr, exe, sizeof exe);
|
||||
for (std::string& x : a)
|
||||
if (x.find(' ') != std::string::npos) x = "\"" + x + "\"";
|
||||
#else
|
||||
const std::string exe = std::filesystem::read_symlink("/proc/self/exe").string();
|
||||
#endif
|
||||
std::vector<char*> argv;
|
||||
for (std::string& x : a) argv.push_back(x.data());
|
||||
argv.push_back(nullptr);
|
||||
log::info("Restarting");
|
||||
#ifdef _WIN32
|
||||
_spawnv(_P_NOWAIT, exe, argv.data());
|
||||
_spawnv(_P_NOWAIT, exe.c_str(), argv.data());
|
||||
#else
|
||||
execv(exe.c_str(), argv.data());
|
||||
log::error("Restart failed");
|
||||
|
|
|
|||
|
|
@ -33,6 +33,12 @@ public:
|
|||
// 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.
|
||||
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
|
||||
// (`changed`: textures, models, sounds); the game reloads its own (scripts, the map).
|
||||
virtual void hotReload(const std::vector<std::string>& changed) { (void)changed; }
|
||||
|
|
|
|||
|
|
@ -21,7 +21,14 @@ namespace {
|
|||
// 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.
|
||||
void preferDedicatedGpu() {
|
||||
#ifndef _WIN32
|
||||
#if defined(__APPLE__)
|
||||
// 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::filesystem::exists("/proc/driver/nvidia")) { // NVIDIA's own driver: PRIME render offload
|
||||
setenv("__NV_PRIME_RENDER_OFFLOAD", "1", 0);
|
||||
|
|
@ -40,7 +47,11 @@ bool Window::init(const char* title, int width, int height, bool opengl) {
|
|||
log::error("SDL_Init failed: {}", SDL_GetError());
|
||||
return false;
|
||||
}
|
||||
window_ = SDL_CreateWindow(title, width, height, (opengl ? SDL_WINDOW_OPENGL : SDL_WINDOW_VULKAN) | SDL_WINDOW_RESIZABLE | SDL_WINDOW_HIGH_PIXEL_DENSITY);
|
||||
SDL_WindowFlags flags = (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_) {
|
||||
log::error("SDL_CreateWindow failed: {}", SDL_GetError());
|
||||
return false;
|
||||
|
|
|
|||
|
|
@ -14,8 +14,20 @@
|
|||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
struct ImDrawList;
|
||||
struct ImDrawCmd;
|
||||
|
||||
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;
|
||||
using TextureId = uint32_t;
|
||||
|
||||
|
|
@ -53,7 +65,8 @@ struct DrawData { // DRAW_FIELDS in model.glsl
|
|||
glm::vec4 cube[6];
|
||||
glm::vec4 params; // x = sun visibility, y = lit by probes, z = liquid cut, w = LiquidStyle
|
||||
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
|
||||
glm::mat4 cascades[4];
|
||||
|
|
@ -205,6 +218,8 @@ public:
|
|||
virtual void beginUi() = 0;
|
||||
virtual void setVsync(bool on) = 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
|
||||
// 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,
|
||||
|
|
@ -216,6 +231,7 @@ public:
|
|||
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).
|
||||
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.
|
||||
virtual bool beginFrame(uint32_t& width, uint32_t& height) = 0;
|
||||
// Renders the packet, then ImGui's draw data, and presents.
|
||||
|
|
|
|||
|
|
@ -100,7 +100,7 @@ glm::mat4 viewProjection(const Camera& c, float aspect) {
|
|||
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));
|
||||
glm::mat4 view = glm::lookAt(c.origin, c.origin + forward, up);
|
||||
glm::mat4 proj = glm::perspective(glm::radians(c.fovY), aspect, 2.0f, 16384.0f);
|
||||
glm::mat4 proj = glm::perspective(glm::radians(c.fovY), aspect, c.zNear, c.zFar);
|
||||
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;
|
||||
return proj * view;
|
||||
|
|
@ -211,18 +211,32 @@ 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)});
|
||||
}
|
||||
|
||||
void Renderer::drawStreak(const glm::vec3& from, const glm::vec3& to, float width, const glm::vec4& color, TextureId texture, bool additive) {
|
||||
void Renderer::drawGrid(const glm::dvec3& under, const glm::vec3& u, const glm::vec3& v, float metre, bool dark, float reach) {
|
||||
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;
|
||||
if (list.size() >= 8192) return;
|
||||
uint32_t bits = texture;
|
||||
float tex;
|
||||
std::memcpy(&tex, &bits, 4);
|
||||
// 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), glm::vec4((to - from) * 0.5f, tex)});
|
||||
// up.xyz: half its length along it (non-zero marks a streak); right.w: half its width at `to`.
|
||||
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)});
|
||||
}
|
||||
|
||||
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 ModelLighting* lighting, bool castShadow, const glm::vec4* liquidPlane, LiquidStyle liquid, const Surface* surface) {
|
||||
if (!model.valid() || packet_.draws.size() >= kMaxDraws) return;
|
||||
ModelDraw d{model.index, transform, tint};
|
||||
d.castShadow = castShadow || shadowOnly_;
|
||||
|
|
@ -261,6 +275,8 @@ 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
|
||||
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);
|
||||
if (skin && mdl.skinned && packet_.joints.size() + skin->size() <= kMaxJointsPerFrame) {
|
||||
d.firstJoint = uint32_t(packet_.joints.size());
|
||||
|
|
@ -445,11 +461,13 @@ void Renderer::buildPacket(uint32_t width, uint32_t height) {
|
|||
for (const glm::vec3& q : corners) radius = std::max(radius, glm::length(q - center));
|
||||
radius = std::ceil(radius / 16.0f) * 16.0f;
|
||||
const float texel = 2.0f * radius / cascadeSize;
|
||||
glm::mat4 lightView = glm::lookAt(glm::vec3(0), -sunDir_, up);
|
||||
glm::vec4 lc = lightView * glm::vec4(center, 1);
|
||||
// (in doubles: far from the origin a float's steps are near a texel's, and it'd shimmer)
|
||||
const glm::dmat4 lightView0 = glm::lookAt(glm::dvec3(0), -glm::dvec3(sunDir_), glm::dvec3(up));
|
||||
glm::dvec4 lc = lightView0 * glm::dvec4(glm::dvec3(center), 1);
|
||||
lc.x = std::floor(lc.x / texel) * texel;
|
||||
lc.y = std::floor(lc.y / texel) * texel;
|
||||
center = glm::vec3(glm::inverse(lightView) * lc);
|
||||
center = glm::vec3(glm::inverse(lightView0) * lc);
|
||||
glm::mat4 lightView;
|
||||
lightView = glm::lookAt(center + sunDir_ * 8000.0f, center, up);
|
||||
frame.cascades[c] = glm::ortho(-radius, radius, -radius, radius, 1.0f, 16000.0f) * lightView;
|
||||
frame.splits[c] = f;
|
||||
|
|
@ -743,7 +761,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));
|
||||
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.aoRadius = std::max(r_ssao_radius.f(), 1.0f);
|
||||
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.bloomStrength = std::clamp(r_bloom.f(), 0.0f, 1.0f);
|
||||
p.post.saturation = r_saturation.f();
|
||||
p.post.vignette = r_vignette.f();
|
||||
|
|
@ -755,16 +773,16 @@ void Renderer::buildPacket(uint32_t width, uint32_t height) {
|
|||
up = glm::cross(right, f);
|
||||
for (auto* list : {&p.sprites, &p.glowSprites})
|
||||
for (gpu::Sprite& s : *list) {
|
||||
if (const glm::vec3 half(s.up); glm::dot(half, half) > 0.0f) { // a streak: its side faces the eye
|
||||
glm::vec3 side = glm::cross(half, camera_.origin - glm::vec3(s.posSize));
|
||||
side = glm::length(side) > 1e-6f ? glm::normalize(side) : right;
|
||||
s.right = glm::vec4(side * s.posSize.w, 0.0f);
|
||||
s.posSize.w = 1.0f;
|
||||
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: each end's side faces the eye (sprite.vert)
|
||||
s.right = glm::vec4(camera_.origin, s.right.w);
|
||||
s.posSize.w = -std::max(s.posSize.w, 1e-9f); // (below 0: a streak; its half width at `from`)
|
||||
continue;
|
||||
}
|
||||
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) {
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
|
@ -787,7 +805,7 @@ void Renderer::buildPacket(uint32_t width, uint32_t height) {
|
|||
p.post.sunColor = glm::vec4(sunColor_, 0.0f);
|
||||
}
|
||||
}
|
||||
p.post.proj = glm::vec4(tanY * float(width) / float(height), tanY, 2.0f, 16384.0f);
|
||||
p.post.proj = glm::vec4(tanY * float(width) / float(height), tanY, camera_.zNear, camera_.zFar);
|
||||
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,
|
||||
// lit by every light (the clusters are this view's), and no fog grid (that's this view's too).
|
||||
|
|
|
|||
|
|
@ -22,9 +22,15 @@ struct Camera {
|
|||
float roll = 0.0f; // degrees, positive tilts right
|
||||
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
|
||||
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.
|
||||
// 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 {
|
||||
glm::vec3 cube[6]; // light arriving from +X -X +Y -Y +Z -Z
|
||||
float sunVisibility = 1.0f;
|
||||
|
|
@ -71,6 +77,7 @@ public:
|
|||
|
||||
// A loaded texture for ImGui::Image (ImTextureID); 0 if it isn't one.
|
||||
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.
|
||||
glm::ivec2 textureSize(TextureId texture) const {
|
||||
const auto it = textureSizes_.find(texture);
|
||||
|
|
@ -91,14 +98,20 @@ public:
|
|||
// 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).
|
||||
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, turned to face the eye as much as
|
||||
// it can (rain).
|
||||
void drawStreak(const glm::vec3& from, const glm::vec3& to, float width, const glm::vec4& color, TextureId texture, bool additive = false);
|
||||
// A sprite stretched from `from` to `to`, `width` across (`widthTo` at `to`: < 0, the same),
|
||||
// each end turned to face the eye as much as it can (rain; lines drawn in pieces meet exactly).
|
||||
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);
|
||||
// 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.
|
||||
// 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),
|
||||
const std::vector<glm::mat4>* skin = nullptr, const ModelLighting* lighting = nullptr, bool castShadow = true,
|
||||
const glm::vec4* liquidPlane = nullptr, LiquidStyle liquid = LiquidStyle::None);
|
||||
const glm::vec4* liquidPlane = nullptr, LiquidStyle liquid = LiquidStyle::None, const Surface* surface = nullptr);
|
||||
// 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.
|
||||
// Alpha below 1 (tint × material) makes a submesh translucent: sorted, blended, after opaque.
|
||||
|
|
@ -151,6 +164,9 @@ public:
|
|||
void onResize() {
|
||||
if (backend_) backend_->onResize();
|
||||
}
|
||||
void onSurfaceLost() {
|
||||
if (backend_) backend_->onSurfaceLost();
|
||||
}
|
||||
|
||||
private:
|
||||
struct Submesh {
|
||||
|
|
|
|||
|
|
@ -232,6 +232,12 @@ public:
|
|||
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_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::SamplerParameteri(compareSampler_, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
|
||||
gl::SamplerParameteri(compareSampler_, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
|
||||
|
|
@ -320,7 +326,7 @@ public:
|
|||
}
|
||||
for (GLuint b : {textureTable_, shadowTable_, draws_, joints_, frame_, mirrorFrame_, lights_, views_, cells_, indices_, push_, empty_})
|
||||
gl::DeleteBuffers(1, &b);
|
||||
for (GLuint s : {repeatSampler_, clampSampler_, compareSampler_}) gl::DeleteSamplers(1, &s);
|
||||
for (GLuint s : {repeatSampler_, clampSampler_, compareSampler_, uiRawSampler_}) gl::DeleteSamplers(1, &s);
|
||||
for (GLuint p : {meshProgram_, skinnedProgram_, shadowMeshProgram_, shadowSkinnedProgram_, boxProgram_, tonemapProgram_, skyProgram_, hzbProgram_, ssaoProgram_, ssaoBlurProgram_, ssrProgram_, adaptProgram_, volumetricProgram_, spriteProgram_,
|
||||
bloomDownProgram_, bloomUpProgram_, taaProgram_})
|
||||
gl::DeleteProgram(p);
|
||||
|
|
@ -358,6 +364,7 @@ public:
|
|||
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());
|
||||
}
|
||||
t.srgb = srgb;
|
||||
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
|
||||
textures_[slot] = t;
|
||||
|
|
@ -385,6 +392,13 @@ public:
|
|||
const auto it = textures_.find(slot);
|
||||
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 {
|
||||
int w = 0, h = 0;
|
||||
|
|
@ -424,6 +438,7 @@ private:
|
|||
struct Texture {
|
||||
GLuint texture = 0;
|
||||
GLuint64 handle = 0, shadowHandle = 0;
|
||||
bool srgb = false;
|
||||
};
|
||||
struct Model {
|
||||
GLuint vertices = 0, indices = 0, skin = 0;
|
||||
|
|
@ -529,7 +544,7 @@ private:
|
|||
SDL_Window* window_ = nullptr;
|
||||
SDL_GLContext context_ = nullptr;
|
||||
bool imgui_ = false;
|
||||
GLuint repeatSampler_ = 0, clampSampler_ = 0, compareSampler_ = 0;
|
||||
GLuint repeatSampler_ = 0, clampSampler_ = 0, compareSampler_ = 0, uiRawSampler_ = 0;
|
||||
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 push_ = 0, empty_ = 0, vao_ = 0;
|
||||
|
|
|
|||
|
|
@ -100,7 +100,11 @@ namespace {
|
|||
X(void, DrawBuffer, (GLenum)) \
|
||||
X(void, DrawBuffers, (GLsizei, const GLenum*)) \
|
||||
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 {
|
||||
#define KEEL_GLX_DECLARE(ret, name, params) using name##_t = ret(APIENTRY*) params; name##_t name = nullptr;
|
||||
|
|
@ -362,24 +366,72 @@ void main() { o_color = vec4(u_color.rgb * (0.5 + 0.5 * max(dot(v_normal, normal
|
|||
const char* kSpriteVert = R"(
|
||||
uniform mat4 u_viewProj;
|
||||
uniform vec4 u_posSize, u_right, u_up; // right.w: turn (radians)
|
||||
uniform vec4 u_color;
|
||||
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));
|
||||
void main() {
|
||||
vec2 q = kQuad[gl_VertexID];
|
||||
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);
|
||||
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_rel = u_color.a < 0.0 ? q * u_posSize.w : vec2(0.0);
|
||||
gl_Position = u_viewProj * vec4(world, 1.0);
|
||||
gl_Position.z = gl_Position.z * 2.0 - gl_Position.w;
|
||||
}
|
||||
)";
|
||||
const char* kSpriteFrag = R"(
|
||||
in vec2 v_uv;
|
||||
in vec2 v_rel;
|
||||
uniform sampler2D u_albedo;
|
||||
uniform vec4 u_color;
|
||||
uniform vec4 u_color, u_posSize, u_right, u_up;
|
||||
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() {
|
||||
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);
|
||||
o_color = vec4(t.rgb * u_color.rgb, clamp((t.a - 0.03) / 0.97, 0.0, 1.0) * u_color.a);
|
||||
}
|
||||
|
|
@ -499,6 +551,14 @@ public:
|
|||
ImGui::GetIO().IniFilename = nullptr;
|
||||
ImGui::StyleColorsDark();
|
||||
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");
|
||||
return imgui_;
|
||||
}
|
||||
|
|
@ -515,6 +575,7 @@ public:
|
|||
for (GLuint t : textures_)
|
||||
if (t) glx::DeleteTextures(1, &t);
|
||||
for (Model& m : models_) releaseModel(m);
|
||||
if (uiRawSampler_) glx::DeleteSamplers(1, &uiRawSampler_);
|
||||
releaseTargets();
|
||||
for (GLuint* t : {&shadowMap_, &jointTexture_}) glx::DeleteTextures(1, t);
|
||||
glx::DeleteFramebuffers(1, &shadowFbo_);
|
||||
|
|
@ -538,7 +599,8 @@ public:
|
|||
// 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,
|
||||
TexFormat = TexFormat::RGBA8) override {
|
||||
if (slot >= textures_.size()) textures_.resize(slot + 1, 0);
|
||||
if (slot >= textures_.size()) textures_.resize(slot + 1, 0), srgb_.resize(slot + 1, false);
|
||||
srgb_[slot] = srgb;
|
||||
GLuint& t = textures_[slot];
|
||||
if (!t) glx::GenTextures(1, &t);
|
||||
glx::BindTexture(GL_TEXTURE_2D, t);
|
||||
|
|
@ -585,6 +647,22 @@ public:
|
|||
}
|
||||
|
||||
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 {
|
||||
int w = 0, h = 0;
|
||||
|
|
@ -722,6 +800,8 @@ private:
|
|||
bool imgui_ = false;
|
||||
Program mesh_, depth_, sky_, box_, tonemap_, sprite_;
|
||||
std::vector<GLuint> textures_;
|
||||
std::vector<bool> srgb_;
|
||||
GLuint uiRawSampler_ = 0;
|
||||
std::vector<Model> models_;
|
||||
GLuint frameUbo_ = 0, jointBuffer_ = 0, jointTexture_ = 0, emptyVao_ = 0;
|
||||
GLuint shadowMap_ = 0, shadowFbo_ = 0;
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@
|
|||
GLenum format, GLsizei size, const void* data)) \
|
||||
X(void, CreateSamplers, (GLsizei n, 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, SamplerParameterf, (GLuint sampler, GLenum pname, GLfloat param)) \
|
||||
X(GLuint64, GetTextureSamplerHandleARB, (GLuint texture, GLuint sampler)) \
|
||||
|
|
|
|||
|
|
@ -404,6 +404,7 @@ public:
|
|||
}
|
||||
void setVsync(bool on) override { device_.setVsync(on); }
|
||||
void onResize() override { device_.markResized(); }
|
||||
void onSurfaceLost() override { device_.markSurfaceLost(); }
|
||||
|
||||
bool renderScale() const override { return true; }
|
||||
bool blockCompression() const override {
|
||||
|
|
@ -425,6 +426,7 @@ public:
|
|||
ici.arrayLayers = 1;
|
||||
ici.samples = VK_SAMPLE_COUNT_1_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{};
|
||||
aci.usage = VMA_MEMORY_USAGE_AUTO;
|
||||
VK_CHECK(vmaCreateImage(device_.allocator(), &ici, &aci, &t.image, &t.alloc, nullptr));
|
||||
|
|
@ -454,6 +456,12 @@ public:
|
|||
vci.format = format;
|
||||
vci.subresourceRange = {VK_IMAGE_ASPECT_COLOR_BIT, 0, uint32_t(mips.size()), 0, 1};
|
||||
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);
|
||||
// Replaced (hot reload): frames in flight may still sample the old one.
|
||||
if (textures_[slot].image) retiredTextures_.emplace_back(frameNumber_, textures_[slot]);
|
||||
|
|
@ -484,7 +492,10 @@ public:
|
|||
uiTextures_.erase(it), it = uiTextures_.end();
|
||||
}
|
||||
if (it == uiTextures_.end())
|
||||
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;
|
||||
it = uiTextures_.emplace(slot, UiTexture{uint64_t(ImGui_ImplVulkan_AddTexture(sampler_, textures_[slot].uiView ? textures_[slot].uiView : textures_[slot].view,
|
||||
VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL)),
|
||||
textures_[slot].view})
|
||||
.first;
|
||||
return it->second.set;
|
||||
}
|
||||
|
||||
|
|
@ -535,6 +546,7 @@ private:
|
|||
VkImage image = VK_NULL_HANDLE;
|
||||
VmaAllocation alloc = 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.
|
||||
struct Blas {
|
||||
|
|
@ -549,6 +561,7 @@ private:
|
|||
|
||||
void destroyTexture(Texture& t) {
|
||||
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);
|
||||
t = {};
|
||||
}
|
||||
|
|
@ -671,7 +684,7 @@ private:
|
|||
Texture hzbMips_[kHzbLevels]; // [0] unused (that's hzb_)
|
||||
uint32_t gpuCulled_ = 0;
|
||||
const vk::Buffer* cullFor_ = nullptr; // this frame's commands, while the opaque lists are drawn
|
||||
bool gpuCulling() const override { return true; }
|
||||
bool gpuCulling() const override { return cullPipeline_ != VK_NULL_HANDLE; }
|
||||
// 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 SmokeSkyPush { VkDeviceAddress frame, sky; };
|
||||
|
|
@ -685,7 +698,7 @@ private:
|
|||
VkPipeline froxelPipeline_ = VK_NULL_HANDLE, froxelRtPipeline_ = VK_NULL_HANDLE; // (rays: sky seen from each cell)
|
||||
uint32_t froxelFrame_ = 0;
|
||||
glm::vec4 froxelPrevPos_{0.0f}, froxelPrevFwd_{0.0f};
|
||||
bool froxelFog() const override { return true; }
|
||||
bool froxelFog() const override { return froxelPipeline_ != VK_NULL_HANDLE; }
|
||||
// Real-time GI (gi.comp): the scene for rays (per frame in flight), and the probes (this
|
||||
// frame's, last frame's).
|
||||
struct Tlas {
|
||||
|
|
@ -924,8 +937,10 @@ void VulkanBackend::createPipelines() {
|
|||
adaptPipeline_ = makePipeline(dev, layout_, {kFullscreenVert, sizeof kFullscreenVert, kAdaptFrag, sizeof kAdaptFrag, 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});
|
||||
// Compute pipelines (GPU culling, the fog grid), on the same layout.
|
||||
auto compute = [&](const uint32_t* code, size_t size, VkPipeline& out) {
|
||||
// Compute pipelines (GPU culling, the fog grid, ray-traced light), on the same layout. All of
|
||||
// them are extras: one a driver can't build is left out (and what it does is off). (The
|
||||
// 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};
|
||||
ci.codeSize = size;
|
||||
ci.pCode = code;
|
||||
|
|
@ -934,12 +949,15 @@ void VulkanBackend::createPipelines() {
|
|||
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.layout = layout_;
|
||||
VK_CHECK(vkCreateComputePipelines(dev, VK_NULL_HANDLE, 1, &pi, nullptr, &out));
|
||||
if (const VkResult res = vkCreateComputePipelines(dev, VK_NULL_HANDLE, 1, &pi, nullptr, &out); res != VK_SUCCESS) {
|
||||
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);
|
||||
};
|
||||
compute(kCullComp, sizeof kCullComp, cullPipeline_);
|
||||
compute(kFroxelComp, sizeof kFroxelComp, froxelPipeline_);
|
||||
if (device_.rayQuery()) compute(kGiComp, sizeof kGiComp, giPipeline_), compute(kFroxelRtComp, sizeof kFroxelRtComp, froxelRtPipeline_);
|
||||
compute("GPU culling", kCullComp, sizeof kCullComp, cullPipeline_);
|
||||
compute("fog grid", kFroxelComp, sizeof kFroxelComp, froxelPipeline_);
|
||||
if (device_.rayQuery()) compute("ray-traced light", kGiComp, sizeof kGiComp, giPipeline_), compute("ray-traced fog", kFroxelRtComp, sizeof kFroxelRtComp, froxelRtPipeline_);
|
||||
// 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});
|
||||
depthSkinnedPipeline_ = makePipeline(dev, layout_, {kSkinnedVert, sizeof kSkinnedVert, kDepthFrag, sizeof kDepthFrag, VK_FORMAT_UNDEFINED, true});
|
||||
|
|
@ -1395,6 +1413,17 @@ void VulkanBackend::render(FramePacket& p) {
|
|||
vkCmdSetScissor(cmd, 0, 1, &scissor);
|
||||
drawMeshes(cmd, p.opaque, p.viewProj, false, depthMeshPipeline_, depthSkinnedPipeline_, cullFor_, true);
|
||||
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");
|
||||
depth.loadOp = VK_ATTACHMENT_LOAD_OP_LOAD;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -46,6 +46,11 @@ static VKAPI_ATTR VkBool32 VKAPI_CALL debugCallback(VkDebugUtilsMessageSeverityF
|
|||
|
||||
bool Device::init(const Window& window, bool validation) {
|
||||
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) {
|
||||
log::error("No Vulkan loader found - is a Vulkan driver installed?");
|
||||
return false;
|
||||
|
|
@ -227,6 +232,9 @@ bool Device::createSwapchain() {
|
|||
vkDeviceWaitIdle(device_);
|
||||
VkSwapchainKHR old = swapchain_;
|
||||
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_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)
|
||||
|
|
@ -255,6 +263,24 @@ bool Device::createSwapchain() {
|
|||
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() {
|
||||
for (VkImageView v : views_) vkDestroyImageView(device_, v, nullptr);
|
||||
for (VkSemaphore s : renderDone_) vkDestroySemaphore(device_, s, nullptr);
|
||||
|
|
@ -263,6 +289,7 @@ void Device::destroySwapchainViews() {
|
|||
}
|
||||
|
||||
VkCommandBuffer Device::beginFrame() {
|
||||
if (surfaceLost_ && !recreateSurface()) return nullptr;
|
||||
if (swapchainDirty_ && !createSwapchain()) return nullptr;
|
||||
|
||||
Frame& f = frames_[frameIndex_];
|
||||
|
|
@ -272,6 +299,10 @@ VkCommandBuffer Device::beginFrame() {
|
|||
swapchainDirty_ = true;
|
||||
return nullptr;
|
||||
}
|
||||
if (r == VK_ERROR_SURFACE_LOST_KHR) {
|
||||
surfaceLost_ = true;
|
||||
return nullptr;
|
||||
}
|
||||
if (r != VK_SUBOPTIMAL_KHR) VK_CHECK(r);
|
||||
VK_CHECK(vkResetFences(device_, 1, &f.inFlight));
|
||||
|
||||
|
|
@ -312,7 +343,15 @@ void Device::endFrame() {
|
|||
pi.pSwapchains = &swapchain_;
|
||||
pi.pImageIndices = &imageIndex_;
|
||||
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;
|
||||
else if (r == VK_ERROR_SURFACE_LOST_KHR) surfaceLost_ = true;
|
||||
else VK_CHECK(r);
|
||||
|
||||
frameIndex_ = (frameIndex_ + 1) % kFramesInFlight;
|
||||
|
|
|
|||
|
|
@ -50,6 +50,8 @@ public:
|
|||
|
||||
void setVsync(bool vsync) { vsync_ = vsync; 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_); }
|
||||
|
||||
VkInstance instance() const { return instance_; }
|
||||
|
|
@ -81,6 +83,7 @@ private:
|
|||
};
|
||||
|
||||
bool createSwapchain();
|
||||
bool recreateSurface();
|
||||
void destroySwapchainViews();
|
||||
|
||||
const Window* window_ = nullptr;
|
||||
|
|
@ -102,7 +105,7 @@ private:
|
|||
std::vector<VkImageView> views_;
|
||||
std::vector<VkSemaphore> renderDone_; // per swapchain image, so present never reuses one in flight
|
||||
bool vsync_ = true;
|
||||
bool swapchainDirty_ = false;
|
||||
bool swapchainDirty_ = false, surfaceLost_ = false;
|
||||
|
||||
Frame frames_[kFramesInFlight];
|
||||
VkCommandPool immPool_ = VK_NULL_HANDLE;
|
||||
|
|
|
|||
|
|
@ -159,6 +159,10 @@ void main() {
|
|||
n = normalize(tbn * vec3(xy, sqrt(max(1.0 - dot(xy, xy), 0.0))));
|
||||
metal = m.z;
|
||||
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
|
||||
// sun visibility), then rain wetting it (darker, glossy) and snow lying on what faces up.
|
||||
|
|
|
|||
|
|
@ -5,6 +5,9 @@ layout(location = 0) out vec3 v_normal;
|
|||
layout(location = 1) out vec2 v_uv;
|
||||
layout(location = 2) out vec2 v_lightmapUv;
|
||||
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() {
|
||||
Vertex vx = VERTEX(VERTEX_ID);
|
||||
|
|
|
|||
|
|
@ -5,6 +5,9 @@ layout(location = 0) out vec3 v_normal;
|
|||
layout(location = 1) out vec2 v_uv;
|
||||
layout(location = 2) out vec2 v_lightmapUv;
|
||||
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() {
|
||||
Vertex vx = VERTEX(VERTEX_ID);
|
||||
|
|
|
|||
|
|
@ -9,8 +9,49 @@ PUSH_BLOCK {
|
|||
vec4 up;
|
||||
} pc;
|
||||
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;
|
||||
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);
|
||||
// Block compression leaves a little alpha where there should be none; bright sprites
|
||||
// would show it as a square: cut it.
|
||||
|
|
|
|||
|
|
@ -9,13 +9,20 @@ PUSH_BLOCK {
|
|||
vec4 up; // camera up, w = texture index (bits)
|
||||
} pc;
|
||||
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));
|
||||
void main() {
|
||||
vec2 q = kQuad[VERTEX_ID];
|
||||
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);
|
||||
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.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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -56,7 +56,8 @@ void main() {
|
|||
vec3 v = viewPos(q) - p;
|
||||
float vv = dot(v, v);
|
||||
float falloff = max(0.0, 1.0 - vv / (pc.radius * pc.radius));
|
||||
occlusion += max(0.0, dot(v, n) - 0.01 * p.z) / (vv + 1.0) * falloff;
|
||||
// (+ 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 + pc.radius * pc.radius * 0.0017) * falloff;
|
||||
}
|
||||
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));
|
||||
|
|
|
|||
332
engine/ui/ui.cpp
|
|
@ -5,15 +5,21 @@
|
|||
#include <imgui.h>
|
||||
#include <imgui_internal.h>
|
||||
|
||||
#include <SDL3/SDL_video.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#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 {
|
||||
ImFont* g_fonts[3] = {};
|
||||
ImFont* g_fonts[4] = {};
|
||||
float g_scale = 1.0f;
|
||||
|
||||
ImFont* loadFont(const char* path, float size) {
|
||||
ImFont* loadFont(const char* path, float size, const char* fallback = nullptr) {
|
||||
auto data = fs::read(path);
|
||||
if (!data) {
|
||||
log::warn("Font {} not found, using the built-in one", path);
|
||||
|
|
@ -21,74 +27,285 @@ ImFont* loadFont(const char* path, float size) {
|
|||
}
|
||||
void* owned = IM_ALLOC(data->size()); // the atlas frees it
|
||||
std::memcpy(owned, data->data(), data->size());
|
||||
return ImGui::GetIO().Fonts->AddFontFromMemoryTTF(owned, int(data->size()), size);
|
||||
ImFont* f = 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;
|
||||
}
|
||||
|
||||
void theme() {
|
||||
ImGuiStyle& s = ImGui::GetStyle();
|
||||
s = ImGuiStyle();
|
||||
s.WindowRounding = 2;
|
||||
s.FrameRounding = 1;
|
||||
s.GrabRounding = 1;
|
||||
s.TabRounding = 1;
|
||||
s.ScrollbarRounding = 1;
|
||||
s.WindowBorderSize = 1;
|
||||
s.FrameBorderSize = 1;
|
||||
s.WindowPadding = ImVec2(14, 12);
|
||||
s.FramePadding = ImVec2(8, 5);
|
||||
s.ItemSpacing = ImVec2(10, 8);
|
||||
s.WindowTitleAlign = ImVec2(0.0f, 0.5f);
|
||||
ImVec4* c = s.Colors;
|
||||
// ── 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); };
|
||||
// Source 2013 GameUI's charcoal panels, grey frames and near-white text, with a red
|
||||
// where Source has its orange.
|
||||
const ImVec4 red = rgb(kAccent[0], kAccent[1], kAccent[2]), redLight = rgb(245, 70, 76), redDark = rgb(150, 24, 32);
|
||||
c[ImGuiCol_Text] = rgb(235, 230, 220);
|
||||
c[ImGuiCol_TextDisabled] = rgb(140, 136, 128);
|
||||
c[ImGuiCol_WindowBg] = rgb(40, 40, 38, 0.94f);
|
||||
c[ImGuiCol_ChildBg] = rgb(30, 30, 29, 0.6f);
|
||||
c[ImGuiCol_PopupBg] = rgb(36, 36, 34, 0.98f);
|
||||
c[ImGuiCol_Border] = rgb(90, 88, 82, 0.8f);
|
||||
c[ImGuiCol_FrameBg] = rgb(28, 28, 27);
|
||||
c[ImGuiCol_FrameBgHovered] = rgb(50, 49, 46);
|
||||
c[ImGuiCol_FrameBgActive] = rgb(60, 58, 54);
|
||||
c[ImGuiCol_TitleBg] = rgb(52, 51, 48);
|
||||
c[ImGuiCol_TitleBgActive] = rgb(70, 30, 32);
|
||||
c[ImGuiCol_Button] = rgb(70, 68, 63);
|
||||
c[ImGuiCol_ButtonHovered] = rgb(95, 92, 84);
|
||||
c[ImGuiCol_ButtonActive] = red;
|
||||
c[ImGuiCol_Header] = rgb(70, 68, 63);
|
||||
c[ImGuiCol_HeaderHovered] = rgb(95, 92, 84);
|
||||
c[ImGuiCol_HeaderActive] = red;
|
||||
c[ImGuiCol_Tab] = rgb(55, 54, 50);
|
||||
c[ImGuiCol_TabHovered] = rgb(95, 92, 84);
|
||||
c[ImGuiCol_TabSelected] = redDark;
|
||||
c[ImGuiCol_TabSelectedOverline] = red;
|
||||
c[ImGuiCol_SliderGrab] = red;
|
||||
c[ImGuiCol_SliderGrabActive] = redLight;
|
||||
c[ImGuiCol_CheckMark] = redLight;
|
||||
c[ImGuiCol_TextSelectedBg] = rgb(kAccent[0], kAccent[1], kAccent[2], 0.45f);
|
||||
c[ImGuiCol_NavCursor] = red;
|
||||
c[ImGuiCol_Separator] = rgb(90, 88, 82, 0.6f);
|
||||
c[ImGuiCol_ScrollbarBg] = rgb(28, 28, 27, 0.6f);
|
||||
c[ImGuiCol_ScrollbarGrab] = rgb(80, 78, 72);
|
||||
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() {
|
||||
derive(g_dark);
|
||||
ImGuiStyle& s = ImGui::GetStyle();
|
||||
const float fontScale = s.FontScaleMain, fontDpi = s.FontScaleDpi;
|
||||
s = ImGuiStyle();
|
||||
s.FontScaleMain = fontScale, s.FontScaleDpi = fontDpi;
|
||||
// Niveus's radii and 4px rhythm. Frames have no border of ImGui's: their edges are the
|
||||
// keys' and wells' own (drawn by frameHook).
|
||||
s.WindowRounding = 8, s.ChildRounding = 6, s.PopupRounding = 8, s.FrameRounding = 6, s.GrabRounding = 5, s.TabRounding = 6;
|
||||
s.ScrollbarRounding = 8, s.ScrollbarSize = 11, s.GrabMinSize = 12;
|
||||
s.WindowBorderSize = 1, s.PopupBorderSize = 1, s.FrameBorderSize = 0, s.TabBorderSize = 0, s.DockingSeparatorSize = 2;
|
||||
s.WindowPadding = ImVec2(10, 10), s.FramePadding = ImVec2(8, 5), s.ItemSpacing = ImVec2(8, 6), s.ItemInnerSpacing = ImVec2(6, 4);
|
||||
s.SeparatorTextBorderSize = 1, s.SeparatorTextPadding = ImVec2(10, 3);
|
||||
s.WindowTitleAlign = ImVec2(0.0f, 0.5f);
|
||||
s.WindowMenuButtonPosition = ImGuiDir_None;
|
||||
ImVec4* c = s.Colors;
|
||||
c[ImGuiCol_Text] = role(Role::Text);
|
||||
c[ImGuiCol_TextDisabled] = role(Role::TextTertiary);
|
||||
c[ImGuiCol_WindowBg] = role(Role::Surface);
|
||||
c[ImGuiCol_ChildBg] = ImVec4(0, 0, 0, 0);
|
||||
c[ImGuiCol_PopupBg] = role(Role::SurfaceElevated);
|
||||
c[ImGuiCol_Border] = role(Role::Border);
|
||||
c[ImGuiCol_BorderShadow] = ImVec4(0, 0, 0, 0);
|
||||
c[ImGuiCol_FrameBg] = role(Role::SurfaceInset);
|
||||
c[ImGuiCol_FrameBgHovered] = g_dark ? role(Role::Background) : role(Role::SurfaceActive);
|
||||
c[ImGuiCol_FrameBgActive] = c[ImGuiCol_FrameBgHovered];
|
||||
c[ImGuiCol_TitleBg] = c[ImGuiCol_TitleBgActive] = c[ImGuiCol_TitleBgCollapsed] = role(Role::BackgroundSubtle);
|
||||
c[ImGuiCol_MenuBarBg] = role(Role::BackgroundSubtle);
|
||||
c[ImGuiCol_ScrollbarBg] = ImVec4(0, 0, 0, 0);
|
||||
c[ImGuiCol_ScrollbarGrab] = role(Role::BorderStrong);
|
||||
c[ImGuiCol_ScrollbarGrabHovered] = role(Role::TextTertiary);
|
||||
c[ImGuiCol_ScrollbarGrabActive] = role(Role::TextSecondary);
|
||||
c[ImGuiCol_CheckMark] = role(Role::Accent);
|
||||
c[ImGuiCol_SliderGrab] = role(Role::Accent);
|
||||
c[ImGuiCol_SliderGrabActive] = role(Role::AccentHover);
|
||||
c[ImGuiCol_Button] = role(Role::SurfaceElevated);
|
||||
c[ImGuiCol_ButtonHovered] = role(Role::SurfaceHover);
|
||||
c[ImGuiCol_ButtonActive] = role(Role::SurfaceActive);
|
||||
c[ImGuiCol_Header] = role(Role::AccentSubtle);
|
||||
c[ImGuiCol_HeaderHovered] = role(Role::SurfaceHover);
|
||||
c[ImGuiCol_HeaderActive] = role(Role::AccentSubtleHover);
|
||||
c[ImGuiCol_Separator] = role(Role::BorderSubtle);
|
||||
c[ImGuiCol_SeparatorHovered] = c[ImGuiCol_SeparatorActive] = role(Role::Accent);
|
||||
c[ImGuiCol_ResizeGrip] = ImVec4(0, 0, 0, 0);
|
||||
c[ImGuiCol_ResizeGripHovered] = c[ImGuiCol_ResizeGripActive] = role(Role::AccentSubtleHover);
|
||||
c[ImGuiCol_InputTextCursor] = role(Role::Accent);
|
||||
c[ImGuiCol_Tab] = c[ImGuiCol_TabDimmed] = role(Role::BackgroundSubtle);
|
||||
c[ImGuiCol_TabHovered] = role(Role::SurfaceHover);
|
||||
c[ImGuiCol_TabSelected] = c[ImGuiCol_TabDimmedSelected] = role(Role::Surface);
|
||||
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
|
||||
|
||||
void init() {
|
||||
ImGuiIO& io = ImGui::GetIO();
|
||||
g_fonts[0] = loadFont("fonts/DejaVuSans.ttf", 16.0f);
|
||||
g_fonts[1] = loadFont("fonts/DejaVuSans-Bold.ttf", 16.0f);
|
||||
g_fonts[2] = loadFont("fonts/DejaVuSans-Bold.ttf", 48.0f);
|
||||
g_fonts[0] = loadFont("fonts/InstrumentSans-Regular.ttf", 16.0f, "fonts/DejaVuSans.ttf");
|
||||
g_fonts[1] = loadFont("fonts/InstrumentSans-SemiBold.ttf", 16.0f, "fonts/DejaVuSans-Bold.ttf");
|
||||
g_fonts[2] = loadFont("fonts/InstrumentSans-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];
|
||||
theme();
|
||||
GImRenderFrameHook = frameHook;
|
||||
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) {
|
||||
return ImGui::ColorConvertFloat4ToU32(ImVec4(std::min(kAccent[0] / 255.0f * brightness, 1.0f), std::min(kAccent[1] / 255.0f * brightness, 1.0f),
|
||||
std::min(kAccent[2] / 255.0f * brightness, 1.0f), alpha));
|
||||
const ImVec4 c = role(Role::Accent);
|
||||
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));
|
||||
}
|
||||
|
||||
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* r = g_fonts[int(f)];
|
||||
return r ? r : ImGui::GetFont();
|
||||
|
|
@ -122,6 +339,7 @@ void applyPixelScale() {
|
|||
void beginFrame(float windowHeight) {
|
||||
g_scale = std::clamp(windowHeight / 1080.0f, 0.75f, 2.5f);
|
||||
ImGui::GetStyle().FontScaleMain = g_scale;
|
||||
if (g_appearance == Appearance::System && g_styled && (fromSystem() != Appearance::Light) != g_dark) restyle(); // (the desktop changed)
|
||||
}
|
||||
|
||||
namespace {
|
||||
|
|
@ -162,7 +380,7 @@ void drawPopups() {
|
|||
// focus cancels the press, so OK would never click.
|
||||
const bool refocus = !ImGui::IsAnyMouseDown();
|
||||
if (refocus) ImGui::SetNextWindowFocus();
|
||||
ImGui::PushStyleColor(ImGuiCol_WindowBg, IM_COL32(0, 0, 0, 120));
|
||||
ImGui::PushStyleColor(ImGuiCol_WindowBg, color(Role::Scrim));
|
||||
ImGui::PushStyleVar(ImGuiStyleVar_WindowBorderSize, 0.0f);
|
||||
ImGui::Begin("##popupdim", nullptr, ImGuiWindowFlags_NoDecoration | ImGuiWindowFlags_NoSavedSettings | ImGuiWindowFlags_NoMove);
|
||||
ImGui::End();
|
||||
|
|
|
|||
|
|
@ -1,16 +1,19 @@
|
|||
#pragma once
|
||||
// Game UI look: fonts from the game folder and a Source-like theme (dark
|
||||
// translucent panels, flat buttons, light text, red highlights) on top of Dear ImGui.
|
||||
// The UI's look: Niveus (github.com/Nexoniarz/niveus) on top of Dear ImGui. Buttons are keys,
|
||||
// bevelled with light falling across them; fields are wells cut into the surface; warm
|
||||
// graphite in dark, soft paper in light, and one brand colour that every accent is made from.
|
||||
#include <cstdint>
|
||||
#include <functional>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
struct ImFont;
|
||||
struct ImDrawList;
|
||||
struct ImVec2;
|
||||
|
||||
namespace keel::ui {
|
||||
|
||||
enum class Font { Regular, Bold, Title };
|
||||
enum class Font { Regular, Bold, Title, Mono };
|
||||
|
||||
// After the renderer (ImGui context) and the VFS are up.
|
||||
void init();
|
||||
|
|
@ -25,10 +28,38 @@ float pixelScale();
|
|||
// The backends call this between the platform's NewFrame and ImGui::NewFrame.
|
||||
void applyPixelScale();
|
||||
|
||||
// The highlight colour: highlights, the logo.
|
||||
inline constexpr uint8_t kAccent[3] = {227, 34, 45};
|
||||
// ── Appearance ──
|
||||
enum class Appearance { System, Dark, Light };
|
||||
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
|
||||
|
||||
// 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) ──
|
||||
struct PopupButton {
|
||||
std::string label;
|
||||
|
|
|
|||
20
project.json
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
{
|
||||
"_": "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"]
|
||||
}
|
||||
|
|
@ -1,24 +1,61 @@
|
|||
# Fry3DX: the app (app.cpp) on the engine, and its modelling core (mesh, scene) as a library the
|
||||
# tests use too.
|
||||
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)
|
||||
target_include_directories(fry_core PUBLIC ${CMAKE_CURRENT_SOURCE_DIR})
|
||||
target_link_libraries(fry_core PUBLIC glm::glm libzstd_static)
|
||||
target_compile_definitions(fry_core PUBLIC GLM_ENABLE_EXPERIMENTAL)
|
||||
# Manifold (github.com/elalish/manifold): exact, robust combining of closed shapes. Just the
|
||||
# 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_link_libraries(fry_core PUBLIC glm::glm libzstd_static PRIVATE stb cgltf manifold)
|
||||
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
|
||||
add_library(fry3dx SHARED app.cpp icons.cpp)
|
||||
add_library(fry3dx SHARED ${FRY_APP_SOURCES})
|
||||
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()
|
||||
add_executable(fry3dx app.cpp icons.cpp)
|
||||
add_executable(fry3dx ${FRY_APP_SOURCES})
|
||||
endif()
|
||||
if(WIN32) # a window program (no console window), with its icon
|
||||
target_sources(fry3dx PRIVATE fry3dx.rc)
|
||||
target_sources(fry3dx PRIVATE ${CMAKE_BINARY_DIR}/generated/fry3dx.rc)
|
||||
set_target_properties(fry3dx PROPERTIES WIN32_EXECUTABLE ON)
|
||||
target_link_options(fry3dx PRIVATE /ENTRY:mainCRTStartup)
|
||||
endif()
|
||||
target_link_libraries(fry3dx PRIVATE fry_core keel stb)
|
||||
target_link_libraries(fry3dx PRIVATE fry_core fry_script 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)
|
||||
foreach(t fry_core fry3dx)
|
||||
foreach(t fry_core fry_script fry3dx)
|
||||
if(MSVC)
|
||||
target_compile_options(${t} PRIVATE /W3)
|
||||
else()
|
||||
|
|
|
|||
7720
src/app.cpp
809
src/app_model.cpp
Normal file
|
|
@ -0,0 +1,809 @@
|
|||
// 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
|
||||
757
src/app_paint.cpp
Normal file
|
|
@ -0,0 +1,757 @@
|
|||
// 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
|
||||
2060
src/app_panels.cpp
Normal file
489
src/app_print.cpp
Normal file
|
|
@ -0,0 +1,489 @@
|
|||
// 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
|
||||
821
src/app_scripts.cpp
Normal file
|
|
@ -0,0 +1,821 @@
|
|||
// 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
|
||||