mirror of
https://github.com/Nexoniarz/Fry3DX.git
synced 2026-11-03 12:34:27 +00:00
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 |
60 changed files with 12692 additions and 7171 deletions
5
.github/workflows/build.yml
vendored
5
.github/workflows/build.yml
vendored
|
|
@ -32,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
|
||||
|
|
|
|||
4
.gitignore
vendored
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.6.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
|
||||
|
|
@ -38,6 +39,9 @@ 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)
|
||||
|
||||
|
|
@ -55,4 +59,4 @@ add_custom_target(content ALL
|
|||
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 script_test USES_TERMINAL VERBATIM)
|
||||
DEPENDS mesh_test script_test fuzz_test USES_TERMINAL VERBATIM)
|
||||
|
|
|
|||
53
README.md
53
README.md
|
|
@ -39,6 +39,12 @@ comes inside the app (MoltenVK, on Metal), and OpenGL 4.1 is the fallback.
|
|||
|
||||
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,
|
||||
|
|
@ -46,7 +52,13 @@ 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
|
||||
|
|
@ -55,7 +67,9 @@ Five tabs along the top: **Model**, **Paint**, **Layout**, **Render**, **Print**
|
|||
**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), smooth points, push out /
|
||||
pull in along the surface (Alt+S), **bridge** two faces with a tunnel.
|
||||
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
|
||||
|
|
@ -64,9 +78,12 @@ Five tabs along the top: **Model**, **Paint**, **Layout**, **Render**, **Print**
|
|||
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. **Show only the selected** (/ or Shift+H; again:
|
||||
|
|
@ -78,20 +95,32 @@ Five tabs along the top: **Model**, **Paint**, **Layout**, **Render**, **Print**
|
|||
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
|
||||
|
|
@ -106,7 +135,10 @@ Five tabs along the top: **Model**, **Paint**, **Layout**, **Render**, **Print**
|
|||
- 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. Three come with it: stairs (straight and spiral), scatter copies, and more materials.
|
||||
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"}
|
||||
|
|
@ -130,7 +162,8 @@ fry.command{name = "Bookcase", menu = "add", settings = {{"shelves", 5}, {"width
|
|||
**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.
|
||||
|
||||
|
|
|
|||
0
a.out
0
a.out
|
|
@ -19,7 +19,7 @@
|
|||
android:name="io.github.nexoniarz.fry3dx.FryActivity"
|
||||
android:exported="true"
|
||||
android:label="Fry3DX"
|
||||
android:screenOrientation="fullUser"
|
||||
android:screenOrientation="sensorLandscape"
|
||||
android:launchMode="singleInstance"
|
||||
android:windowSoftInputMode="adjustResize"
|
||||
android:configChanges="layoutDirection|locale|orientation|uiMode|screenLayout|screenSize|smallestScreenSize|keyboard|keyboardHidden|navigation">
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
81
cmake/Project.cmake
Normal file
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()
|
||||
22
content/Fry3DX/plugins/stamp.lua
Normal file
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,
|
||||
}
|
||||
|
|
@ -29,20 +29,25 @@ window.FRY = {
|
|||
features: [
|
||||
{ group: "make", icon: "cube", title: "Shapes that stay editable", text: "Cube, cylinder, sphere, torus, stairs, arch and more, plus starter models like a table, chair or barrel. Change their sizes any time in Properties." },
|
||||
{ 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. The result is always closed and ready to print." },
|
||||
{ group: "make", icon: "points", title: "Real shape editing", text: "Points, edges and faces. Extrude, inset, round edges, loop cut, knife, bridge, push and pull. Soft selection as in Blender and Maya: near points follow (Shift+O)." },
|
||||
{ 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 or round in a circle. Make a surface thick, or a solid hollow with walls exactly as thick as you need." },
|
||||
{ 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. Paste any picture (Ctrl+V) and it goes on like a sticker that bends over edges and corners." },
|
||||
{ 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, checks for holes and flipped faces with fix buttons, overhangs shown red, weight and filament." },
|
||||
{ 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. Stairs, scatter and more materials come with it." },
|
||||
{ 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." },
|
||||
],
|
||||
|
||||
|
|
@ -124,6 +129,9 @@ fry.frame()`,
|
|||
},
|
||||
|
||||
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"] },
|
||||
|
|
|
|||
|
|
@ -25,6 +25,9 @@
|
|||
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"/>',
|
||||
|
|
|
|||
|
|
@ -371,10 +371,16 @@ int runClient(Game& game) {
|
|||
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).
|
||||
|
|
|
|||
|
|
@ -36,6 +36,9 @@ public:
|
|||
// 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; }
|
||||
|
|
|
|||
|
|
@ -218,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,
|
||||
|
|
|
|||
|
|
@ -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,14 +211,28 @@ 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,
|
||||
|
|
@ -447,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;
|
||||
|
|
@ -745,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();
|
||||
|
|
@ -757,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);
|
||||
});
|
||||
}
|
||||
|
|
@ -789,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,6 +22,7 @@ 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.
|
||||
|
|
@ -97,9 +98,15 @@ 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),
|
||||
|
|
@ -157,6 +164,9 @@ public:
|
|||
void onResize() {
|
||||
if (backend_) backend_->onResize();
|
||||
}
|
||||
void onSurfaceLost() {
|
||||
if (backend_) backend_->onSurfaceLost();
|
||||
}
|
||||
|
||||
private:
|
||||
struct Submesh {
|
||||
|
|
|
|||
|
|
@ -366,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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
@ -1412,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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -263,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);
|
||||
|
|
@ -271,6 +289,7 @@ void Device::destroySwapchainViews() {
|
|||
}
|
||||
|
||||
VkCommandBuffer Device::beginFrame() {
|
||||
if (surfaceLost_ && !recreateSurface()) return nullptr;
|
||||
if (swapchainDirty_ && !createSwapchain()) return nullptr;
|
||||
|
||||
Frame& f = frames_[frameIndex_];
|
||||
|
|
@ -280,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));
|
||||
|
||||
|
|
@ -328,6 +351,7 @@ void Device::endFrame() {
|
|||
}
|
||||
#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;
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
20
project.json
Normal file
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,19 +1,35 @@
|
|||
# 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 sculpt.cpp)
|
||||
# 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)
|
||||
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 app.cpp icons.cpp ${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
|
||||
|
|
@ -28,15 +44,16 @@ elseif(APPLE) # a Mac app: Fry3DX.app, content in Contents/Resources, MoltenVK i
|
|||
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 fry_script keel stb)
|
||||
target_compile_definitions(fry3dx PRIVATE FRY_VERSION="${PROJECT_VERSION}")
|
||||
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 fry_script fry3dx)
|
||||
if(MSVC)
|
||||
|
|
|
|||
8814
src/app.cpp
8814
src/app.cpp
File diff suppressed because it is too large
Load diff
809
src/app_model.cpp
Normal file
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
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
2060
src/app_panels.cpp
Normal file
File diff suppressed because it is too large
Load diff
489
src/app_print.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
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
|
||||
1734
src/app_view.cpp
Normal file
1734
src/app_view.cpp
Normal file
File diff suppressed because it is too large
Load diff
244
src/boolean.cpp
244
src/boolean.cpp
|
|
@ -3,10 +3,15 @@
|
|||
// shared, points that land on another piece's edge set into it (so it stays closed all round).
|
||||
#include "mesh.h"
|
||||
|
||||
#include <manifold/manifold.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <map>
|
||||
#include <functional>
|
||||
#include <memory>
|
||||
#include <optional>
|
||||
#include <set>
|
||||
|
||||
namespace fry {
|
||||
namespace {
|
||||
|
|
@ -96,18 +101,28 @@ struct Node {
|
|||
if (front) front->all(out);
|
||||
if (back) back->all(out);
|
||||
}
|
||||
void build(const std::vector<Poly>& in) {
|
||||
void build(const std::vector<Poly>& in, int depth = 0) {
|
||||
if (in.empty()) return;
|
||||
if (!hasPlane) hasPlane = true, n = in[0].n, w = in[0].w;
|
||||
size_t first = 0;
|
||||
if (!hasPlane) { // (the piece the plane is taken from stays here: a thin sliver's own corners can
|
||||
// be off its rounded plane, and split by it again and again, forever)
|
||||
hasPlane = true, n = in[0].n, w = in[0].w;
|
||||
polys.push_back(in[0]);
|
||||
first = 1;
|
||||
}
|
||||
if (depth > 3000) { // (a tree this deep is gone wrong: the rest kept here, not a stack overflow)
|
||||
polys.insert(polys.end(), in.begin() + long(first), in.end());
|
||||
return;
|
||||
}
|
||||
std::vector<Poly> f, b;
|
||||
for (const Poly& p : in) split(n, w, p, polys, polys, f, b);
|
||||
for (size_t i = first; i < in.size(); ++i) split(n, w, in[i], polys, polys, f, b);
|
||||
if (!f.empty()) {
|
||||
if (!front) front = std::make_unique<Node>();
|
||||
front->build(f);
|
||||
front->build(f, depth + 1);
|
||||
}
|
||||
if (!b.empty()) {
|
||||
if (!back) back = std::make_unique<Node>();
|
||||
back->build(b);
|
||||
back->build(b, depth + 1);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
@ -171,7 +186,7 @@ Mesh meshOf(const std::vector<Poly>& polys) {
|
|||
const int v = *it;
|
||||
if (v == a || v == b) continue;
|
||||
const float t = glm::dot(m.verts[size_t(v)] - pa, d) / len2;
|
||||
if (t <= 1e-4f || t >= 1 - 1e-4f) continue;
|
||||
if (const float in = 2e-6f / std::sqrt(len2); t <= in || t >= 1 - in) continue; // (a µm or two from its ends: on a long edge, a part of it)
|
||||
if (const glm::vec3 off = pa + d * t - m.verts[size_t(v)]; glm::dot(off, off) < 1e-10f) on.push_back({t, v});
|
||||
}
|
||||
std::sort(on.begin(), on.end());
|
||||
|
|
@ -206,13 +221,91 @@ void removeSpikes(Mesh& m) {
|
|||
std::erase_if(m.faces, [](const std::vector<int>& f) { return f.size() < 3; });
|
||||
}
|
||||
|
||||
} // namespace
|
||||
// Gaps the cutting leaves (a tenth of a mm, where one side's rounding cut a piece the other
|
||||
// didn't): an edge with no face back along it. A small loop of them, or a sliver of next to no
|
||||
// area, gets a face of its own; the shape is closed again.
|
||||
void closeSmallGaps(Mesh& m) {
|
||||
// Two faces back to back on the same corners (a wall of no thickness): both go.
|
||||
std::map<std::vector<int>, std::vector<size_t>> byCorners;
|
||||
auto key = [](std::vector<int> f) {
|
||||
std::rotate(f.begin(), std::min_element(f.begin(), f.end()), f.end());
|
||||
return f;
|
||||
};
|
||||
for (size_t i = 0; i < m.faces.size(); ++i) byCorners[key(m.faces[i])].push_back(i);
|
||||
std::vector<bool> drop(m.faces.size(), false);
|
||||
for (size_t i = 0; i < m.faces.size(); ++i) {
|
||||
if (drop[i]) continue;
|
||||
std::vector<int> r(m.faces[i].rbegin(), m.faces[i].rend());
|
||||
if (auto it = byCorners.find(key(r)); it != byCorners.end())
|
||||
for (size_t j : it->second)
|
||||
if (j != i && !drop[j]) {
|
||||
drop[i] = drop[j] = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
for (size_t i = m.faces.size(); i-- > 0;)
|
||||
if (drop[i]) m.faces.erase(m.faces.begin() + long(i));
|
||||
|
||||
bool combine(Mesh& a, const Mesh& b, Combine op) {
|
||||
std::set<std::pair<int, int>> have;
|
||||
for (const auto& f : m.faces)
|
||||
for (size_t i = 0; i < f.size(); ++i) have.insert({f[i], f[(i + 1) % f.size()]});
|
||||
std::map<int, std::vector<int>> next; // (the open edges, a to b)
|
||||
for (auto [a, b] : have)
|
||||
if (!have.contains({b, a})) next[a].push_back(b);
|
||||
auto fill = [&](std::vector<int> loop) {
|
||||
if (loop.size() < 3) return;
|
||||
float around = 0.0f;
|
||||
glm::vec3 area(0.0f);
|
||||
for (size_t i = 0; i < loop.size(); ++i) {
|
||||
const glm::vec3 p = m.verts[size_t(loop[i])], q = m.verts[size_t(loop[(i + 1) % loop.size()])];
|
||||
around += glm::distance(p, q), area += glm::cross(p, q);
|
||||
}
|
||||
// A small one, or one next to flat: a piece the cutting lost. Else a real opening: left.
|
||||
const glm::vec3 n = glm::length(area) > 0.0f ? glm::normalize(area) : glm::vec3(0.0f);
|
||||
float bulge = 0.0f;
|
||||
for (int v : loop) bulge = std::max(bulge, std::abs(glm::dot(m.verts[size_t(v)] - m.verts[size_t(loop[0])], n)));
|
||||
if (around > 2e-3f && glm::length(area) > 2e-9f && bulge > 1e-4f) return;
|
||||
std::reverse(loop.begin(), loop.end());
|
||||
m.faces.push_back(loop);
|
||||
};
|
||||
while (!next.empty()) {
|
||||
std::vector<int> loop{next.begin()->first};
|
||||
while (!loop.empty() && loop.size() <= 64) {
|
||||
auto it = next.find(loop.back());
|
||||
if (it == next.end()) break; // (an open run, not a loop)
|
||||
const int b = it->second.back();
|
||||
it->second.pop_back();
|
||||
if (it->second.empty()) next.erase(it);
|
||||
// Back at a point already passed: the loop from there closes (two gaps meeting at a corner, a figure 8).
|
||||
if (auto at = std::find(loop.begin(), loop.end(), b); at != loop.end()) {
|
||||
fill(std::vector<int>(at, loop.end()));
|
||||
loop.erase(at + 1, loop.end());
|
||||
if (loop.size() == 1 && !next.contains(loop[0])) break;
|
||||
} else
|
||||
loop.push_back(b);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Every edge has as many faces along it one way as the other (closed; edges where two parts
|
||||
// only touch count too).
|
||||
bool sealed(const Mesh& m) {
|
||||
std::map<std::pair<int, int>, int> e;
|
||||
for (const auto& f : m.faces)
|
||||
for (size_t i = 0; i < f.size(); ++i) {
|
||||
const int a = f[i], b = f[(i + 1) % f.size()];
|
||||
a < b ? ++e[{a, b}] : --e[{b, a}];
|
||||
}
|
||||
for (auto [k, n] : e)
|
||||
if (n) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
Mesh combineOnce(const Mesh& am, const Mesh& b, Combine op) {
|
||||
Node A, B;
|
||||
A.build(polysOf(a));
|
||||
A.build(polysOf(am));
|
||||
B.build(polysOf(b));
|
||||
if (!A.hasPlane || !B.hasPlane) return false;
|
||||
if (!A.hasPlane || !B.hasPlane) return {};
|
||||
std::vector<Poly> bp;
|
||||
switch (op) {
|
||||
case Combine::Add:
|
||||
|
|
@ -230,13 +323,142 @@ bool combine(Mesh& a, const Mesh& b, Combine op) {
|
|||
}
|
||||
std::vector<Poly> out;
|
||||
A.all(out);
|
||||
a = meshOf(out);
|
||||
Mesh a = meshOf(out);
|
||||
// Points the cutting left a hair apart (closer than 0.03 mm: nothing a printer or a model
|
||||
// could show) are one; else they fold tiny faces back over their neighbours.
|
||||
mergeByDistance(a, 3e-5f);
|
||||
removeSpikes(a);
|
||||
mergeFlatFaces(a);
|
||||
removeSpikes(a);
|
||||
closeSmallGaps(a);
|
||||
return a;
|
||||
}
|
||||
|
||||
|
||||
// Manifold's way, for two closed shapes: exact, and doesn't go wrong where faces just touch.
|
||||
// Each triangle carries the face it came from, so its faces come back whole (not triangles).
|
||||
// Nothing if either isn't closed all round the way it needs (then the BSP's way).
|
||||
std::optional<Mesh> combineManifold(const Mesh& a, const Mesh& b, Combine op) {
|
||||
auto toManifold = [](const Mesh& m, uint32_t firstFace) -> std::optional<manifold::Manifold> {
|
||||
manifold::MeshGL g;
|
||||
g.numProp = 3;
|
||||
g.vertProperties.reserve(m.verts.size() * 3);
|
||||
for (const glm::vec3& v : m.verts) {
|
||||
if (!std::isfinite(v.x) || !std::isfinite(v.y) || !std::isfinite(v.z)) return std::nullopt; // (it can't take those)
|
||||
g.vertProperties.insert(g.vertProperties.end(), {v.x, v.y, v.z});
|
||||
}
|
||||
for (size_t f = 0; f < m.faces.size(); ++f) {
|
||||
const auto& fv = m.faces[f];
|
||||
if (fv.size() < 3) return std::nullopt; // (a line: not a solid)
|
||||
for (size_t i = 0; i < fv.size(); ++i) // (a point twice round a face, or one that isn't there: it can't take that either)
|
||||
if (fv[i] < 0 || size_t(fv[i]) >= m.verts.size() || std::find(fv.begin() + long(i) + 1, fv.end(), fv[i]) != fv.end()) return std::nullopt;
|
||||
for (const auto& t : triangulate(m, int(f)))
|
||||
if (t[0] == t[1] || t[1] == t[2] || t[0] == t[2]) return std::nullopt;
|
||||
else g.triVerts.insert(g.triVerts.end(), {uint32_t(t[0]), uint32_t(t[1]), uint32_t(t[2])}), g.faceID.push_back(firstFace + uint32_t(f));
|
||||
}
|
||||
if (g.triVerts.empty()) return std::nullopt;
|
||||
g.runOriginalID = {manifold::Manifold::ReserveIDs(1)}; // (so it keeps the faces given, not its own numbering)
|
||||
g.runIndex = {0, uint32_t(g.triVerts.size())};
|
||||
g.Merge(); // (points in the same place, kept apart by a seam: one)
|
||||
manifold::Manifold out(g);
|
||||
if (out.Status() != manifold::Manifold::Error::NoError) return std::nullopt;
|
||||
return out;
|
||||
};
|
||||
const auto A = toManifold(a, 0), B = toManifold(b, uint32_t(a.faces.size()));
|
||||
if (!A || !B) return std::nullopt;
|
||||
const manifold::Manifold r = op == Combine::Add ? *A + *B : op == Combine::Subtract ? *A - *B : *A ^ *B;
|
||||
if (r.Status() != manifold::Manifold::Error::NoError) return std::nullopt;
|
||||
const manifold::MeshGL g = r.GetMeshGL();
|
||||
Mesh out;
|
||||
const size_t n = g.NumVert();
|
||||
out.verts.reserve(n);
|
||||
for (size_t i = 0; i < n; ++i) out.verts.push_back({g.vertProperties[i * g.numProp], g.vertProperties[i * g.numProp + 1], g.vertProperties[i * g.numProp + 2]});
|
||||
// The triangles of each face, joined back into it: its outline, walked round (a face cut into
|
||||
// two pieces, or with a hole in it: as it comes, triangles).
|
||||
// A face of one shape lying flat against a face of the other, side by side (both cubes' tops
|
||||
// in a row): one face. A shape's own faces stay as they were.
|
||||
const size_t tris = g.triVerts.size() / 3;
|
||||
const uint32_t split = uint32_t(a.faces.size());
|
||||
std::vector<uint32_t> id(tris);
|
||||
for (size_t t = 0; t < tris; ++t) id[t] = t < g.faceID.size() ? g.faceID[t] : uint32_t(0x80000000u + t);
|
||||
std::map<uint32_t, uint32_t> parent;
|
||||
std::function<uint32_t(uint32_t)> root = [&](uint32_t x) {
|
||||
auto it = parent.find(x);
|
||||
if (it == parent.end() || it->second == x) return x;
|
||||
return it->second = root(it->second);
|
||||
};
|
||||
std::map<uint32_t, glm::vec4> plane; // (each face's: its normal and how far along it)
|
||||
for (size_t t = 0; t < tris; ++t) {
|
||||
if (plane.count(id[t])) continue;
|
||||
const glm::vec3 p0 = out.verts[g.triVerts[t * 3]], p1 = out.verts[g.triVerts[t * 3 + 1]], p2 = out.verts[g.triVerts[t * 3 + 2]];
|
||||
const glm::vec3 c = glm::cross(p1 - p0, p2 - p0);
|
||||
if (glm::length(c) > 1e-12f) plane[id[t]] = glm::vec4(glm::normalize(c), glm::dot(glm::normalize(c), p0));
|
||||
}
|
||||
float size = 0;
|
||||
for (const glm::vec3& v : out.verts) size = std::max(size, std::max(std::abs(v.x), std::max(std::abs(v.y), std::abs(v.z))));
|
||||
std::map<std::pair<int, int>, size_t> edgeTri;
|
||||
for (size_t t = 0; t < tris; ++t)
|
||||
for (int k = 0; k < 3; ++k) edgeTri[{int(g.triVerts[t * 3 + size_t(k)]), int(g.triVerts[t * 3 + size_t((k + 1) % 3)])}] = t;
|
||||
for (const auto& [e, t] : edgeTri) {
|
||||
auto o = edgeTri.find({e.second, e.first});
|
||||
if (o == edgeTri.end()) continue;
|
||||
const uint32_t x = id[t], y = id[o->second];
|
||||
if (x == y || (x < split) == (y < split) || !plane.count(x) || !plane.count(y)) continue;
|
||||
const glm::vec4 px = plane[x], py = plane[y];
|
||||
if (glm::dot(glm::vec3(px), glm::vec3(py)) > 1 - 1e-5f && std::abs(px.w - py.w) < 1e-5f * std::max(size, 1.0f)) {
|
||||
const uint32_t rx = root(x), ry = root(y);
|
||||
if (rx != ry) parent[rx] = ry, parent.emplace(ry, ry);
|
||||
}
|
||||
}
|
||||
std::map<uint32_t, std::vector<std::array<int, 3>>> faces;
|
||||
for (size_t t = 0; t < tris; ++t) faces[root(id[t])].push_back({int(g.triVerts[t * 3]), int(g.triVerts[t * 3 + 1]), int(g.triVerts[t * 3 + 2])});
|
||||
for (const auto& [id, ts] : faces) {
|
||||
std::set<std::pair<int, int>> inside;
|
||||
for (const auto& t : ts)
|
||||
for (int k = 0; k < 3; ++k) inside.insert({t[size_t(k)], t[size_t((k + 1) % 3)]});
|
||||
std::map<int, int> next;
|
||||
bool simple = true;
|
||||
for (const auto& [p, q] : inside)
|
||||
if (!inside.count({q, p})) simple &= next.emplace(p, q).second; // (an outline edge)
|
||||
std::vector<int> loop;
|
||||
if (simple && !next.empty()) {
|
||||
int at = next.begin()->first;
|
||||
do {
|
||||
loop.push_back(at);
|
||||
auto it = next.find(at);
|
||||
if (it == next.end() || loop.size() > next.size()) break;
|
||||
at = it->second;
|
||||
} while (at != loop.front());
|
||||
}
|
||||
if (simple && loop.size() == next.size() && loop.size() >= 3) out.faces.push_back(loop);
|
||||
else
|
||||
for (const auto& t : ts) out.faces.push_back({t[0], t[1], t[2]});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
bool combine(Mesh& a, const Mesh& b, Combine op) {
|
||||
if (auto m = combineManifold(a, b, op)) {
|
||||
a = std::move(*m);
|
||||
return !a.faces.empty();
|
||||
}
|
||||
Mesh out = combineOnce(a, b, op);
|
||||
// Closed going in but not coming out: the rounding went wrong somewhere. Again with the
|
||||
// other shape moved a few µm (nothing that shows), and the first that comes out closed kept.
|
||||
if (!out.faces.empty() && !sealed(out) && sealed(a) && sealed(b)) {
|
||||
static const glm::vec3 nudge[] = {{3e-6f, 5e-6f, 7e-6f}, {-6e-6f, 4e-6f, -3e-6f}, {5e-6f, -7e-6f, 4e-6f}};
|
||||
for (const glm::vec3& d : nudge) {
|
||||
Mesh moved = b;
|
||||
for (glm::vec3& v : moved.verts) v += d;
|
||||
if (Mesh again = combineOnce(a, moved, op); !again.faces.empty() && sealed(again)) {
|
||||
out = std::move(again);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
a = std::move(out);
|
||||
return !a.faces.empty();
|
||||
}
|
||||
|
||||
|
|
|
|||
136
src/bvh.cpp
Normal file
136
src/bvh.cpp
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
#include "bvh.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
|
||||
namespace fry {
|
||||
|
||||
std::optional<float> rayTriangle(const glm::vec3& origin, const glm::vec3& dir, const glm::vec3& a, const glm::vec3& b, const glm::vec3& c) {
|
||||
const glm::vec3 e1 = b - a, e2 = c - a, p = glm::cross(dir, e2);
|
||||
const float det = glm::dot(e1, p);
|
||||
if (std::abs(det) < 1e-12f) return std::nullopt;
|
||||
const float inv = 1.0f / det;
|
||||
const glm::vec3 s = origin - a;
|
||||
const float u = glm::dot(s, p) * inv;
|
||||
if (u < 0 || u > 1) return std::nullopt;
|
||||
const glm::vec3 q = glm::cross(s, e1);
|
||||
const float v = glm::dot(dir, q) * inv;
|
||||
if (v < 0 || u + v > 1) return std::nullopt;
|
||||
const float t = glm::dot(e2, q) * inv;
|
||||
return t > 1e-6f ? std::optional<float>(t) : std::nullopt;
|
||||
}
|
||||
|
||||
namespace {
|
||||
|
||||
// Where the ray goes into the box and out, or nothing (in the ray's lengths).
|
||||
bool rayBox(const glm::vec3& o, const glm::vec3& inv, const glm::vec3& lo, const glm::vec3& hi, float maxT) {
|
||||
const glm::vec3 a = (lo - o) * inv, b = (hi - o) * inv;
|
||||
const glm::vec3 n = glm::min(a, b), f = glm::max(a, b);
|
||||
const float in = std::max(std::max(n.x, n.y), std::max(n.z, 0.0f)), out = std::min(std::min(f.x, f.y), std::min(f.z, maxT));
|
||||
return in <= out;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
TriangleTree::TriangleTree(const Mesh& m, const glm::mat4& matrix) {
|
||||
points_.resize(m.verts.size());
|
||||
for (size_t i = 0; i < points_.size(); ++i) points_[i] = glm::vec3(matrix * glm::vec4(m.verts[i], 1.0f));
|
||||
for (size_t f = 0; f < m.faces.size(); ++f) {
|
||||
const auto& fv = m.faces[f];
|
||||
if (fv.size() == 3) tris_.push_back({{fv[0], fv[1], fv[2]}, int(f)}); // (the usual ones, without triangulating)
|
||||
else if (fv.size() == 4 && glm::dot(glm::cross(m.verts[size_t(fv[1])] - m.verts[size_t(fv[0])], m.verts[size_t(fv[2])] - m.verts[size_t(fv[0])]),
|
||||
glm::cross(m.verts[size_t(fv[2])] - m.verts[size_t(fv[0])], m.verts[size_t(fv[3])] - m.verts[size_t(fv[0])])) > 0)
|
||||
tris_.push_back({{fv[0], fv[1], fv[2]}, int(f)}), tris_.push_back({{fv[0], fv[2], fv[3]}, int(f)}); // (a quad with no dent)
|
||||
else
|
||||
for (const auto& t : triangulate(m, int(f))) tris_.push_back({t, int(f)});
|
||||
}
|
||||
if (!tris_.empty()) nodes_.reserve(tris_.size() * 2 / 3 + 1), build(0, int(tris_.size()));
|
||||
}
|
||||
|
||||
int TriangleTree::build(int first, int count) {
|
||||
const int at = int(nodes_.size());
|
||||
nodes_.push_back({});
|
||||
glm::vec3 lo(1e30f), hi(-1e30f), clo(1e30f), chi(-1e30f);
|
||||
for (int i = first; i < first + count; ++i) {
|
||||
glm::vec3 c(0.0f);
|
||||
for (int k : tris_[size_t(i)].v) lo = glm::min(lo, points_[size_t(k)]), hi = glm::max(hi, points_[size_t(k)]), c += points_[size_t(k)];
|
||||
clo = glm::min(clo, c / 3.0f), chi = glm::max(chi, c / 3.0f);
|
||||
}
|
||||
nodes_[size_t(at)].lo = lo, nodes_[size_t(at)].hi = hi;
|
||||
if (count <= 4) {
|
||||
nodes_[size_t(at)].first = first, nodes_[size_t(at)].count = count;
|
||||
return at;
|
||||
}
|
||||
// Split down the middle of the widest way the triangles' middles spread.
|
||||
const glm::vec3 size = chi - clo;
|
||||
const int axis = size.x > size.y && size.x > size.z ? 0 : size.y > size.z ? 1 : 2;
|
||||
auto mid = [&](const Tri& t) { return points_[size_t(t.v[0])][axis] + points_[size_t(t.v[1])][axis] + points_[size_t(t.v[2])][axis]; };
|
||||
const int half = count / 2;
|
||||
std::nth_element(tris_.begin() + first, tris_.begin() + first + half, tris_.begin() + first + count, [&](const Tri& a, const Tri& b) { return mid(a) < mid(b); });
|
||||
build(first, half);
|
||||
const int right = build(first + half, count - half);
|
||||
nodes_[size_t(at)].first = right, nodes_[size_t(at)].count = 0;
|
||||
return at;
|
||||
}
|
||||
|
||||
std::optional<TriangleTree::Hit> TriangleTree::nearest(const glm::vec3& o, const glm::vec3& d, float maxT) const {
|
||||
if (nodes_.empty()) return std::nullopt;
|
||||
const glm::vec3 inv(1.0f / d.x, 1.0f / d.y, 1.0f / d.z);
|
||||
std::optional<Hit> best;
|
||||
int stack[128], n = 0;
|
||||
stack[n++] = 0;
|
||||
while (n) {
|
||||
const Node& node = nodes_[size_t(stack[--n])];
|
||||
if (!rayBox(o, inv, node.lo, node.hi, best ? best->t : maxT)) continue;
|
||||
if (node.count) {
|
||||
for (int i = node.first; i < node.first + node.count; ++i) {
|
||||
const Tri& t = tris_[size_t(i)];
|
||||
if (auto h = rayTriangle(o, d, points_[size_t(t.v[0])], points_[size_t(t.v[1])], points_[size_t(t.v[2])]); h && *h < (best ? best->t : maxT))
|
||||
best = Hit{*h, t.face, t.v};
|
||||
}
|
||||
} else if (n + 2 <= 128) {
|
||||
stack[n++] = node.first, stack[n++] = int(&node - nodes_.data()) + 1;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
bool TriangleTree::blocked(const glm::vec3& o, const glm::vec3& d, float maxT, int skip) const {
|
||||
if (nodes_.empty()) return false;
|
||||
const glm::vec3 inv(1.0f / d.x, 1.0f / d.y, 1.0f / d.z);
|
||||
int stack[128], n = 0;
|
||||
stack[n++] = 0;
|
||||
while (n) {
|
||||
const Node& node = nodes_[size_t(stack[--n])];
|
||||
if (!rayBox(o, inv, node.lo, node.hi, maxT)) continue;
|
||||
if (node.count) {
|
||||
for (int i = node.first; i < node.first + node.count; ++i) {
|
||||
const Tri& t = tris_[size_t(i)];
|
||||
if (t.v[0] == skip || t.v[1] == skip || t.v[2] == skip) continue;
|
||||
if (auto h = rayTriangle(o, d, points_[size_t(t.v[0])], points_[size_t(t.v[1])], points_[size_t(t.v[2])]); h && *h < maxT) return true;
|
||||
}
|
||||
} else if (n + 2 <= 128) {
|
||||
stack[n++] = node.first, stack[n++] = int(&node - nodes_.data()) + 1;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
std::vector<float> wallThickness(const Mesh& m) {
|
||||
const TriangleTree tree(m, glm::mat4(1.0f));
|
||||
std::vector<float> out(m.faces.size(), 1e30f);
|
||||
for (size_t f = 0; f < m.faces.size(); ++f) {
|
||||
const auto& fv = m.faces[f];
|
||||
if (fv.size() < 3) continue;
|
||||
glm::vec3 c(0.0f);
|
||||
for (int v : fv) c += m.verts[size_t(v)];
|
||||
c /= float(fv.size());
|
||||
const glm::vec3 n = faceNormal(m, int(f));
|
||||
// (a hair inside, so the face itself isn't what it meets)
|
||||
const glm::vec3 from = c - n * 1e-6f;
|
||||
if (auto h = tree.nearest(from, -n)) out[f] = h->t + 1e-6f;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
} // namespace fry
|
||||
55
src/bvh.h
Normal file
55
src/bvh.h
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
#pragma once
|
||||
// A tree of boxes round a mesh's triangles (a bounding volume hierarchy): what a ray hits first,
|
||||
// or whether anything's in its way, without trying every triangle.
|
||||
#include "mesh.h"
|
||||
|
||||
#include <glm/glm.hpp>
|
||||
|
||||
#include <array>
|
||||
#include <cstdint>
|
||||
#include <optional>
|
||||
#include <vector>
|
||||
|
||||
namespace fry {
|
||||
|
||||
class TriangleTree {
|
||||
public:
|
||||
TriangleTree() = default;
|
||||
// The mesh's triangles, its points moved by `matrix` (into the world).
|
||||
TriangleTree(const Mesh& m, const glm::mat4& matrix);
|
||||
|
||||
struct Hit {
|
||||
float t = 0;
|
||||
int face = -1; // (the mesh's face it's part of)
|
||||
std::array<int, 3> tri{}; // (its points)
|
||||
};
|
||||
// The nearest hit along the ray (dir needn't be unit length: t is in its lengths), before maxT.
|
||||
std::optional<Hit> nearest(const glm::vec3& origin, const glm::vec3& dir, float maxT = 1e30f) const;
|
||||
// Anything in the way before maxT, but triangles with point `skip` (the point being looked at).
|
||||
bool blocked(const glm::vec3& origin, const glm::vec3& dir, float maxT, int skip = -1) const;
|
||||
bool empty() const { return tris_.empty(); }
|
||||
const std::vector<glm::vec3>& points() const { return points_; } // (in the world)
|
||||
|
||||
private:
|
||||
struct Tri {
|
||||
std::array<int, 3> v;
|
||||
int face;
|
||||
};
|
||||
struct Node {
|
||||
glm::vec3 lo, hi;
|
||||
int first, count; // (a leaf: its triangles; else count 0 and `first` is the right child, the left one next)
|
||||
};
|
||||
std::vector<glm::vec3> points_;
|
||||
std::vector<Tri> tris_;
|
||||
std::vector<Node> nodes_;
|
||||
int build(int first, int count);
|
||||
};
|
||||
|
||||
// How thick the shape is at each face: from its middle straight in (against its normal) to the
|
||||
// surface on the other side (1e30 where nothing's there: an open shape, a face turned in).
|
||||
std::vector<float> wallThickness(const Mesh& m);
|
||||
|
||||
// Where a ray meets a triangle (Möller–Trumbore), in the ray's lengths.
|
||||
std::optional<float> rayTriangle(const glm::vec3& origin, const glm::vec3& dir, const glm::vec3& a, const glm::vec3& b, const glm::vec3& c);
|
||||
|
||||
} // namespace fry
|
||||
179
src/commands.cpp
Normal file
179
src/commands.cpp
Normal file
|
|
@ -0,0 +1,179 @@
|
|||
#include "commands.h"
|
||||
|
||||
#include <imgui_internal.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cctype>
|
||||
#include <cmath>
|
||||
#include <numbers>
|
||||
|
||||
namespace fry {
|
||||
namespace {
|
||||
|
||||
std::string lower(std::string s) {
|
||||
for (char& c : s) c = char(std::tolower(static_cast<unsigned char>(c)));
|
||||
return s;
|
||||
}
|
||||
|
||||
// The keys a chord can be made of (letters, numbers, F keys, the keypad and the rest).
|
||||
bool chordKey(ImGuiKey k) {
|
||||
return (k >= ImGuiKey_Tab && k <= ImGuiKey_KeypadEqual) && !(k >= ImGuiKey_LeftCtrl && k <= ImGuiKey_Menu) && k != ImGuiKey_Escape;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
std::string chordName(const Chord& c) {
|
||||
if (!c) return "";
|
||||
std::string s;
|
||||
if (c.ctrl) s += "Ctrl+";
|
||||
if (c.shift) s += "Shift+";
|
||||
if (c.alt) s += "Alt+";
|
||||
return s + ImGui::GetKeyName(c.key);
|
||||
}
|
||||
|
||||
std::optional<Chord> parseChord(const std::string& text) {
|
||||
Chord c;
|
||||
std::string rest = text;
|
||||
for (;;) {
|
||||
if (rest.starts_with("Ctrl+")) c.ctrl = true, rest.erase(0, 5);
|
||||
else if (rest.starts_with("Shift+")) c.shift = true, rest.erase(0, 6);
|
||||
else if (rest.starts_with("Alt+")) c.alt = true, rest.erase(0, 4);
|
||||
else break;
|
||||
}
|
||||
for (int k = ImGuiKey_NamedKey_BEGIN; k < ImGuiKey_NamedKey_END; ++k)
|
||||
if (chordKey(ImGuiKey(k)) && rest == ImGui::GetKeyName(ImGuiKey(k))) {
|
||||
c.key = ImGuiKey(k);
|
||||
return c;
|
||||
}
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
std::optional<Chord> pressedChord() {
|
||||
const ImGuiIO& io = ImGui::GetIO();
|
||||
for (int k = ImGuiKey_NamedKey_BEGIN; k < ImGuiKey_NamedKey_END; ++k)
|
||||
if (chordKey(ImGuiKey(k)) && ImGui::IsKeyPressed(ImGuiKey(k), false)) return Chord{ImGuiKey(k), io.KeyCtrl, io.KeyShift, io.KeyAlt};
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
int commandScore(const Command& c, const std::string& query) {
|
||||
const std::string name = lower(c.name), all = name + " " + lower(c.group) + " " + lower(c.words) + " " + lower(c.keys);
|
||||
int score = 1;
|
||||
size_t from = 0;
|
||||
bool any = false;
|
||||
const std::string q = lower(query);
|
||||
while (from < q.size()) {
|
||||
const size_t space = q.find(' ', from);
|
||||
const std::string word = q.substr(from, space == std::string::npos ? std::string::npos : space - from);
|
||||
from = space == std::string::npos ? q.size() : space + 1;
|
||||
if (word.empty()) continue;
|
||||
any = true;
|
||||
const size_t at = all.find(word);
|
||||
if (at == std::string::npos) return 0;
|
||||
const bool wordStart = at == 0 || !std::isalnum(static_cast<unsigned char>(all[at - 1]));
|
||||
score += (at < name.size() ? 20 : 6) + (wordStart ? 10 : 0) + (at == 0 ? 15 : 0);
|
||||
}
|
||||
if (any && name == q) score += 50;
|
||||
return score;
|
||||
}
|
||||
|
||||
int CommandSearch::draw(const std::vector<Command>& all, const std::vector<std::string>& recent) {
|
||||
if (!open) return -1;
|
||||
const ImGuiViewport* vp = ImGui::GetMainViewport();
|
||||
const float w = std::min(560.0f, vp->WorkSize.x - 32.0f);
|
||||
ImGui::SetNextWindowPos(ImVec2(vp->WorkPos.x + vp->WorkSize.x * 0.5f, vp->WorkPos.y + vp->WorkSize.y * 0.12f), ImGuiCond_Always, ImVec2(0.5f, 0.0f));
|
||||
ImGui::SetNextWindowSize(ImVec2(w, 0));
|
||||
if (!ImGui::IsPopupOpen("##commands")) ImGui::OpenPopup("##commands");
|
||||
int run = -1;
|
||||
if (!ImGui::BeginPopup("##commands", ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoSavedSettings)) {
|
||||
open = false;
|
||||
return -1;
|
||||
}
|
||||
// F3 or Ctrl+Space again: closed (not the press that opened it)
|
||||
if (!focus && (ImGui::IsKeyPressed(ImGuiKey_F3, false) || (ImGui::GetIO().KeyCtrl && ImGui::IsKeyPressed(ImGuiKey_Space, false)))) open = false;
|
||||
if (focus) ImGui::SetKeyboardFocusHere(), focus = false;
|
||||
ImGui::SetNextItemWidth(-FLT_MIN);
|
||||
ImGui::InputTextWithHint("##find", "Type what you want to do: round edges, hole, export STL...", text, sizeof text);
|
||||
// What fits, best first (nothing typed: the ones used lately, then the rest A to Z).
|
||||
std::vector<std::pair<int, int>> found; // (score, index)
|
||||
const std::string q = text;
|
||||
for (size_t i = 0; i < all.size(); ++i) {
|
||||
int s = q.empty() ? 1 : commandScore(all[i], q);
|
||||
if (!s) continue;
|
||||
if (const auto r = std::find(recent.begin(), recent.end(), all[i].id); r != recent.end()) s += 40 - int(r - recent.begin()); // (used lately: higher)
|
||||
if (!all[i].usable()) s -= 30;
|
||||
found.push_back({s, int(i)});
|
||||
}
|
||||
std::stable_sort(found.begin(), found.end(), [](const auto& a, const auto& b) { return a.first > b.first; });
|
||||
if (found.size() > 60) found.resize(60);
|
||||
at = std::clamp(at, 0, std::max(0, int(found.size()) - 1));
|
||||
if (ImGui::IsKeyPressed(ImGuiKey_DownArrow)) at = std::min(at + 1, int(found.size()) - 1);
|
||||
if (ImGui::IsKeyPressed(ImGuiKey_UpArrow)) at = std::max(at - 1, 0);
|
||||
if (ImGui::IsItemEdited()) at = 0;
|
||||
const bool enter = ImGui::IsKeyPressed(ImGuiKey_Enter) || ImGui::IsKeyPressed(ImGuiKey_KeypadEnter);
|
||||
const float rowH = ImGui::GetTextLineHeightWithSpacing() + 4;
|
||||
ImGui::BeginChild("##list", ImVec2(0, std::min(float(found.size()), 12.0f) * rowH + 6), ImGuiChildFlags_None);
|
||||
for (int n = 0; n < int(found.size()); ++n) {
|
||||
const Command& c = all[size_t(found[size_t(n)].second)];
|
||||
ImGui::PushID(n);
|
||||
ImGui::BeginDisabled(!c.usable());
|
||||
const ImVec2 p = ImGui::GetCursorScreenPos();
|
||||
if (ImGui::Selectable("##c", n == at, ImGuiSelectableFlags_None, ImVec2(0, rowH - 4))) run = found[size_t(n)].second;
|
||||
if (n == at && (ImGui::IsKeyPressed(ImGuiKey_DownArrow) || ImGui::IsKeyPressed(ImGuiKey_UpArrow))) ImGui::SetScrollHereY();
|
||||
ImDrawList* dl = ImGui::GetWindowDrawList();
|
||||
const float ty = p.y + (rowH - 4 - ImGui::GetFontSize()) * 0.5f;
|
||||
dl->AddText(ImVec2(p.x + 6, ty), ImGui::GetColorU32(ImGuiCol_Text), c.name.c_str());
|
||||
const std::string right = c.keys.empty() ? c.group : c.group + " " + c.keys;
|
||||
const float rw = ImGui::CalcTextSize(right.c_str()).x;
|
||||
dl->AddText(ImVec2(p.x + ImGui::GetContentRegionAvail().x - rw - 6, ty), ImGui::GetColorU32(ImGuiCol_TextDisabled), right.c_str());
|
||||
ImGui::EndDisabled();
|
||||
ImGui::PopID();
|
||||
}
|
||||
if (found.empty()) ImGui::TextDisabled("Nothing called that");
|
||||
ImGui::EndChild();
|
||||
if (enter && !found.empty()) run = found[size_t(at)].second;
|
||||
if (run >= 0 && !all[size_t(run)].usable()) run = -1;
|
||||
if (run >= 0 || ImGui::IsKeyPressed(ImGuiKey_Escape)) open = false;
|
||||
if (!open) ImGui::CloseCurrentPopup();
|
||||
ImGui::EndPopup();
|
||||
return run;
|
||||
}
|
||||
|
||||
int pieMenu(const char* title, ImVec2 c, const std::vector<PieItem>& items, bool released) {
|
||||
ImDrawList* dl = ImGui::GetForegroundDrawList();
|
||||
const ImGuiIO& io = ImGui::GetIO();
|
||||
const int n = int(items.size());
|
||||
if (!n) return -2;
|
||||
const float radius = std::max(110.0f, ImGui::GetFontSize() * 8.5f), dead = 26.0f;
|
||||
const ImVec2 d(io.MousePos.x - c.x, io.MousePos.y - c.y);
|
||||
const float far = std::hypot(d.x, d.y);
|
||||
int hot = -1;
|
||||
if (far > dead) { // (the slice the mouse points into; the first at the top, going round clockwise)
|
||||
const float a = std::atan2(d.x, -d.y), slice = 2 * std::numbers::pi_v<float> / float(n);
|
||||
hot = int(std::floor((a + slice * 0.5f + 2 * std::numbers::pi_v<float>) / slice)) % n;
|
||||
}
|
||||
const ImU32 bg = ImGui::GetColorU32(ImGuiCol_PopupBg), text = ImGui::GetColorU32(ImGuiCol_Text), hi = ImGui::GetColorU32(ImGuiCol_ButtonActive),
|
||||
line = ImGui::GetColorU32(ImGuiCol_Border);
|
||||
dl->AddCircleFilled(c, dead, bg, 32);
|
||||
dl->AddCircle(c, dead, line, 32, 1.5f);
|
||||
if (far > dead) dl->AddLine(c, ImVec2(c.x + d.x / far * dead, c.y + d.y / far * dead), hi, 3.0f);
|
||||
const ImVec2 ts = ImGui::CalcTextSize(title);
|
||||
dl->AddText(ImVec2(c.x - ts.x * 0.5f, c.y + dead + 6), ImGui::GetColorU32(ImGuiCol_TextDisabled), title);
|
||||
const float h = ImGui::GetFrameHeight() + 6;
|
||||
for (int i = 0; i < n; ++i) {
|
||||
const float a = 2 * std::numbers::pi_v<float> * float(i) / float(n);
|
||||
const ImVec2 at(c.x + std::sin(a) * radius, c.y - std::cos(a) * radius);
|
||||
const float tw = ImGui::CalcTextSize(items[size_t(i)].name.c_str()).x, w = tw + (items[size_t(i)].hasIcon ? h : 0) + 20;
|
||||
const ImVec2 lo(at.x - w * 0.5f, at.y - h * 0.5f), up(at.x + w * 0.5f, at.y + h * 0.5f);
|
||||
const bool hover = i == hot || ImGui::IsMouseHoveringRect(lo, up, false);
|
||||
if (hover) hot = i;
|
||||
dl->AddRectFilled(lo, up, i == hot ? hi : bg, h * 0.5f);
|
||||
dl->AddRect(lo, up, items[size_t(i)].on ? hi : line, h * 0.5f, 0, items[size_t(i)].on ? 2.0f : 1.0f);
|
||||
float x = lo.x + 10;
|
||||
if (items[size_t(i)].hasIcon) drawIcon(dl, items[size_t(i)].icon, ImVec2(x, lo.y + 4), h - 8, text), x += h - 2;
|
||||
dl->AddText(ImVec2(x, at.y - ImGui::GetFontSize() * 0.5f), text, items[size_t(i)].name.c_str());
|
||||
}
|
||||
if (released || ImGui::IsMouseClicked(ImGuiMouseButton_Left)) return hot >= 0 ? hot : -2;
|
||||
return -1;
|
||||
}
|
||||
|
||||
} // namespace fry
|
||||
64
src/commands.h
Normal file
64
src/commands.h
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
#pragma once
|
||||
// Commands: everything the app can do, by name. Found by typing (the command search, F3 or
|
||||
// Ctrl+Space), put on keys of your own (Keys and mouse), and offered round the mouse (pie menus).
|
||||
#include "icons.h"
|
||||
|
||||
#include <imgui.h>
|
||||
|
||||
#include <functional>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace fry {
|
||||
|
||||
struct Command {
|
||||
std::string id; // (stays the same: what keys of your own are kept against)
|
||||
std::string name; // what it's called ("Round edges")
|
||||
std::string group; // where it lives ("Shape", "View")
|
||||
std::string keys; // its own key, as shown ("B")
|
||||
std::function<void()> run;
|
||||
std::function<bool()> ok; // (can it be done now; none: always)
|
||||
std::string words; // more to find it by ("bevel chamfer")
|
||||
bool usable() const { return !ok || ok(); }
|
||||
};
|
||||
|
||||
// A key with what's held with it.
|
||||
struct Chord {
|
||||
ImGuiKey key = ImGuiKey_None;
|
||||
bool ctrl = false, shift = false, alt = false;
|
||||
bool operator==(const Chord&) const = default;
|
||||
explicit operator bool() const { return key != ImGuiKey_None; }
|
||||
};
|
||||
std::string chordName(const Chord& c);
|
||||
std::optional<Chord> parseChord(const std::string& text);
|
||||
// The chord pressed this frame (a key that isn't only Ctrl / Shift / Alt), or none.
|
||||
std::optional<Chord> pressedChord();
|
||||
|
||||
// How well `query` fits a command (0: not at all): every word of it in the name, group or
|
||||
// words; the start of a word, and the name, count most.
|
||||
int commandScore(const Command& c, const std::string& query);
|
||||
|
||||
// The command search: a box to type in and the commands that fit, best first; Enter or a click
|
||||
// runs one. `recent`: ids run lately (first when nothing's typed yet). True while it's open.
|
||||
struct CommandSearch {
|
||||
bool open = false;
|
||||
char text[128] = {};
|
||||
int at = 0;
|
||||
bool focus = false;
|
||||
void show() { open = true, focus = true, text[0] = 0, at = 0; }
|
||||
// Draws it; the command to run (its index), or -1.
|
||||
int draw(const std::vector<Command>& all, const std::vector<std::string>& recent);
|
||||
};
|
||||
|
||||
// A pie menu: the choices round the mouse, picked by moving towards one and letting go of the
|
||||
// key (or clicking). Draws it; the index picked, -1 while choosing, -2 when let go on nothing.
|
||||
struct PieItem {
|
||||
std::string name;
|
||||
Icon icon = Icon::Select;
|
||||
bool hasIcon = true;
|
||||
bool on = false;
|
||||
};
|
||||
int pieMenu(const char* title, ImVec2 centre, const std::vector<PieItem>& items, bool released);
|
||||
|
||||
} // namespace fry
|
||||
|
|
@ -1,25 +0,0 @@
|
|||
// Windows: the program's icon and details (Explorer's Properties).
|
||||
1 ICON "../branding/fry3dx.ico"
|
||||
1 VERSIONINFO
|
||||
FILEVERSION 0,5,0,0
|
||||
PRODUCTVERSION 0,5,0,0
|
||||
BEGIN
|
||||
BLOCK "StringFileInfo"
|
||||
BEGIN
|
||||
BLOCK "040904b0"
|
||||
BEGIN
|
||||
VALUE "CompanyName", "Nexoniarz"
|
||||
VALUE "FileDescription", "Fry3DX - Frying 3D Extreme"
|
||||
VALUE "FileVersion", "0.5.0"
|
||||
VALUE "InternalName", "fry3dx"
|
||||
VALUE "LegalCopyright", "Copyright 2026 Nexoniarz, Apache License 2.0"
|
||||
VALUE "OriginalFilename", "fry3dx.exe"
|
||||
VALUE "ProductName", "Fry3DX"
|
||||
VALUE "ProductVersion", "0.5.0"
|
||||
END
|
||||
END
|
||||
BLOCK "VarFileInfo"
|
||||
BEGIN
|
||||
VALUE "Translation", 0x409, 1200
|
||||
END
|
||||
END
|
||||
|
|
@ -213,6 +213,24 @@ void drawIcon(ImDrawList* dl, Icon icon, ImVec2 o, float s, ImU32 c) {
|
|||
line(0.62f, 0.34f, 0.84f, 0.16f, t * 0.8f), tri(0.92f, 0.08f, 0.78f, 0.12f, 0.88f, 0.22f);
|
||||
break;
|
||||
}
|
||||
case Icon::Eye: case Icon::EyeShut: // an eye: open, or shut with its lashes
|
||||
if (icon == Icon::Eye) {
|
||||
dl->AddBezierQuadratic(P(0.04f, 0.5f), P(0.5f, 0.02f), P(0.96f, 0.5f), c, t);
|
||||
dl->AddBezierQuadratic(P(0.04f, 0.5f), P(0.5f, 0.98f), P(0.96f, 0.5f), c, t);
|
||||
dot(0.5f, 0.5f, 0.15f);
|
||||
} else {
|
||||
dl->AddBezierQuadratic(P(0.04f, 0.42f), P(0.5f, 0.86f), P(0.96f, 0.42f), c, t);
|
||||
line(0.26f, 0.60f, 0.18f, 0.76f), line(0.5f, 0.66f, 0.5f, 0.84f), line(0.74f, 0.60f, 0.82f, 0.76f);
|
||||
}
|
||||
break;
|
||||
case Icon::Lock: case Icon::Unlock: { // a padlock: the shackle down in it, or up and open
|
||||
dl->AddRectFilled(P(0.24f, 0.46f), P(0.76f, 0.88f), c, s * 0.08f);
|
||||
const float lift = icon == Icon::Unlock ? 0.12f : 0.0f;
|
||||
dl->PathArcTo(P(0.5f, 0.38f - lift), 0.17f * s, 3.14159f, 3.14159f * 2.0f, 12);
|
||||
dl->PathStroke(c, 0, t);
|
||||
line(0.33f, 0.38f - lift, 0.33f, 0.47f - (lift > 0 ? lift : 0)), line(0.67f, 0.38f - lift, 0.67f, 0.47f);
|
||||
break;
|
||||
}
|
||||
case Icon::LayFlat:
|
||||
quad(0.22f, 0.40f, 0.62f, 0.22f, 0.86f, 0.50f, 0.46f, 0.68f, false, c);
|
||||
line(0.10f, 0.88f, 0.90f, 0.88f);
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
|
||||
namespace fry {
|
||||
|
||||
enum class Icon { Select, Move, Rotate, Scale, Draw, Extrude, Inset, Bevel, LoopCut, Objects, Shape, Points, Edges, Faces, Box, Brush, Lasso, Add, Knife, Hole, PaintBrush, Eraser, Fill, Picker, Picture, Render, Layout, Print, Ruler, LayFlat, Sculpt, Inflate, Smooth, Flatten, Pinch, Grab };
|
||||
enum class Icon { Select, Move, Rotate, Scale, Draw, Extrude, Inset, Bevel, LoopCut, Objects, Shape, Points, Edges, Faces, Box, Brush, Lasso, Add, Knife, Hole, PaintBrush, Eraser, Fill, Picker, Picture, Render, Layout, Print, Ruler, LayFlat, Sculpt, Inflate, Smooth, Flatten, Pinch, Grab, Eye, EyeShut, Lock, Unlock };
|
||||
|
||||
void drawIcon(ImDrawList* dl, Icon icon, ImVec2 topLeft, float size, ImU32 color);
|
||||
|
||||
|
|
|
|||
454
src/import.cpp
Normal file
454
src/import.cpp
Normal file
|
|
@ -0,0 +1,454 @@
|
|||
#include "import.h"
|
||||
|
||||
#define CGLTF_IMPLEMENTATION
|
||||
#include <cgltf.h>
|
||||
|
||||
#include <glm/gtc/type_ptr.hpp>
|
||||
|
||||
#include <algorithm>
|
||||
#include <charconv>
|
||||
#include <cmath>
|
||||
#include <cstring>
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <functional>
|
||||
#include <map>
|
||||
#include <set>
|
||||
#include <sstream>
|
||||
|
||||
namespace fry {
|
||||
|
||||
// ───────────────────────────── DEFLATE (as zlib's puff: small, not fast) ─────────────────────────────
|
||||
|
||||
namespace {
|
||||
|
||||
struct Bits {
|
||||
const uint8_t* in;
|
||||
size_t size, at = 0;
|
||||
uint32_t buf = 0;
|
||||
int count = 0;
|
||||
bool bad = false;
|
||||
int need(int n) {
|
||||
while (count < n) {
|
||||
if (at >= size) return bad = true, 0;
|
||||
buf |= uint32_t(in[at++]) << count, count += 8;
|
||||
}
|
||||
const int v = int(buf & ((1u << n) - 1));
|
||||
buf >>= n, count -= n;
|
||||
return v;
|
||||
}
|
||||
};
|
||||
|
||||
struct Huffman {
|
||||
short count[16] = {}, symbol[288] = {};
|
||||
// From each symbol's code length; false if they don't make a code.
|
||||
bool make(const short* lengths, int n) {
|
||||
std::fill(std::begin(count), std::end(count), 0);
|
||||
for (int s = 0; s < n; ++s) ++count[lengths[s]];
|
||||
if (count[0] == n) return true;
|
||||
int left = 1;
|
||||
for (int len = 1; len < 16; ++len)
|
||||
if ((left = left * 2 - count[len]) < 0) return false;
|
||||
short offs[16] = {};
|
||||
for (int len = 1; len < 15; ++len) offs[len + 1] = short(offs[len] + count[len]);
|
||||
for (int s = 0; s < n; ++s)
|
||||
if (lengths[s]) symbol[offs[lengths[s]]++] = short(s);
|
||||
return true;
|
||||
}
|
||||
int decode(Bits& b) const {
|
||||
int code = 0, first = 0, index = 0;
|
||||
for (int len = 1; len < 16; ++len) {
|
||||
code |= b.need(1);
|
||||
if (b.bad) return -1;
|
||||
const int c = count[len];
|
||||
if (code - c < first) return symbol[index + (code - first)];
|
||||
index += c, first += c, first <<= 1, code <<= 1;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
};
|
||||
|
||||
const short kLenBase[] = {3, 4, 5, 6, 7, 8, 9, 10, 11, 13, 15, 17, 19, 23, 27, 31, 35, 43, 51, 59, 67, 83, 99, 115, 131, 163, 195, 227, 258};
|
||||
const short kLenExtra[] = {0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 2, 2, 2, 2, 3, 3, 3, 3, 4, 4, 4, 4, 5, 5, 5, 5, 0};
|
||||
const short kDistBase[] = {1, 2, 3, 4, 5, 7, 9, 13, 17, 25, 33, 49, 65, 97, 129, 193, 257, 385, 513, 769, 1025, 1537, 2049, 3073, 4097, 6145, 8193, 12289, 16385, 24577};
|
||||
const short kDistExtra[] = {0, 0, 0, 0, 1, 1, 2, 2, 3, 3, 4, 4, 5, 5, 6, 6, 7, 7, 8, 8, 9, 9, 10, 10, 11, 11, 12, 12, 13, 13};
|
||||
|
||||
bool codes(Bits& b, std::vector<uint8_t>& out, const Huffman& lit, const Huffman& dist) {
|
||||
for (;;) {
|
||||
int s = lit.decode(b);
|
||||
if (s < 0) return false;
|
||||
if (s < 256) out.push_back(uint8_t(s));
|
||||
else if (s == 256) return true;
|
||||
else {
|
||||
s -= 257;
|
||||
if (s >= 29) return false;
|
||||
const int len = kLenBase[s] + b.need(kLenExtra[s]);
|
||||
const int d = dist.decode(b);
|
||||
if (d < 0 || d >= 30) return false;
|
||||
const size_t back = size_t(kDistBase[d] + b.need(kDistExtra[d]));
|
||||
if (b.bad || back > out.size()) return false;
|
||||
for (int i = 0; i < len; ++i) out.push_back(out[out.size() - back]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
std::vector<uint8_t> inflate(const uint8_t* data, size_t size, size_t expect) {
|
||||
Bits b{data, size};
|
||||
std::vector<uint8_t> out;
|
||||
out.reserve(expect);
|
||||
for (bool last = false; !last;) {
|
||||
last = b.need(1);
|
||||
const int type = b.need(2);
|
||||
if (b.bad) return {};
|
||||
if (type == 0) { // stored
|
||||
b.buf = 0, b.count = 0;
|
||||
if (b.at + 4 > size) return {};
|
||||
const size_t len = size_t(data[b.at] | data[b.at + 1] << 8);
|
||||
b.at += 4;
|
||||
if (b.at + len > size) return {};
|
||||
out.insert(out.end(), data + b.at, data + b.at + len), b.at += len;
|
||||
} else if (type == 1) { // the fixed codes
|
||||
static Huffman lit, dist;
|
||||
static bool made = false;
|
||||
if (!made) {
|
||||
short l[288];
|
||||
for (int i = 0; i < 288; ++i) l[i] = short(i < 144 ? 8 : i < 256 ? 9 : i < 280 ? 7 : 8);
|
||||
lit.make(l, 288);
|
||||
for (int i = 0; i < 30; ++i) l[i] = 5;
|
||||
dist.make(l, 30), made = true;
|
||||
}
|
||||
if (!codes(b, out, lit, dist)) return {};
|
||||
} else if (type == 2) { // codes of its own
|
||||
const int nlen = b.need(5) + 257, ndist = b.need(5) + 1, ncode = b.need(4) + 4;
|
||||
static const short order[19] = {16, 17, 18, 0, 8, 7, 9, 6, 10, 5, 11, 4, 12, 3, 13, 2, 14, 1, 15};
|
||||
short lengths[320] = {};
|
||||
for (int i = 0; i < ncode; ++i) lengths[order[i]] = short(b.need(3));
|
||||
Huffman lc;
|
||||
if (b.bad || !lc.make(lengths, 19)) return {};
|
||||
for (int i = 0; i < nlen + ndist;) {
|
||||
int s = lc.decode(b);
|
||||
if (s < 0) return {};
|
||||
if (s < 16) lengths[i++] = short(s);
|
||||
else {
|
||||
short len = 0;
|
||||
int rep;
|
||||
if (s == 16) {
|
||||
if (!i) return {};
|
||||
len = lengths[i - 1], rep = 3 + b.need(2);
|
||||
} else rep = s == 17 ? 3 + b.need(3) : 11 + b.need(7);
|
||||
if (i + rep > nlen + ndist) return {};
|
||||
while (rep--) lengths[i++] = len;
|
||||
}
|
||||
}
|
||||
Huffman lit, dist;
|
||||
if (!lit.make(lengths, nlen) || !dist.make(lengths + nlen, ndist) || !codes(b, out, lit, dist)) return {};
|
||||
} else
|
||||
return {};
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ───────────────────────────── the formats ─────────────────────────────
|
||||
|
||||
namespace {
|
||||
|
||||
std::string lower(std::string s) {
|
||||
for (char& c : s) c = char(std::tolower(static_cast<unsigned char>(c)));
|
||||
return s;
|
||||
}
|
||||
float toFloat(std::string_view s) { return std::strtof(std::string(s).c_str(), nullptr); }
|
||||
|
||||
void tidy(Mesh& m) {
|
||||
mergeByDistance(m, 1e-7f);
|
||||
removeUnused(m);
|
||||
std::erase_if(m.faces, [](const std::vector<int>& f) { return f.size() < 3 || std::set<int>(f.begin(), f.end()).size() < 3; });
|
||||
}
|
||||
|
||||
std::vector<ImportedMesh> readStl(const std::vector<uint8_t>& d, const std::string& name, std::string* error) {
|
||||
Mesh m;
|
||||
const bool ascii = d.size() >= 5 && std::memcmp(d.data(), "solid", 5) == 0 && std::string_view(reinterpret_cast<const char*>(d.data()), std::min<size_t>(d.size(), 600)).find("facet") != std::string::npos;
|
||||
if (!ascii) {
|
||||
if (d.size() < 84) return *error = "That STL is too short", std::vector<ImportedMesh>{};
|
||||
uint32_t n;
|
||||
std::memcpy(&n, d.data() + 80, 4);
|
||||
if (d.size() < 84 + size_t(n) * 50) return *error = "That STL is cut short", std::vector<ImportedMesh>{};
|
||||
for (uint32_t i = 0; i < n; ++i) {
|
||||
const uint8_t* t = d.data() + 84 + size_t(i) * 50 + 12;
|
||||
std::vector<int> f;
|
||||
for (int k = 0; k < 3; ++k) {
|
||||
float v[3];
|
||||
std::memcpy(v, t + k * 12, 12);
|
||||
f.push_back(int(m.verts.size())), m.verts.push_back(glm::vec3(v[0], v[1], v[2]) * 0.001f);
|
||||
}
|
||||
m.faces.push_back(f);
|
||||
}
|
||||
} else {
|
||||
std::istringstream in(std::string(d.begin(), d.end()));
|
||||
std::string word;
|
||||
std::vector<int> f;
|
||||
while (in >> word) {
|
||||
if (word == "vertex") {
|
||||
glm::vec3 v;
|
||||
in >> v.x >> v.y >> v.z;
|
||||
f.push_back(int(m.verts.size())), m.verts.push_back(v * 0.001f);
|
||||
} else if (word == "endfacet") {
|
||||
if (f.size() >= 3) m.faces.push_back(f);
|
||||
f.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
tidy(m);
|
||||
return {{name, std::move(m)}};
|
||||
}
|
||||
|
||||
std::vector<ImportedMesh> readObj(const std::vector<uint8_t>& d, const std::string& name) {
|
||||
std::vector<glm::vec3> all;
|
||||
std::vector<ImportedMesh> out{{name, {}}};
|
||||
std::map<int, int> local; // (the file's point number: the one in this object's mesh)
|
||||
std::istringstream in(std::string(d.begin(), d.end()));
|
||||
for (std::string line; std::getline(in, line);) {
|
||||
if (line.size() < 2) continue;
|
||||
std::istringstream l(line);
|
||||
std::string what;
|
||||
l >> what;
|
||||
if (what == "v") {
|
||||
glm::vec3 v;
|
||||
l >> v.x >> v.y >> v.z;
|
||||
all.push_back(glm::vec3(v.x, -v.z, v.y)); // (OBJ's up is Y; here it's Z)
|
||||
} else if ((what == "o" || what == "g") && !out.back().mesh.faces.empty()) {
|
||||
std::string n;
|
||||
std::getline(l >> std::ws, n);
|
||||
out.push_back({n.empty() ? name : n, {}}), local.clear();
|
||||
} else if (what == "o" || what == "g") {
|
||||
std::string n;
|
||||
std::getline(l >> std::ws, n);
|
||||
if (!n.empty()) out.back().name = n;
|
||||
} else if (what == "f") {
|
||||
std::vector<int> f;
|
||||
for (std::string c; l >> c;) {
|
||||
int i = std::atoi(c.c_str());
|
||||
i = i < 0 ? int(all.size()) + i : i - 1;
|
||||
if (i < 0 || i >= int(all.size())) continue;
|
||||
auto [it, fresh] = local.try_emplace(i, int(out.back().mesh.verts.size()));
|
||||
if (fresh) out.back().mesh.verts.push_back(all[size_t(i)]);
|
||||
f.push_back(it->second);
|
||||
}
|
||||
if (f.size() >= 3) out.back().mesh.faces.push_back(f);
|
||||
}
|
||||
}
|
||||
std::erase_if(out, [](const ImportedMesh& m) { return m.mesh.faces.empty(); });
|
||||
// (no units in an OBJ: one this big is millimetres, as most made for printing are)
|
||||
float big = 0;
|
||||
for (const ImportedMesh& m : out)
|
||||
if (!m.mesh.verts.empty()) big = std::max(big, glm::length(boundsMax(m.mesh) - boundsMin(m.mesh)));
|
||||
for (ImportedMesh& m : out) {
|
||||
if (big > 20) transform(m.mesh, glm::scale(glm::mat4(1.0f), glm::vec3(0.001f)));
|
||||
tidy(m.mesh);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// A zip's file (stored or deflated), or empty.
|
||||
std::vector<uint8_t> zipFile(const std::vector<uint8_t>& z, const std::function<bool(const std::string&)>& wanted, std::string* found = nullptr) {
|
||||
auto u16 = [&](size_t at) { return at + 2 <= z.size() ? uint32_t(z[at] | z[at + 1] << 8) : 0u; };
|
||||
auto u32 = [&](size_t at) { return at + 4 <= z.size() ? u16(at) | u16(at + 2) << 16 : 0u; };
|
||||
// The central directory, from its end record (sizes are right there even when the local headers leave them out).
|
||||
size_t end = z.size() >= 22 ? z.size() - 22 : 0;
|
||||
while (end > 0 && u32(end) != 0x06054b50) --end;
|
||||
if (u32(end) != 0x06054b50) return {};
|
||||
size_t at = u32(end + 16);
|
||||
for (uint32_t n = u16(end + 10); n-- && u32(at) == 0x02014b50;) {
|
||||
const uint32_t method = u16(at + 10), packed = u32(at + 20), size = u32(at + 24), nameLen = u16(at + 28), extra = u16(at + 30), comment = u16(at + 32), local = u32(at + 42);
|
||||
const std::string name(z.begin() + long(at + 46), z.begin() + long(std::min(z.size(), at + 46 + nameLen)));
|
||||
at += 46 + nameLen + extra + comment;
|
||||
if (!wanted(name)) continue;
|
||||
const size_t data = local + 30 + u16(local + 26) + u16(local + 28);
|
||||
if (data + packed > z.size()) return {};
|
||||
if (found) *found = name;
|
||||
if (method == 0) return std::vector<uint8_t>(z.begin() + long(data), z.begin() + long(data + packed));
|
||||
if (method == 8) return inflate(z.data() + data, packed, size);
|
||||
return {};
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
// The value of attribute `a` in a tag (as text).
|
||||
std::string_view attr(std::string_view tag, std::string_view a) {
|
||||
for (size_t at = 0; (at = tag.find(a, at)) != std::string_view::npos; at += a.size()) {
|
||||
if (at > 0 && !std::isspace(static_cast<unsigned char>(tag[at - 1]))) continue;
|
||||
size_t q = at + a.size();
|
||||
while (q < tag.size() && std::isspace(static_cast<unsigned char>(tag[q]))) ++q;
|
||||
if (q >= tag.size() || tag[q] != '=') continue;
|
||||
++q;
|
||||
while (q < tag.size() && std::isspace(static_cast<unsigned char>(tag[q]))) ++q;
|
||||
if (q >= tag.size()) break;
|
||||
const char quote = tag[q];
|
||||
const size_t close = tag.find(quote, q + 1);
|
||||
if (close == std::string_view::npos) break;
|
||||
return tag.substr(q + 1, close - q - 1);
|
||||
}
|
||||
return {};
|
||||
}
|
||||
// XML's & < > " ' and &#...; made the characters again.
|
||||
std::string unescape(std::string_view s) {
|
||||
std::string out;
|
||||
for (size_t i = 0; i < s.size(); ++i) {
|
||||
const size_t semi = s[i] == '&' ? s.find(';', i) : std::string_view::npos;
|
||||
if (semi == std::string_view::npos || semi - i > 10) {
|
||||
out += s[i];
|
||||
continue;
|
||||
}
|
||||
const std::string_view e = s.substr(i + 1, semi - i - 1);
|
||||
uint32_t c = e == "amp" ? '&' : e == "lt" ? '<' : e == "gt" ? '>' : e == "quot" ? '"' : e == "apos" ? '\'' : 0;
|
||||
if (!c && e.starts_with("#x")) c = uint32_t(std::strtoul(std::string(e.substr(2)).c_str(), nullptr, 16));
|
||||
else if (!c && e.starts_with("#")) c = uint32_t(std::strtoul(std::string(e.substr(1)).c_str(), nullptr, 10));
|
||||
if (!c) {
|
||||
out += s[i];
|
||||
continue;
|
||||
}
|
||||
if (c < 0x80) out += char(c);
|
||||
else if (c < 0x800) out += char(0xC0 | c >> 6), out += char(0x80 | (c & 0x3F));
|
||||
else if (c < 0x10000) out += char(0xE0 | c >> 12), out += char(0x80 | (c >> 6 & 0x3F)), out += char(0x80 | (c & 0x3F));
|
||||
else out += char(0xF0 | c >> 18), out += char(0x80 | (c >> 12 & 0x3F)), out += char(0x80 | (c >> 6 & 0x3F)), out += char(0x80 | (c & 0x3F));
|
||||
i = semi;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
glm::mat4 transform3mf(std::string_view t) {
|
||||
if (t.empty()) return glm::mat4(1.0f);
|
||||
float v[12] = {1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0};
|
||||
std::istringstream in{std::string(t)};
|
||||
for (float& f : v) in >> f;
|
||||
glm::mat4 m(1.0f);
|
||||
for (int r = 0; r < 4; ++r)
|
||||
for (int c = 0; c < 3; ++c) m[r][c] = v[r * 3 + c];
|
||||
return m;
|
||||
}
|
||||
|
||||
std::vector<ImportedMesh> read3mf(const std::vector<uint8_t>& d, const std::string& name, std::string* error) {
|
||||
const std::vector<uint8_t> xml = zipFile(d, [](const std::string& n) { return lower(n).ends_with(".model"); });
|
||||
if (xml.empty()) return *error = "No model in that 3MF (or it's packed a way that can't be read)", std::vector<ImportedMesh>{};
|
||||
const std::string_view s(reinterpret_cast<const char*>(xml.data()), xml.size());
|
||||
float unit = 0.001f;
|
||||
if (const size_t m = s.find("<model"); m != std::string_view::npos) {
|
||||
const std::string u(attr(s.substr(m, s.find('>', m) - m), "unit"));
|
||||
unit = u == "micron" ? 1e-6f : u == "centimeter" ? 0.01f : u == "meter" ? 1.0f : u == "inch" ? 0.0254f : u == "foot" ? 0.3048f : 0.001f;
|
||||
}
|
||||
struct Obj {
|
||||
std::string name;
|
||||
Mesh mesh;
|
||||
std::vector<std::pair<int, glm::mat4>> parts;
|
||||
};
|
||||
std::map<int, Obj> objs;
|
||||
std::vector<std::pair<int, glm::mat4>> items;
|
||||
Obj* cur = nullptr;
|
||||
for (size_t at = 0; (at = s.find('<', at)) != std::string_view::npos;) {
|
||||
const size_t close = s.find('>', at);
|
||||
if (close == std::string_view::npos) break;
|
||||
const std::string_view tag = s.substr(at + 1, close - at - 1);
|
||||
at = close + 1;
|
||||
auto is = [&](std::string_view t) { return tag.starts_with(t) && tag.size() > t.size() && (std::isspace(static_cast<unsigned char>(tag[t.size()])) || tag[t.size()] == '/'); };
|
||||
if (is("object")) {
|
||||
cur = &objs[std::atoi(std::string(attr(tag, "id")).c_str())];
|
||||
cur->name = unescape(attr(tag, "name"));
|
||||
} else if (is("vertex") && cur)
|
||||
cur->mesh.verts.push_back(glm::vec3(toFloat(attr(tag, "x")), toFloat(attr(tag, "y")), toFloat(attr(tag, "z"))));
|
||||
else if (is("triangle") && cur)
|
||||
cur->mesh.faces.push_back({std::atoi(std::string(attr(tag, "v1")).c_str()), std::atoi(std::string(attr(tag, "v2")).c_str()), std::atoi(std::string(attr(tag, "v3")).c_str())});
|
||||
else if (is("component") && cur)
|
||||
cur->parts.push_back({std::atoi(std::string(attr(tag, "objectid")).c_str()), transform3mf(attr(tag, "transform"))});
|
||||
else if (is("item"))
|
||||
items.push_back({std::atoi(std::string(attr(tag, "objectid")).c_str()), transform3mf(attr(tag, "transform"))});
|
||||
}
|
||||
std::function<void(int, const glm::mat4&, Mesh&, int)> place = [&](int id, const glm::mat4& m, Mesh& into, int depth) {
|
||||
auto it = objs.find(id);
|
||||
if (it == objs.end() || depth > 8) return;
|
||||
for (const auto& f : it->second.mesh.faces)
|
||||
for (int v : f)
|
||||
if (v < 0 || v >= int(it->second.mesh.verts.size())) return;
|
||||
append(into, it->second.mesh, m);
|
||||
for (const auto& [part, pm] : it->second.parts) place(part, m * pm, into, depth + 1);
|
||||
};
|
||||
std::vector<ImportedMesh> out;
|
||||
if (items.empty())
|
||||
for (const auto& [id, o] : objs) items.push_back({id, glm::mat4(1.0f)});
|
||||
for (const auto& [id, m] : items) {
|
||||
ImportedMesh im{objs.count(id) && !objs[id].name.empty() ? objs[id].name : name, {}};
|
||||
place(id, glm::scale(glm::mat4(1.0f), glm::vec3(unit)) * m, im.mesh, 0);
|
||||
tidy(im.mesh);
|
||||
if (!im.mesh.faces.empty()) out.push_back(std::move(im));
|
||||
}
|
||||
if (out.empty()) *error = "No shapes in that 3MF";
|
||||
return out;
|
||||
}
|
||||
|
||||
std::vector<ImportedMesh> readGltf(const std::string& path, const std::string& name, std::string* error) {
|
||||
cgltf_options opt{};
|
||||
cgltf_data* data = nullptr;
|
||||
if (cgltf_parse_file(&opt, path.c_str(), &data) != cgltf_result_success || cgltf_load_buffers(&opt, data, path.c_str()) != cgltf_result_success) {
|
||||
if (data) cgltf_free(data);
|
||||
*error = "That glTF can't be read";
|
||||
return {};
|
||||
}
|
||||
std::vector<ImportedMesh> out;
|
||||
for (size_t n = 0; n < data->nodes_count; ++n) {
|
||||
const cgltf_node& node = data->nodes[n];
|
||||
if (!node.mesh) continue;
|
||||
float w[16];
|
||||
cgltf_node_transform_world(&node, w);
|
||||
const glm::mat4 m = glm::mat4(1, 0, 0, 0, 0, 0, 1, 0, 0, -1, 0, 0, 0, 0, 0, 1) * glm::make_mat4(w); // (glTF's up is Y; here it's Z)
|
||||
ImportedMesh im{node.name ? node.name : node.mesh->name ? node.mesh->name : name, {}};
|
||||
for (size_t p = 0; p < node.mesh->primitives_count; ++p) {
|
||||
const cgltf_primitive& prim = node.mesh->primitives[p];
|
||||
if (prim.type != cgltf_primitive_type_triangles) continue;
|
||||
const cgltf_accessor* pos = nullptr;
|
||||
for (size_t a = 0; a < prim.attributes_count; ++a)
|
||||
if (prim.attributes[a].type == cgltf_attribute_type_position) pos = prim.attributes[a].data;
|
||||
if (!pos) continue;
|
||||
const int base = int(im.mesh.verts.size());
|
||||
for (size_t i = 0; i < pos->count; ++i) {
|
||||
float v[3] = {};
|
||||
cgltf_accessor_read_float(pos, i, v, 3);
|
||||
im.mesh.verts.push_back(glm::vec3(m * glm::vec4(v[0], v[1], v[2], 1)));
|
||||
}
|
||||
const size_t count = prim.indices ? prim.indices->count : pos->count;
|
||||
for (size_t i = 0; i + 2 < count; i += 3) {
|
||||
std::vector<int> f(3);
|
||||
for (int k = 0; k < 3; ++k) f[size_t(k)] = base + int(prim.indices ? cgltf_accessor_read_index(prim.indices, i + size_t(k)) : i + size_t(k));
|
||||
im.mesh.faces.push_back(f);
|
||||
}
|
||||
}
|
||||
tidy(im.mesh);
|
||||
if (!im.mesh.faces.empty()) out.push_back(std::move(im));
|
||||
}
|
||||
cgltf_free(data);
|
||||
if (out.empty()) *error = "No shapes in that glTF";
|
||||
return out;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
const std::vector<std::string>& importFormats() {
|
||||
static const std::vector<std::string> f = {".stl", ".obj", ".3mf", ".glb", ".gltf"};
|
||||
return f;
|
||||
}
|
||||
|
||||
std::vector<ImportedMesh> importModel(const std::string& path, std::string* error) {
|
||||
std::string dummy;
|
||||
if (!error) error = &dummy;
|
||||
const std::filesystem::path p(path);
|
||||
const std::string ext = lower(p.extension().string()), name = p.stem().string();
|
||||
if (ext == ".glb" || ext == ".gltf") return readGltf(path, name, error);
|
||||
std::ifstream in(path, std::ios::binary);
|
||||
if (!in) return *error = "Couldn't read " + p.filename().string(), std::vector<ImportedMesh>{};
|
||||
const std::vector<uint8_t> d{std::istreambuf_iterator<char>(in), {}};
|
||||
if (ext == ".stl") return readStl(d, name, error);
|
||||
if (ext == ".obj") return readObj(d, name);
|
||||
if (ext == ".3mf") return read3mf(d, name, error);
|
||||
*error = "Fry3DX can't read " + ext + " files (STL, OBJ, 3MF, glTF and GLB it can)";
|
||||
return {};
|
||||
}
|
||||
|
||||
} // namespace fry
|
||||
24
src/import.h
Normal file
24
src/import.h
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
#pragma once
|
||||
// Models made elsewhere, read in: STL, OBJ, 3MF, glTF / GLB. Each object in the file comes back
|
||||
// as its own mesh (in metres; points that share a place, one).
|
||||
#include "mesh.h"
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace fry {
|
||||
|
||||
struct ImportedMesh {
|
||||
std::string name;
|
||||
Mesh mesh;
|
||||
};
|
||||
|
||||
// The file's objects, or empty with `error` set. STL and 3MF are in millimetres: made metres.
|
||||
std::vector<ImportedMesh> importModel(const std::string& path, std::string* error = nullptr);
|
||||
// The file types it reads, for the file picker (".stl", ...).
|
||||
const std::vector<std::string>& importFormats();
|
||||
|
||||
// A raw DEFLATE stream unpacked (what a zip, so a 3MF, holds); empty if it's broken.
|
||||
std::vector<uint8_t> inflate(const uint8_t* data, size_t size, size_t expect = 0);
|
||||
|
||||
} // namespace fry
|
||||
46
src/mesh.cpp
46
src/mesh.cpp
|
|
@ -4,6 +4,7 @@
|
|||
#include <cmath>
|
||||
#include <map>
|
||||
#include <numeric>
|
||||
#include <unordered_map>
|
||||
|
||||
namespace fry {
|
||||
|
||||
|
|
@ -182,6 +183,10 @@ std::vector<std::array<int, 3>> triangulate(const Mesh& m, int face) {
|
|||
const glm::vec3 v = glm::cross(n, u);
|
||||
std::vector<glm::vec2> p;
|
||||
for (int i : f) p.emplace_back(glm::dot(m.verts[size_t(i)], u), glm::dot(m.verts[size_t(i)], v));
|
||||
// (the tests go by the face's own size: a letter 5 mm tall the same as a wall 5 m wide)
|
||||
glm::vec2 lo(1e30f), hi(-1e30f);
|
||||
for (const glm::vec2& q : p) lo = glm::min(lo, q), hi = glm::max(hi, q);
|
||||
const float span = std::max(hi.x - lo.x, hi.y - lo.y), eps = std::max(span * span * 1e-9f, 1e-30f);
|
||||
auto cross2 = [](glm::vec2 a, glm::vec2 b, glm::vec2 c) { return (b.x - a.x) * (c.y - a.y) - (b.y - a.y) * (c.x - a.x); };
|
||||
std::vector<int> left(f.size());
|
||||
std::iota(left.begin(), left.end(), 0);
|
||||
|
|
@ -189,12 +194,16 @@ std::vector<std::array<int, 3>> triangulate(const Mesh& m, int face) {
|
|||
bool cut = false;
|
||||
for (size_t i = 0; i < left.size() && !cut; ++i) {
|
||||
const int a = left[(i + left.size() - 1) % left.size()], b = left[i], c = left[(i + 1) % left.size()];
|
||||
if (cross2(p[size_t(a)], p[size_t(b)], p[size_t(c)]) <= 1e-12f) continue; // dents in, or flat
|
||||
const glm::vec2 pa = p[size_t(a)], pb = p[size_t(b)], pc = p[size_t(c)];
|
||||
if (cross2(pa, pb, pc) <= eps) continue; // dents in, or flat
|
||||
bool inside = false;
|
||||
for (int o : left) {
|
||||
if (o == a || o == b || o == c) continue;
|
||||
const glm::vec2 q = p[size_t(o)];
|
||||
if (cross2(p[size_t(a)], p[size_t(b)], q) >= 0 && cross2(p[size_t(b)], p[size_t(c)], q) >= 0 && cross2(p[size_t(c)], p[size_t(a)], q) >= 0) {
|
||||
if (q == pa || q == pb || q == pc) continue; // (the same place: where a hole's cut joins)
|
||||
// (strictly inside, or on the new edge a-c: a point on the outline's own edges a-b, b-c is no harm)
|
||||
const float ab = cross2(pa, pb, q), bc = cross2(pb, pc, q), ca = cross2(pc, pa, q);
|
||||
if (ab > eps && bc > eps && ca >= -eps) {
|
||||
inside = true;
|
||||
break;
|
||||
}
|
||||
|
|
@ -204,6 +213,11 @@ std::vector<std::array<int, 3>> triangulate(const Mesh& m, int face) {
|
|||
left.erase(left.begin() + std::ptrdiff_t(i));
|
||||
cut = true;
|
||||
}
|
||||
if (!cut) // (no ear: a point in a straight line between its neighbours goes, it covers nothing)
|
||||
for (size_t i = 0; i < left.size() && !cut; ++i) {
|
||||
const int a = left[(i + left.size() - 1) % left.size()], b = left[i], c = left[(i + 1) % left.size()];
|
||||
if (std::abs(cross2(p[size_t(a)], p[size_t(b)], p[size_t(c)])) <= eps) left.erase(left.begin() + std::ptrdiff_t(i)), cut = true;
|
||||
}
|
||||
if (!cut) { // (a tangled face: a fan of what's left)
|
||||
for (size_t i = 1; i + 1 < left.size(); ++i) out.push_back({f[size_t(left[0])], f[size_t(left[i])], f[size_t(left[i + 1])]});
|
||||
return out;
|
||||
|
|
@ -275,13 +289,14 @@ std::vector<int> extrudeFaces(Mesh& m, const std::set<int>& faces, float distanc
|
|||
}
|
||||
}
|
||||
// One flat piece moves as one, along its normal; a bent one, each point along its own.
|
||||
region = glm::length(region) > 1e-12f ? glm::normalize(region) : glm::vec3(0, 0, 1);
|
||||
// (faces facing opposite ways, a plane's two sides: theirs cancel out; the first one's way)
|
||||
region = glm::length(region) > 1e-12f ? glm::normalize(region) : faces.empty() ? glm::vec3(0, 0, 1) : faceNormal(m, *faces.begin());
|
||||
bool flat = true;
|
||||
for (int f : faces) flat &= glm::dot(faceNormal(m, f), region) > 0.99f;
|
||||
std::map<int, int> moved;
|
||||
std::vector<int> made;
|
||||
for (auto& [v, n] : along) {
|
||||
const glm::vec3 dir = flat ? region : glm::normalize(n);
|
||||
const glm::vec3 dir = flat || glm::length(n) < 1e-9f ? region : glm::normalize(n);
|
||||
moved[v] = int(m.verts.size());
|
||||
made.push_back(moved[v]);
|
||||
m.verts.push_back(m.verts[size_t(v)] + dir * distance);
|
||||
|
|
@ -363,13 +378,24 @@ bool makeFace(Mesh& m, const std::vector<int>& verts, const glm::vec3& towards)
|
|||
int mergeByDistance(Mesh& m, float distance) {
|
||||
std::vector<int> to(m.verts.size(), -1);
|
||||
std::vector<glm::vec3> kept;
|
||||
// (kept points in a grid of cubes `distance` big: one is looked for in its own and the 26
|
||||
// round it, not among all of them; an imported STL has hundreds of thousands)
|
||||
const float cell = std::max(distance, 1e-9f);
|
||||
auto cellOf = [&](const glm::vec3& p) { return glm::ivec3(glm::floor(p / cell)); };
|
||||
auto key = [](glm::ivec3 c) { return (uint64_t(uint32_t(c.x) & 0x1FFFFF) << 42) | (uint64_t(uint32_t(c.y) & 0x1FFFFF) << 21) | uint64_t(uint32_t(c.z) & 0x1FFFFF); };
|
||||
std::unordered_map<uint64_t, std::vector<int>> grid;
|
||||
grid.reserve(m.verts.size());
|
||||
for (size_t v = 0; v < m.verts.size(); ++v) {
|
||||
for (size_t k = 0; k < kept.size(); ++k)
|
||||
if (glm::distance(kept[k], m.verts[v]) <= distance) {
|
||||
to[v] = int(k);
|
||||
break;
|
||||
}
|
||||
if (to[v] < 0) to[v] = int(kept.size()), kept.push_back(m.verts[v]);
|
||||
const glm::ivec3 c = cellOf(m.verts[v]);
|
||||
int best = -1;
|
||||
for (int dx = -1; dx <= 1; ++dx)
|
||||
for (int dy = -1; dy <= 1; ++dy)
|
||||
for (int dz = -1; dz <= 1; ++dz)
|
||||
if (auto it = grid.find(key(c + glm::ivec3(dx, dy, dz))); it != grid.end())
|
||||
for (int k : it->second)
|
||||
if ((best < 0 || k < best) && glm::distance(kept[size_t(k)], m.verts[v]) <= distance) best = k; // (the first kept, as before)
|
||||
if (best < 0) best = int(kept.size()), kept.push_back(m.verts[v]), grid[key(c)].push_back(best);
|
||||
to[v] = best;
|
||||
}
|
||||
const int gone = int(m.verts.size() - kept.size());
|
||||
remapFaces(m, to);
|
||||
|
|
|
|||
32
src/mesh.h
32
src/mesh.h
|
|
@ -75,8 +75,9 @@ void bevelEdges(Mesh& m, const std::set<std::pair<int, int>>& edges, float width
|
|||
// Edges where the faces meet sharper than `angle` degrees.
|
||||
std::set<std::pair<int, int>> sharpEdges(const Mesh& m, float angle);
|
||||
// Every face cut into four-sided pieces; `smooth` rounds it (Catmull–Clark) — edges sharper
|
||||
// than `keepAngle` degrees stay sharp (180: none do).
|
||||
void subdivide(Mesh& m, bool smooth, float keepAngle = 180.0f);
|
||||
// than `keepAngle` degrees stay sharp (180: none do), and the `creases` (point pairs, the lower
|
||||
// first) too: they're made the new edges along them, for the next time round.
|
||||
void subdivide(Mesh& m, bool smooth, float keepAngle = 180.0f, std::set<std::pair<int, int>>* creases = nullptr);
|
||||
// The ring of four-sided faces across an edge, cut round the middle `cuts` times. Returns the new
|
||||
// points (empty if the edge has no four-sided face).
|
||||
std::vector<int> loopCut(Mesh& m, std::pair<int, int> edge, int cuts = 1);
|
||||
|
|
@ -150,6 +151,22 @@ int mergeFlatFaces(Mesh& m, float angle = 0.5f);
|
|||
// Made `thickness` thick: a surface gets a back and walls round its open edges; a closed shape
|
||||
// gets an inside (hollow, walls that thick). Negative: the new side outwards instead.
|
||||
void thicken(Mesh& m, float thickness);
|
||||
// Turned round an axis (through `origin`, along `axis`) by `degrees`, in `steps` pieces, rising
|
||||
// `rise` along the axis as it goes (a screw; 0: a plain spin): what it sweeps through. Its open
|
||||
// edges (or a flat shape's outline) sweep into the walls; a flat shape is its two ends, when the
|
||||
// turn doesn't come back round on itself.
|
||||
Mesh spin(const Mesh& m, const glm::vec3& origin, const glm::vec3& axis, float degrees, int steps, float rise = 0.0f);
|
||||
|
||||
// A shape as a path to put things along: its open edges chained up (the longest run; a flat
|
||||
// shape's outline, a line's points), or, a closed solid, its biggest face's outline. `closed`:
|
||||
// it comes back round to its start.
|
||||
std::vector<glm::vec3> pathOf(const Mesh& m, bool& closed);
|
||||
struct PathPoint {
|
||||
glm::vec3 at, along; // (the way the path goes there, unit length)
|
||||
};
|
||||
// `count` points evenly spaced along it by length (ends included on an open one; on a closed one
|
||||
// the last isn't on top of the first).
|
||||
std::vector<PathPoint> alongPath(const std::vector<glm::vec3>& path, bool closed, int count);
|
||||
// Two shapes (in the same space) made one: `b` added to `a`, taken out of it, or only where they
|
||||
// overlap kept. The result goes in `a`; false if nothing's left.
|
||||
enum class Combine { Add, Subtract, Overlap };
|
||||
|
|
@ -173,6 +190,17 @@ bool overhangs(const Mesh& m, int face, float overhang, float bedZ);
|
|||
int closeHoles(Mesh& m);
|
||||
// Turns faces so each piece's all face the same way, outwards; how many were turned.
|
||||
int faceOutward(Mesh& m);
|
||||
// Where a level plane at height z cuts the shape: the pieces of its outline there (a slicer's
|
||||
// layer), each from one face.
|
||||
std::vector<std::pair<glm::vec3, glm::vec3>> sliceAt(const Mesh& m, float z);
|
||||
// Made ready to print, as far as it can be without guessing: points in the same place joined (an
|
||||
// STL's corners come apart), faces with no area gone, faces there twice once (back to back: both
|
||||
// gone, an inside wall), faces turned the same way, holes closed. What it did.
|
||||
struct Repair {
|
||||
int joinedPoints = 0, emptyFaces = 0, doubledFaces = 0, turnedFaces = 0, closedHoles = 0;
|
||||
int total() const { return joinedPoints + emptyFaces + doubledFaces + turnedFaces + closedHoles; }
|
||||
};
|
||||
Repair repairForPrint(Mesh& m);
|
||||
|
||||
// ── Its picture's layout ──
|
||||
|
||||
|
|
|
|||
140
src/modify.cpp
140
src/modify.cpp
|
|
@ -1,6 +1,8 @@
|
|||
// Whole-shape changes: flat faces joined up, and a surface made thick.
|
||||
#include "mesh.h"
|
||||
|
||||
#include <glm/gtc/matrix_transform.hpp>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <map>
|
||||
|
|
@ -104,4 +106,142 @@ void thicken(Mesh& m, float thickness) {
|
|||
for (auto& f : m.faces) std::reverse(f.begin(), f.end());
|
||||
}
|
||||
|
||||
Mesh spin(const Mesh& m, const glm::vec3& origin, const glm::vec3& axis, float degrees, int steps, float rise) {
|
||||
if (m.verts.empty() || glm::length(axis) < 1e-9f) return m;
|
||||
const glm::vec3 ax = glm::normalize(axis);
|
||||
steps = std::clamp(steps, 1, 4096);
|
||||
const bool round = std::abs(std::abs(degrees) - 360.0f) < 1e-3f && std::abs(rise) < 1e-9f; // (back where it started: no ends)
|
||||
const int copies = round ? steps : steps + 1;
|
||||
const int n = int(m.verts.size());
|
||||
Mesh out;
|
||||
out.verts.reserve(size_t(n * copies));
|
||||
for (int k = 0; k < copies; ++k) {
|
||||
const float t = float(k) / float(steps);
|
||||
const glm::mat4 turn = glm::translate(glm::mat4(1.0f), origin + ax * (rise * t)) * glm::rotate(glm::mat4(1.0f), glm::radians(degrees * t), ax) * glm::translate(glm::mat4(1.0f), -origin);
|
||||
for (const glm::vec3& v : m.verts) out.verts.push_back(glm::vec3(turn * glm::vec4(v, 1.0f)));
|
||||
}
|
||||
auto at = [&](int k, int v) { return (k % copies) * n + v; };
|
||||
// What sweeps: edges with one face (a surface's rim, a flat shape's outline), each once.
|
||||
std::map<std::pair<int, int>, int> count;
|
||||
for (const auto& f : m.faces)
|
||||
for (size_t i = 0; i < f.size(); ++i) ++count[{std::min(f[i], f[(i + 1) % f.size()]), std::max(f[i], f[(i + 1) % f.size()])}];
|
||||
for (const auto& f : m.faces)
|
||||
for (size_t i = 0; i < f.size(); ++i) {
|
||||
const int a = f[i], b = f[(i + 1) % f.size()];
|
||||
if (count[{std::min(a, b), std::max(a, b)}] != 1) continue;
|
||||
for (int k = 0; k < steps; ++k) out.faces.push_back({at(k, b), at(k, a), at(k + 1, a), at(k + 1, b)});
|
||||
}
|
||||
// A closed shape turned: its own faces at each end too (it sweeps a solid). A surface: its two ends.
|
||||
if (!round)
|
||||
for (const auto& f : m.faces) {
|
||||
std::vector<int> first, last;
|
||||
for (int v : f) first.push_back(at(0, v)), last.push_back(at(steps, v));
|
||||
out.faces.push_back(first);
|
||||
std::reverse(last.begin(), last.end());
|
||||
out.faces.push_back(last);
|
||||
}
|
||||
// Points on the axis come to the same place each step: one point, and faces left with fewer than three gone.
|
||||
mergeByDistance(out, 1e-6f);
|
||||
for (auto& f : out.faces) {
|
||||
f.erase(std::unique(f.begin(), f.end()), f.end());
|
||||
while (f.size() > 1 && f.front() == f.back()) f.pop_back();
|
||||
}
|
||||
std::erase_if(out.faces, [](const std::vector<int>& f) { return f.size() < 3; });
|
||||
removeUnused(out);
|
||||
faceOutward(out);
|
||||
return out;
|
||||
}
|
||||
|
||||
} // namespace fry
|
||||
|
||||
namespace fry {
|
||||
|
||||
std::vector<glm::vec3> pathOf(const Mesh& m, bool& closed) {
|
||||
closed = false;
|
||||
std::map<std::pair<int, int>, int> count;
|
||||
for (const auto& f : m.faces)
|
||||
if (f.size() == 2) count[{std::min(f[0], f[1]), std::max(f[0], f[1])}] = 1; // (a line's piece)
|
||||
else
|
||||
for (size_t i = 0; i < f.size(); ++i) ++count[{std::min(f[i], f[(i + 1) % f.size()]), std::max(f[i], f[(i + 1) % f.size()])}];
|
||||
std::map<int, std::vector<int>> next;
|
||||
for (const auto& [e, n] : count)
|
||||
if (n == 1 && e.first != e.second) next[e.first].push_back(e.second), next[e.second].push_back(e.first);
|
||||
if (next.empty()) { // (closed all round: the biggest face's outline)
|
||||
int best = -1;
|
||||
float area = 0;
|
||||
for (size_t f = 0; f < m.faces.size(); ++f) {
|
||||
if (m.faces[f].size() < 3) continue;
|
||||
glm::vec3 n(0.0f);
|
||||
const auto& fv = m.faces[f];
|
||||
for (size_t i = 0; i < fv.size(); ++i) n += glm::cross(m.verts[size_t(fv[i])], m.verts[size_t(fv[(i + 1) % fv.size()])]);
|
||||
if (glm::length(n) > area) area = glm::length(n), best = int(f);
|
||||
}
|
||||
std::vector<glm::vec3> out;
|
||||
if (best >= 0)
|
||||
for (int v : m.faces[size_t(best)]) out.push_back(m.verts[size_t(v)]);
|
||||
closed = out.size() >= 3;
|
||||
return out;
|
||||
}
|
||||
// Each run of edges, walked from an end (or anywhere on a loop); the longest kept.
|
||||
std::set<std::pair<int, int>> used;
|
||||
std::vector<glm::vec3> best;
|
||||
bool bestClosed = false;
|
||||
float bestLength = -1;
|
||||
auto walk = [&](int from) {
|
||||
std::vector<int> chain{from};
|
||||
int at = from, prev = -1;
|
||||
bool loop = false;
|
||||
for (;;) {
|
||||
int go = -1;
|
||||
for (int n : next[at])
|
||||
if (n != prev && !used.count({std::min(at, n), std::max(at, n)})) {
|
||||
go = n;
|
||||
break;
|
||||
}
|
||||
if (go < 0) break;
|
||||
used.insert({std::min(at, go), std::max(at, go)});
|
||||
if (go == from) {
|
||||
loop = true;
|
||||
break;
|
||||
}
|
||||
chain.push_back(go), prev = at, at = go;
|
||||
}
|
||||
float length = 0;
|
||||
for (size_t i = 1; i < chain.size(); ++i) length += glm::distance(m.verts[size_t(chain[i - 1])], m.verts[size_t(chain[i])]);
|
||||
if (loop) length += glm::distance(m.verts[size_t(chain.back())], m.verts[size_t(from)]);
|
||||
if (chain.size() >= 2 && length > bestLength) {
|
||||
bestLength = length, bestClosed = loop, best.clear();
|
||||
for (int v : chain) best.push_back(m.verts[size_t(v)]);
|
||||
}
|
||||
};
|
||||
for (const auto& [v, ns] : next)
|
||||
if (ns.size() == 1) walk(v); // (ends first: open runs from their start)
|
||||
for (const auto& [v, ns] : next)
|
||||
for (int n : ns)
|
||||
if (!used.count({std::min(v, n), std::max(v, n)})) walk(v);
|
||||
closed = bestClosed;
|
||||
return best;
|
||||
}
|
||||
|
||||
std::vector<PathPoint> alongPath(const std::vector<glm::vec3>& path, bool closed, int count) {
|
||||
std::vector<PathPoint> out;
|
||||
if (path.size() < 2 || count < 1) return out;
|
||||
std::vector<glm::vec3> p = path;
|
||||
if (closed) p.push_back(path.front());
|
||||
std::vector<float> at(p.size(), 0.0f); // (the length to each point)
|
||||
for (size_t i = 1; i < p.size(); ++i) at[i] = at[i - 1] + glm::distance(p[i - 1], p[i]);
|
||||
const float total = at.back();
|
||||
if (total < 1e-12f) return out;
|
||||
for (int k = 0; k < count; ++k) {
|
||||
const float want = closed ? total * float(k) / float(count) : count == 1 ? 0.0f : total * float(k) / float(count - 1);
|
||||
size_t i = size_t(std::upper_bound(at.begin(), at.end(), want) - at.begin());
|
||||
i = std::clamp<size_t>(i, 1, p.size() - 1);
|
||||
while (i < p.size() - 1 && at[i] - at[i - 1] < 1e-12f) ++i; // (no way along a piece with no length)
|
||||
const float piece = std::max(at[i] - at[i - 1], 1e-12f), t = std::clamp((want - at[i - 1]) / piece, 0.0f, 1.0f);
|
||||
const glm::vec3 d = p[i] - p[i - 1];
|
||||
out.push_back({glm::mix(p[i - 1], p[i], t), glm::length(d) > 1e-12f ? glm::normalize(d) : glm::vec3(1, 0, 0)});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
} // namespace fry
|
||||
|
|
|
|||
|
|
@ -119,4 +119,76 @@ int faceOutward(Mesh& m) {
|
|||
return turned;
|
||||
}
|
||||
|
||||
Repair repairForPrint(Mesh& m) {
|
||||
Repair r;
|
||||
if (m.verts.empty()) return r;
|
||||
// (how near is "the same place": a millionth of its size, a micron at least)
|
||||
const float size = glm::length(boundsMax(m) - boundsMin(m));
|
||||
const size_t before = m.verts.size(), faces = m.faces.size();
|
||||
mergeByDistance(m, std::max(size * 1e-6f, 1e-6f)); // (it drops faces left with fewer than three points: counted below)
|
||||
r.joinedPoints = int(before - m.verts.size());
|
||||
// Faces with a point twice in a row, fewer than three points, or no area.
|
||||
const float tiny = std::max(size * size * 1e-12f, 1e-16f);
|
||||
for (auto& f : m.faces) {
|
||||
f.erase(std::unique(f.begin(), f.end()), f.end());
|
||||
while (f.size() > 1 && f.front() == f.back()) f.pop_back();
|
||||
}
|
||||
std::erase_if(m.faces, [&](const std::vector<int>& f) { return f.size() < 3; });
|
||||
for (size_t i = m.faces.size(); i-- > 0;)
|
||||
if (faceArea(m, int(i)) <= tiny) m.faces.erase(m.faces.begin() + std::ptrdiff_t(i));
|
||||
r.emptyFaces = int(faces - m.faces.size());
|
||||
// The same face twice: once. Back to back (the same points, the other way round): neither.
|
||||
std::map<std::vector<int>, std::vector<size_t>> same;
|
||||
for (size_t i = 0; i < m.faces.size(); ++i) {
|
||||
std::vector<int> key = m.faces[i];
|
||||
std::sort(key.begin(), key.end());
|
||||
same[key].push_back(i);
|
||||
}
|
||||
std::vector<char> drop(m.faces.size(), 0);
|
||||
auto turn = [](const std::vector<int>& f) { // (the face started at its lowest point: two that turn the same way match)
|
||||
std::vector<int> g = f;
|
||||
std::rotate(g.begin(), std::min_element(g.begin(), g.end()), g.end());
|
||||
return g;
|
||||
};
|
||||
for (const auto& [key, list] : same) {
|
||||
if (list.size() < 2) continue;
|
||||
int ways = 0, other = 0;
|
||||
const std::vector<int> first = turn(m.faces[list[0]]);
|
||||
for (size_t i : list) (turn(m.faces[i]) == first ? ways : other)++;
|
||||
if (ways == other) // (back to back: an inside wall)
|
||||
for (size_t i : list) drop[i] = 1;
|
||||
else
|
||||
for (size_t k = 1; k < list.size(); ++k) drop[list[k]] = 1;
|
||||
}
|
||||
size_t at = 0;
|
||||
for (size_t i = 0; i < m.faces.size(); ++i)
|
||||
if (!drop[i]) {
|
||||
if (at != i) m.faces[at] = std::move(m.faces[i]); // (not onto itself: that empties it)
|
||||
++at;
|
||||
}
|
||||
r.doubledFaces = int(m.faces.size() - at);
|
||||
m.faces.resize(at);
|
||||
removeUnused(m);
|
||||
r.turnedFaces = faceOutward(m);
|
||||
r.closedHoles = closeHoles(m);
|
||||
if (r.closedHoles) r.turnedFaces += faceOutward(m);
|
||||
return r;
|
||||
}
|
||||
|
||||
std::vector<std::pair<glm::vec3, glm::vec3>> sliceAt(const Mesh& m, float z) {
|
||||
std::vector<std::pair<glm::vec3, glm::vec3>> out;
|
||||
for (size_t f = 0; f < m.faces.size(); ++f)
|
||||
for (const auto& t : triangulate(m, int(f))) { // (a triangle crosses it twice or not at all; a dented face can more)
|
||||
glm::vec3 at[2];
|
||||
int n = 0;
|
||||
for (int i = 0; i < 3; ++i) {
|
||||
const glm::vec3 a = m.verts[size_t(t[size_t(i)])], b = m.verts[size_t(t[size_t((i + 1) % 3)])];
|
||||
const float da = a.z - z, db = b.z - z;
|
||||
if ((da < 0) != (db < 0) && n < 2) at[n++] = glm::mix(a, b, da / (da - db));
|
||||
}
|
||||
if (n == 2) out.push_back({at[0], at[1]});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
} // namespace fry
|
||||
|
|
|
|||
|
|
@ -113,6 +113,9 @@ void applyMaterial(Object& o, const MaterialPreset& m) {
|
|||
|
||||
Mesh finishedMesh(const Object& o, bool mirrorOnly) {
|
||||
Mesh m = o.mesh;
|
||||
std::set<std::pair<int, int>> creases;
|
||||
for (const auto& [a, b] : o.creases) // (only those still edges of its points)
|
||||
if (a >= 0 && b >= 0 && a < int(m.verts.size()) && b < int(m.verts.size())) creases.insert({std::min(a, b), std::max(a, b)});
|
||||
for (int axis = 0; axis < 3; ++axis) {
|
||||
if (!(o.mirror & (1 << axis))) continue;
|
||||
glm::vec3 s(1.0f);
|
||||
|
|
@ -127,6 +130,10 @@ Mesh finishedMesh(const Object& o, bool mirrorOnly) {
|
|||
if (std::abs(m.verts[i][axis]) < eps) to[i] = int(i - before);
|
||||
for (auto& f : m.faces)
|
||||
for (int& v : f) v = to[size_t(v)];
|
||||
for (const auto& [a, b] : std::set(creases)) { // (the creases mirrored with it)
|
||||
const int p = to[size_t(a) + before], q = to[size_t(b) + before];
|
||||
creases.insert({std::min(p, q), std::max(p, q)});
|
||||
}
|
||||
std::erase_if(m.faces, [](const std::vector<int>& f) { return std::set<int>(f.begin(), f.end()).size() < 3; });
|
||||
// (a face lying on the mirror line would now be there twice, back to back: both go)
|
||||
std::map<std::vector<int>, int> seen;
|
||||
|
|
@ -140,11 +147,23 @@ Mesh finishedMesh(const Object& o, bool mirrorOnly) {
|
|||
std::sort(k.begin(), k.end());
|
||||
return seen[k] > 1;
|
||||
});
|
||||
{ // (the creases numbered as removeUnused is about to number the points)
|
||||
std::vector<int> num(m.verts.size(), -1);
|
||||
for (const auto& f : m.faces)
|
||||
for (int v : f) num[size_t(v)] = 0;
|
||||
int next = 0;
|
||||
for (int& n : num)
|
||||
if (n == 0) n = next++;
|
||||
std::set<std::pair<int, int>> now;
|
||||
for (const auto& [a, b] : creases)
|
||||
if (num[size_t(a)] >= 0 && num[size_t(b)] >= 0) now.insert({std::min(num[size_t(a)], num[size_t(b)]), std::max(num[size_t(a)], num[size_t(b)])});
|
||||
creases = std::move(now);
|
||||
}
|
||||
removeUnused(m);
|
||||
}
|
||||
if (mirrorOnly) return m;
|
||||
if (o.round > 0.0f) bevelEdges(m, sharpEdges(m, o.roundAngle), o.round, o.roundSteps);
|
||||
for (int i = 0; i < std::min(o.smoothLevels, 4); ++i) subdivide(m, true, o.smoothKeepSharp ? 30.0f : 180.0f);
|
||||
if (o.round > 0.0f) bevelEdges(m, sharpEdges(m, o.roundAngle), o.round, o.roundSteps), creases.clear(); // (rounded: its points renumbered)
|
||||
for (int i = 0; i < std::min(o.smoothLevels, 4); ++i) subdivide(m, true, o.smoothKeepSharp ? 30.0f : 180.0f, &creases);
|
||||
return m;
|
||||
}
|
||||
|
||||
|
|
@ -205,7 +224,7 @@ std::string sceneText(const Scene& scene) {
|
|||
for (const SceneScript& s : scene.scripts) out << "script " << quoted(s.name) << " " << s.code.size() << "\n" << s.code << "\n";
|
||||
for (const Reference& r : scene.references)
|
||||
out << "reference " << quoted(r.path) << " " << axisName(r.axis) << " " << r.offset.x << " " << r.offset.y << " " << r.width << " " << r.opacity
|
||||
<< " " << r.visible << "\n";
|
||||
<< " " << r.visible << " " << r.ownViewOnly << "\n";
|
||||
// Folders by their place in the file (1 up; 0 the top), as ids change on loading.
|
||||
std::map<uint32_t, size_t> folderAt;
|
||||
for (size_t i = 0; i < scene.folders.size(); ++i) folderAt[scene.folders[i].id] = i + 1;
|
||||
|
|
@ -214,6 +233,7 @@ std::string sceneText(const Scene& scene) {
|
|||
for (const Object& o : scene.objects) {
|
||||
out << "object " << quoted(o.name) << "\n";
|
||||
if (folderAt.count(o.folder)) out << " folder " << folderAt[o.folder] << "\n";
|
||||
if (const Object* t = cutTarget(scene, o)) out << " cuts " << (t - scene.objects.data()) + 1 << "\n"; // (its place in the file)
|
||||
out << " position " << o.position.x << " " << o.position.y << " " << o.position.z << "\n";
|
||||
out << " rotation " << o.rotation.w << " " << o.rotation.x << " " << o.rotation.y << " " << o.rotation.z << "\n"; // (a quaternion: w x y z)
|
||||
out << " scale " << o.scale.x << " " << o.scale.y << " " << o.scale.z << "\n";
|
||||
|
|
@ -229,6 +249,11 @@ std::string sceneText(const Scene& scene) {
|
|||
if (o.mirror) out << " mirror " << o.mirror << "\n";
|
||||
if (o.round > 0.0f) out << " round " << o.round << " " << o.roundSteps << " " << o.roundAngle << "\n";
|
||||
if (o.smoothLevels) out << " subdivide " << o.smoothLevels << " " << o.smoothKeepSharp << "\n";
|
||||
if (!o.creases.empty()) {
|
||||
out << " crease";
|
||||
for (const auto& [a, b] : o.creases) out << " " << a << " " << b;
|
||||
out << "\n";
|
||||
}
|
||||
if (!o.pictureFile.empty()) out << " picture " << quoted(o.pictureFile) << "\n";
|
||||
for (const PictureLayer& l : o.layers) {
|
||||
if (l.file.empty()) continue;
|
||||
|
|
@ -315,6 +340,7 @@ std::optional<Scene> parseScene(std::istream& in, const std::string& path, std::
|
|||
r.path = readQuoted(l);
|
||||
std::string axis;
|
||||
l >> axis >> r.offset.x >> r.offset.y >> r.width >> r.opacity >> r.visible;
|
||||
if (!(l >> r.ownViewOnly)) r.ownViewOnly = false; // (not in files from before 0.8)
|
||||
r.axis = axis == "front" ? Axis::Front : axis == "top" ? Axis::Top : Axis::Side;
|
||||
s.references.push_back(r);
|
||||
} else if (word == "look") {
|
||||
|
|
@ -348,7 +374,7 @@ std::optional<Scene> parseScene(std::istream& in, const std::string& path, std::
|
|||
size_t at = 0;
|
||||
l >> at;
|
||||
o->folder = uint32_t(at); // (a place in the file for now)
|
||||
} else if (word == "visible") l >> o->visible;
|
||||
} else if (word == "cuts") l >> o->cuts; // (a place in the file for now) else if (word == "visible") l >> o->visible;
|
||||
else if (word == "locked") l >> o->locked;
|
||||
else if (word == "recipe") {
|
||||
l >> o->recipe;
|
||||
|
|
@ -356,6 +382,8 @@ std::optional<Scene> parseScene(std::istream& in, const std::string& path, std::
|
|||
} else if (word == "mirror") l >> o->mirror;
|
||||
else if (word == "round") l >> o->round >> o->roundSteps >> o->roundAngle;
|
||||
else if (word == "subdivide") l >> o->smoothLevels >> o->smoothKeepSharp;
|
||||
else if (word == "crease")
|
||||
for (int a, b; l >> a >> b;) o->creases.insert({std::min(a, b), std::max(a, b)});
|
||||
else if (word == "picture") o->pictureFile = readQuoted(l);
|
||||
else if (word == "layer") {
|
||||
PictureLayer p;
|
||||
|
|
@ -395,9 +423,58 @@ std::optional<Scene> parseScene(std::istream& in, const std::string& path, std::
|
|||
p = std::find_if(s.folders.begin(), s.folders.end(), [&](const Folder& g) { return g.id == p; })->parent;
|
||||
}
|
||||
for (Object& o : s.objects) o.folder = idAt(o.folder);
|
||||
// A number that isn't one (a file damaged, or edited by hand: "1e999", "nan"): nothing there.
|
||||
auto finite = [](glm::vec3& v, glm::vec3 instead) {
|
||||
if (!std::isfinite(v.x) || !std::isfinite(v.y) || !std::isfinite(v.z)) v = instead;
|
||||
};
|
||||
for (Object& o : s.objects) {
|
||||
for (glm::vec3& v : o.mesh.verts) finite(v, glm::vec3(0.0f));
|
||||
finite(o.position, glm::vec3(0.0f)), finite(o.scale, glm::vec3(1.0f));
|
||||
if (!std::isfinite(o.rotation.w) || !std::isfinite(o.rotation.x) || !std::isfinite(o.rotation.y) || !std::isfinite(o.rotation.z) || glm::length(o.rotation) < 1e-6f)
|
||||
o.rotation = glm::quat(1, 0, 0, 0);
|
||||
else
|
||||
o.rotation = glm::normalize(o.rotation);
|
||||
}
|
||||
for (Object& o : s.objects) o.cuts = o.cuts >= 1 && o.cuts <= s.objects.size() && &s.objects[o.cuts - 1] != &o ? s.objects[o.cuts - 1].id : 0u;
|
||||
return s;
|
||||
}
|
||||
|
||||
const Object* cutTarget(const Scene& s, const Object& cutter) {
|
||||
if (!cutter.cuts || cutter.cuts == cutter.id) return nullptr;
|
||||
for (const Object& o : s.objects)
|
||||
if (o.id == cutter.cuts) return &o;
|
||||
return nullptr;
|
||||
}
|
||||
bool hasCuts(const Scene& s, const Object& o) {
|
||||
for (const Object& c : s.objects)
|
||||
if (c.cuts == o.id && c.id != o.id && !c.mesh.faces.empty()) return true;
|
||||
return false;
|
||||
}
|
||||
Mesh cutMesh(const Scene& s, const Object& o, bool mirrorOnly) {
|
||||
Mesh m = o.hasFinish() ? finishedMesh(o, mirrorOnly) : o.mesh;
|
||||
const glm::mat4 into = glm::inverse(objectMatrix(o));
|
||||
for (const Object& c : s.objects) {
|
||||
if (c.cuts != o.id || c.id == o.id || c.mesh.faces.empty()) continue;
|
||||
Mesh cutter = c.hasFinish() ? finishedMesh(c) : c.mesh;
|
||||
transform(cutter, into * objectMatrix(c));
|
||||
if (Mesh cut = m; combine(cut, cutter, Combine::Subtract)) m = std::move(cut); // (one that takes everything, or fails: left out)
|
||||
}
|
||||
return m;
|
||||
}
|
||||
Scene withCutsMade(const Scene& s) {
|
||||
Scene out = s;
|
||||
for (size_t i = 0; i < out.objects.size(); ++i) {
|
||||
Object& o = out.objects[i];
|
||||
if (hasCuts(s, s.objects[i])) {
|
||||
o.mesh = cutMesh(s, s.objects[i]);
|
||||
o.mirror = 0, o.round = 0, o.smoothLevels = 0, o.creases.clear();
|
||||
o.uvs.clear(); // (its painting doesn't fit the new faces)
|
||||
}
|
||||
if (cutTarget(s, s.objects[i])) o.visible = false;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ── glTF ──
|
||||
|
||||
bool exportGltf(const std::string& path, const Scene& scene, const std::vector<std::vector<uint8_t>>& pngs, bool binary, std::string* error) {
|
||||
|
|
|
|||
14
src/scene.h
14
src/scene.h
|
|
@ -10,6 +10,7 @@
|
|||
#include <cstdint>
|
||||
#include <memory>
|
||||
#include <optional>
|
||||
#include <set>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <vector>
|
||||
|
|
@ -64,6 +65,10 @@ struct Object {
|
|||
int roundSteps = 3;
|
||||
int smoothLevels = 0;
|
||||
bool smoothKeepSharp = false;
|
||||
std::set<std::pair<int, int>> creases; // edges (points, the lower first) smoothing keeps sharp
|
||||
// An editable cut: this object is taken out of that one (its id), and stays an object you can
|
||||
// move and change (the hole follows); shown see-through, not exported or printed itself.
|
||||
uint32_t cuts = 0;
|
||||
bool hasFinish() const { return mirror || round > 0.0f || smoothLevels > 0; }
|
||||
// A painted picture on it: where each face corner sits on it (0..1), and the picture
|
||||
// (shared with the undo steps until it's painted on again). Saved beside the .fry as a PNG.
|
||||
|
|
@ -110,6 +115,7 @@ struct Reference {
|
|||
float width = 1.0f; // metres across
|
||||
float opacity = 0.6f;
|
||||
bool visible = true;
|
||||
bool ownViewOnly = false; // (shown only looking straight at it: a front drawing not in the way from the side)
|
||||
};
|
||||
|
||||
// The Render tab's light and look.
|
||||
|
|
@ -135,6 +141,14 @@ struct Scene {
|
|||
uint32_t nextId = 1;
|
||||
};
|
||||
|
||||
// Editable cuts: whether something's cut out of it, and it with them cut out (and its finishing
|
||||
// touches; only the mirror with mirrorOnly) in its own space. The scene with each cut made for
|
||||
// good and its cutters hidden, for exporting.
|
||||
const Object* cutTarget(const Scene& s, const Object& cutter); // (what it cuts, if it's there)
|
||||
bool hasCuts(const Scene& s, const Object& o);
|
||||
Mesh cutMesh(const Scene& s, const Object& o, bool mirrorOnly = false);
|
||||
Scene withCutsMade(const Scene& s);
|
||||
|
||||
// Materials to start from: a colour (unless `keepColor`: only how metal and rough it is) and
|
||||
// how metal and rough. Shown in the view (Solid and Rendered) and exported with the object.
|
||||
struct MaterialPreset {
|
||||
|
|
|
|||
|
|
@ -745,6 +745,20 @@ int fryCommand(lua_State* L) {
|
|||
p->commands.push_back(std::move(c));
|
||||
return 0;
|
||||
}
|
||||
// fry.tool{name = "Stamp", help = "...", click = function(at, normal, object) ... end}
|
||||
int fryTool(lua_State* L) {
|
||||
Plugin* p = context(L).plugin;
|
||||
if (!p) luaL_error(L, "fry.tool is for plug-ins: put this script in the plug-ins folder (Script > Plug-ins)");
|
||||
luaL_checktype(L, 1, LUA_TTABLE);
|
||||
PluginTool t;
|
||||
t.name = fieldString(L, 1, "name");
|
||||
if (t.name.empty()) luaL_error(L, "a tool needs a name");
|
||||
t.help = fieldString(L, 1, "help");
|
||||
if (lua_getfield(L, 1, "click") != LUA_TFUNCTION) luaL_error(L, "a tool needs click = function(at, normal, object) ... end");
|
||||
t.ref = luaL_ref(L, LUA_REGISTRYINDEX);
|
||||
p->tools.push_back(std::move(t));
|
||||
return 0;
|
||||
}
|
||||
int fryLook(lua_State* L) { // fry.look{sun_turn = 40, sky = false, ...}; returns the look as it is
|
||||
Look& k = scene(L).look;
|
||||
struct Num {
|
||||
|
|
@ -803,7 +817,7 @@ const luaL_Reg kFry[] = {
|
|||
{"add", fryAdd}, {"mesh", fryMesh}, {"lathe", fryLathe}, {"extrude", fryExtrude}, {"objects", fryObjects}, {"find", fryFind},
|
||||
{"selected", frySelected}, {"select", frySelect}, {"clear", fryClear}, {"group", fryGroup}, {"shapes", fryShapes}, {"look", fryLook}, {"materials", fryMaterials},
|
||||
{"vec", fryVec}, {"say", fryPrint}, {"frame", fryFrame}, {"help", fryHelp}, {"material", fryMaterial}, {"plugin", fryPlugin},
|
||||
{"command", fryCommand}, {nullptr, nullptr},
|
||||
{"command", fryCommand}, {"tool", fryTool}, {nullptr, nullptr},
|
||||
};
|
||||
|
||||
// Stops a script that's run too long (an endless loop).
|
||||
|
|
@ -945,6 +959,22 @@ ScriptResult Plugin::run(ScriptHost& host, size_t command, const std::vector<dou
|
|||
return r;
|
||||
}
|
||||
|
||||
ScriptResult Plugin::click(ScriptHost& host, size_t tool, glm::vec3 at, glm::vec3 normal, uint32_t object) {
|
||||
if (!s_->L || tool >= tools.size()) return {false, "that tool isn't there any more"};
|
||||
s_->ctx.host = &host, s_->ctx.start = std::chrono::steady_clock::now(), s_->ctx.made = 0;
|
||||
lua_State* L = s_->L;
|
||||
lua_rawgeti(L, LUA_REGISTRYINDEX, tools[tool].ref);
|
||||
pushVec(L, at), pushVec(L, normal);
|
||||
if (object && findObject(*host.scene, object)) pushObject(L, object);
|
||||
else lua_pushnil(L);
|
||||
ScriptResult r;
|
||||
r.error = call(L, 3);
|
||||
r.ok = r.error.empty();
|
||||
r.objectsMade = s_->ctx.made;
|
||||
tidySelection(host);
|
||||
return r;
|
||||
}
|
||||
|
||||
const char* scriptHelp() {
|
||||
return R"(Scripts are Lua (lua.org/manual/5.4). Sizes are metres, turns degrees, Z is up.
|
||||
|
||||
|
|
@ -976,6 +1006,7 @@ An object (o):
|
|||
o:points() o:faces() o:setpoints(points) o:setshape(points, faces) o:set(name, value) o:settings()
|
||||
|
||||
Plug-ins: scripts in the plug-ins folder (Script > Plug-ins), run when Fry3DX starts and kept.
|
||||
fry.tool{name, help, click} (plug-ins) a mouse tool in the Toolbox: click(at, normal, object) where the view's clicked
|
||||
fry.plugin{name, about} what it's called in the Plug-ins window
|
||||
fry.command{name, menu, help, settings, run} a command in a menu: menu "add", "object" or "script";
|
||||
settings {{"steps", 12}, {"rise", 0.18}} are asked for first, then run(s) gets s.steps, s.rise.
|
||||
|
|
|
|||
|
|
@ -44,6 +44,12 @@ struct PluginCommand {
|
|||
int ref = 0; // (its run function, in the plug-in's Lua)
|
||||
};
|
||||
|
||||
// A mouse tool a plug-in adds (fry.tool): with it on, a click in the view calls it with the spot.
|
||||
struct PluginTool {
|
||||
std::string name, help;
|
||||
int ref = 0; // (its click function)
|
||||
};
|
||||
|
||||
class Plugin {
|
||||
public:
|
||||
explicit Plugin(std::string file); // "stairs.lua": shows in errors, and as its name until fry.plugin{name=} says
|
||||
|
|
@ -54,9 +60,12 @@ public:
|
|||
bool load(ScriptHost& host, const std::string& code, std::string* error);
|
||||
// One of its commands, with the settings asked for (in order). Like a script: one run.
|
||||
ScriptResult run(ScriptHost& host, size_t command, const std::vector<double>& settings);
|
||||
// One of its tools, clicked: where (the world), the surface's way out there, the object (0: the ground).
|
||||
ScriptResult click(ScriptHost& host, size_t tool, glm::vec3 at, glm::vec3 normal, uint32_t object);
|
||||
|
||||
std::string file, name, about;
|
||||
std::vector<PluginCommand> commands;
|
||||
std::vector<PluginTool> tools;
|
||||
|
||||
private:
|
||||
struct State;
|
||||
|
|
|
|||
202
src/text.cpp
Normal file
202
src/text.cpp
Normal file
|
|
@ -0,0 +1,202 @@
|
|||
#include "text.h"
|
||||
|
||||
#define STBTT_STATIC
|
||||
#define STB_TRUETYPE_IMPLEMENTATION
|
||||
#include <stb_truetype.h>
|
||||
|
||||
#include <glm/gtc/matrix_transform.hpp>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
|
||||
namespace fry {
|
||||
namespace {
|
||||
|
||||
// The next character of UTF-8 text (a bad byte: itself).
|
||||
uint32_t nextChar(const std::string& s, size_t& i) {
|
||||
const auto b = [&](size_t k) { return k < s.size() ? uint32_t(uint8_t(s[k])) : 0u; };
|
||||
const uint32_t c = b(i);
|
||||
if (c < 0x80) return ++i, c;
|
||||
if ((c & 0xE0) == 0xC0) return i += 2, ((c & 0x1F) << 6) | (b(i - 1) & 0x3F);
|
||||
if ((c & 0xF0) == 0xE0) return i += 3, ((c & 0x0F) << 12) | ((b(i - 2) & 0x3F) << 6) | (b(i - 1) & 0x3F);
|
||||
if ((c & 0xF8) == 0xF0) return i += 4, ((c & 0x07) << 18) | ((b(i - 3) & 0x3F) << 12) | ((b(i - 2) & 0x3F) << 6) | (b(i - 1) & 0x3F);
|
||||
return ++i, c;
|
||||
}
|
||||
|
||||
using Contour = std::vector<glm::vec2>;
|
||||
|
||||
// A glyph's outlines (font units), its curves cut into `detail` straight pieces each.
|
||||
std::vector<Contour> glyphContours(const stbtt_fontinfo& f, int glyph, int detail) {
|
||||
stbtt_vertex* v = nullptr;
|
||||
const int n = stbtt_GetGlyphShape(&f, glyph, &v);
|
||||
std::vector<Contour> out;
|
||||
glm::vec2 at(0.0f);
|
||||
for (int i = 0; i < n; ++i) {
|
||||
const glm::vec2 to(v[i].x, v[i].y);
|
||||
switch (v[i].type) {
|
||||
case STBTT_vmove:
|
||||
out.emplace_back();
|
||||
out.back().push_back(to);
|
||||
break;
|
||||
case STBTT_vline:
|
||||
if (!out.empty()) out.back().push_back(to);
|
||||
break;
|
||||
case STBTT_vcurve: {
|
||||
const glm::vec2 c(v[i].cx, v[i].cy);
|
||||
for (int k = 1; k <= detail && !out.empty(); ++k) {
|
||||
const float t = float(k) / float(detail);
|
||||
out.back().push_back((1 - t) * (1 - t) * at + 2 * (1 - t) * t * c + t * t * to);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case STBTT_vcubic: {
|
||||
const glm::vec2 c1(v[i].cx, v[i].cy), c2(v[i].cx1, v[i].cy1);
|
||||
for (int k = 1; k <= detail + 2 && !out.empty(); ++k) {
|
||||
const float t = float(k) / float(detail + 2), u = 1 - t;
|
||||
out.back().push_back(u * u * u * at + 3 * u * u * t * c1 + 3 * u * t * t * c2 + t * t * t * to);
|
||||
}
|
||||
break;
|
||||
}
|
||||
default: break;
|
||||
}
|
||||
at = to;
|
||||
}
|
||||
stbtt_FreeShape(&f, v);
|
||||
for (Contour& c : out) {
|
||||
while (c.size() > 1 && glm::distance(c.front(), c.back()) < 1e-3f) c.pop_back();
|
||||
Contour clean; // (no point twice in a row, none in a straight line between its neighbours)
|
||||
for (const glm::vec2& p : c)
|
||||
if (clean.empty() || glm::distance(clean.back(), p) > 1e-3f) clean.push_back(p);
|
||||
c = clean;
|
||||
}
|
||||
std::erase_if(out, [](const Contour& c) { return c.size() < 3; });
|
||||
return out;
|
||||
}
|
||||
|
||||
// Its area: positive when it turns counter-clockwise.
|
||||
float area(const Contour& c) {
|
||||
float a = 0;
|
||||
for (size_t i = 0; i < c.size(); ++i) {
|
||||
const glm::vec2 p = c[i], q = c[(i + 1) % c.size()];
|
||||
a += p.x * q.y - q.x * p.y;
|
||||
}
|
||||
return a * 0.5f;
|
||||
}
|
||||
|
||||
// A glyph made solid. Fonts tell holes by the way an outline turns: the other way from the
|
||||
// letter's body. Pieces that turn the body's way are joined (a letter can be drawn as overlapping
|
||||
// strokes), then the holes cut out of what they're in.
|
||||
Mesh solidGlyph(const std::vector<Contour>& cs, float scale, glm::vec2 at, float depth) {
|
||||
if (cs.empty()) return {};
|
||||
size_t biggest = 0;
|
||||
for (size_t i = 1; i < cs.size(); ++i)
|
||||
if (std::abs(area(cs[i])) > std::abs(area(cs[biggest]))) biggest = i;
|
||||
const bool bodyTurn = area(cs[biggest]) > 0;
|
||||
auto prism = [&](const Contour& c, float thick) {
|
||||
std::vector<glm::vec3> pts;
|
||||
for (const glm::vec2& p : c) pts.push_back(glm::vec3(at + p * scale, 0.0f));
|
||||
Mesh m = extrudeOutline(pts, glm::vec3(0, 0, 1), thick);
|
||||
transform(m, glm::translate(glm::mat4(1.0f), glm::vec3(0, 0, depth * 0.5f)));
|
||||
return m;
|
||||
};
|
||||
struct Box {
|
||||
glm::vec2 lo{1e30f}, hi{-1e30f};
|
||||
bool meets(const Box& o) const { return lo.x <= o.hi.x && o.lo.x <= hi.x && lo.y <= o.hi.y && o.lo.y <= hi.y; }
|
||||
};
|
||||
std::vector<Box> box(cs.size());
|
||||
for (size_t i = 0; i < cs.size(); ++i)
|
||||
for (const glm::vec2& p : cs[i]) box[i].lo = glm::min(box[i].lo, p), box[i].hi = glm::max(box[i].hi, p);
|
||||
// The body's pieces in groups that touch (each group one solid).
|
||||
std::vector<size_t> body, holes;
|
||||
for (size_t i = 0; i < cs.size(); ++i) (area(cs[i]) > 0) == bodyTurn ? body.push_back(i) : holes.push_back(i);
|
||||
std::vector<int> group(cs.size(), -1);
|
||||
int groups = 0;
|
||||
for (size_t i : body) {
|
||||
if (group[i] >= 0) continue;
|
||||
std::vector<size_t> todo{i};
|
||||
group[i] = groups;
|
||||
while (!todo.empty()) {
|
||||
const size_t k = todo.back();
|
||||
todo.pop_back();
|
||||
for (size_t j : body)
|
||||
if (group[j] < 0 && box[k].meets(box[j])) group[j] = groups, todo.push_back(j);
|
||||
}
|
||||
++groups;
|
||||
}
|
||||
Mesh out;
|
||||
for (int g = 0; g < groups; ++g) {
|
||||
Mesh solid;
|
||||
Box gb;
|
||||
for (size_t i : body) {
|
||||
if (group[i] != g) continue;
|
||||
gb.lo = glm::min(gb.lo, box[i].lo), gb.hi = glm::max(gb.hi, box[i].hi);
|
||||
if (solid.faces.empty()) solid = prism(cs[i], depth);
|
||||
else if (Mesh joined = solid; combine(joined, prism(cs[i], depth), Combine::Add)) solid = std::move(joined);
|
||||
else append(solid, prism(cs[i], depth)); // (couldn't join: overlapping, but there)
|
||||
}
|
||||
for (size_t h : holes)
|
||||
if (gb.meets(box[h]))
|
||||
if (Mesh cut = solid; combine(cut, prism(cs[h], depth * 1.5f), Combine::Subtract)) solid = std::move(cut);
|
||||
append(out, solid);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
Mesh makeText(const std::vector<uint8_t>& font, const std::string& text, const TextStyle& style, std::string* error) {
|
||||
stbtt_fontinfo f;
|
||||
if (font.empty() || !stbtt_InitFont(&f, font.data(), stbtt_GetFontOffsetForIndex(font.data(), 0))) {
|
||||
if (error) *error = "That isn't a font that can be read (TrueType or OpenType)";
|
||||
return {};
|
||||
}
|
||||
// The size: a capital H as tall as asked.
|
||||
int x0, y0, x1, y1;
|
||||
float capital = 0;
|
||||
if (stbtt_GetCodepointBox(&f, 'H', &x0, &y0, &x1, &y1)) capital = float(y1);
|
||||
if (capital <= 0) {
|
||||
int ascent, descent, gap;
|
||||
stbtt_GetFontVMetrics(&f, &ascent, &descent, &gap);
|
||||
capital = float(ascent) * 0.7f;
|
||||
}
|
||||
const float scale = style.height / std::max(capital, 1.0f);
|
||||
// The lines, each as wide as its letters.
|
||||
std::vector<std::string> lines(1);
|
||||
for (char c : text) c == '\n' ? (void)lines.emplace_back() : lines.back().push_back(c);
|
||||
auto width = [&](const std::string& line) {
|
||||
float x = 0;
|
||||
int prev = 0;
|
||||
for (size_t i = 0; i < line.size();) {
|
||||
const int g = stbtt_FindGlyphIndex(&f, int(nextChar(line, i)));
|
||||
int adv, lsb;
|
||||
stbtt_GetGlyphHMetrics(&f, g, &adv, &lsb);
|
||||
if (prev) x += float(stbtt_GetGlyphKernAdvance(&f, prev, g)) * scale;
|
||||
x += float(adv) * scale + style.spacing * style.height;
|
||||
prev = g;
|
||||
}
|
||||
return x;
|
||||
};
|
||||
float widest = 0;
|
||||
for (const std::string& l : lines) widest = std::max(widest, width(l));
|
||||
Mesh out;
|
||||
float y = 0;
|
||||
for (const std::string& line : lines) {
|
||||
const float w = width(line);
|
||||
float x = style.align == 1 ? (widest - w) * 0.5f : style.align == 2 ? widest - w : 0.0f;
|
||||
int prev = 0;
|
||||
for (size_t i = 0; i < line.size();) {
|
||||
const int g = stbtt_FindGlyphIndex(&f, int(nextChar(line, i)));
|
||||
if (prev) x += float(stbtt_GetGlyphKernAdvance(&f, prev, g)) * scale;
|
||||
append(out, solidGlyph(glyphContours(f, g, std::max(1, style.detail)), scale, glm::vec2(x, y), style.depth));
|
||||
int adv, lsb;
|
||||
stbtt_GetGlyphHMetrics(&f, g, &adv, &lsb);
|
||||
x += float(adv) * scale + style.spacing * style.height;
|
||||
prev = g;
|
||||
}
|
||||
y -= style.height * (1.0f + std::max(0.0f, style.lineGap) + 0.4f); // (a capital, what hangs below, and the gap)
|
||||
}
|
||||
if (out.faces.empty() && error) *error = "No letters to make (only spaces?)";
|
||||
return out;
|
||||
}
|
||||
|
||||
} // namespace fry
|
||||
24
src/text.h
Normal file
24
src/text.h
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
#pragma once
|
||||
// Text as a 3D shape: the letters' outlines from a font (TrueType / OpenType), made solid.
|
||||
#include "mesh.h"
|
||||
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace fry {
|
||||
|
||||
struct TextStyle {
|
||||
float height = 0.1f; // a capital letter's height (m)
|
||||
float depth = 0.02f; // how thick the letters are (m)
|
||||
float spacing = 0.0f; // more (or less) room between letters, times the height
|
||||
float lineGap = 0.25f; // room between lines, times the height
|
||||
int align = 0; // 0 left, 1 middle, 2 right
|
||||
int detail = 6; // points along each curve of a letter
|
||||
};
|
||||
|
||||
// The letters lying on the ground (XY), the first line's base along X from 0, going up out of the
|
||||
// ground to `depth`. Lines split at '\n'. Empty, with `error` set, if the font can't be read.
|
||||
Mesh makeText(const std::vector<uint8_t>& font, const std::string& text, const TextStyle& style, std::string* error = nullptr);
|
||||
|
||||
} // namespace fry
|
||||
|
|
@ -267,11 +267,11 @@ void bevelEdges(Mesh& m, const std::set<std::pair<int, int>>& want, float width,
|
|||
|
||||
// ── Subdivide (Catmull–Clark) ──
|
||||
|
||||
void subdivide(Mesh& m, bool smooth, float keepAngle) {
|
||||
void subdivide(Mesh& m, bool smooth, float keepAngle, std::set<std::pair<int, int>>* creases) {
|
||||
if (m.faces.empty()) return;
|
||||
const auto d = directed(m);
|
||||
const std::set<Edge> sharp = keepAngle < 179.9f ? sharpEdges(m, keepAngle) : std::set<Edge>{};
|
||||
auto crease = [&](int a, int b) { return !d.count({a, b}) || !d.count({b, a}) || sharp.count(key(a, b)); };
|
||||
auto crease = [&](int a, int b) { return !d.count({a, b}) || !d.count({b, a}) || sharp.count(key(a, b)) || (creases && creases->count(key(a, b))); };
|
||||
Mesh out;
|
||||
out.verts = m.verts;
|
||||
std::vector<int> facePt(m.faces.size());
|
||||
|
|
@ -317,6 +317,23 @@ void subdivide(Mesh& m, bool smooth, float keepAngle) {
|
|||
for (size_t i = 0; i < n; ++i)
|
||||
out.faces.push_back({fv[i], edgePt[key(fv[i], fv[(i + 1) % n])], facePt[f], edgePt[key(fv[(i + n - 1) % n], fv[i])]});
|
||||
}
|
||||
if (creases) { // (each crease is now two edges, through its new middle point; numbered as removeUnused will)
|
||||
std::vector<int> to(out.verts.size(), -1);
|
||||
int next = 0;
|
||||
std::vector<bool> used(out.verts.size());
|
||||
for (const auto& f : out.faces)
|
||||
for (int v : f) used[size_t(v)] = true;
|
||||
for (size_t v = 0; v < used.size(); ++v)
|
||||
if (used[v]) to[v] = next++;
|
||||
std::set<std::pair<int, int>> now;
|
||||
for (const auto& [a, b] : *creases) {
|
||||
const auto it = edgePt.find(key(a, b));
|
||||
if (it == edgePt.end()) continue; // (not an edge any more)
|
||||
for (const auto& [p, q] : {std::pair{a, it->second}, std::pair{it->second, b}})
|
||||
if (to[size_t(p)] >= 0 && to[size_t(q)] >= 0) now.insert(key(to[size_t(p)], to[size_t(q)]));
|
||||
}
|
||||
*creases = std::move(now);
|
||||
}
|
||||
m = std::move(out);
|
||||
removeUnused(m);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,12 @@
|
|||
add_executable(mesh_test mesh_test.cpp)
|
||||
target_link_libraries(mesh_test PRIVATE fry_core)
|
||||
target_compile_definitions(mesh_test PRIVATE FRY_FONTS_DIR="${PROJECT_SOURCE_DIR}/content/engine/fonts")
|
||||
add_test(NAME mesh_test COMMAND mesh_test WORKING_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR})
|
||||
add_executable(script_test script_test.cpp)
|
||||
target_link_libraries(script_test PRIVATE fry_script)
|
||||
target_compile_definitions(script_test PRIVATE FRY_SCRIPTS_DIR="${PROJECT_SOURCE_DIR}/content/Fry3DX/scripts" FRY_PLUGINS_DIR="${PROJECT_SOURCE_DIR}/content/Fry3DX/plugins")
|
||||
add_test(NAME script_test COMMAND script_test)
|
||||
# Random shapes, edits and broken files thrown at the core: nothing may crash (the same each run).
|
||||
add_executable(fuzz_test fuzz_test.cpp)
|
||||
target_link_libraries(fuzz_test PRIVATE fry_core)
|
||||
add_test(NAME fuzz_test COMMAND fuzz_test)
|
||||
|
|
|
|||
259
tests/fuzz_test.cpp
Normal file
259
tests/fuzz_test.cpp
Normal file
|
|
@ -0,0 +1,259 @@
|
|||
// Shaking the modelling core: random shapes put through random edits, combined at random, and
|
||||
// files made of nonsense or with bits knocked out of them read. Nothing may crash or hang, every
|
||||
// face must point at points that are there, and shapes closed going into a combine come out
|
||||
// closed. The same each run (a fixed seed); fuzz_test N for more rounds, fuzz_test N S another seed.
|
||||
#include "import.h"
|
||||
#include "mesh.h"
|
||||
#include "scene.h"
|
||||
|
||||
#include <glm/gtc/matrix_transform.hpp>
|
||||
|
||||
#include <chrono>
|
||||
#include <cmath>
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <random>
|
||||
#include <set>
|
||||
#include <sstream>
|
||||
#include <string>
|
||||
|
||||
using namespace fry;
|
||||
|
||||
namespace {
|
||||
|
||||
std::mt19937 rng;
|
||||
int failures = 0;
|
||||
std::string lastEdit; // (what the last edit was, exactly, for when it went wrong)
|
||||
|
||||
// A mesh as an OBJ (to look at what went wrong).
|
||||
void saveObj(const Mesh& m, const std::string& path) {
|
||||
std::ofstream out(path);
|
||||
for (const glm::vec3& v : m.verts) out << "v " << v.x << " " << v.y << " " << v.z << "\n";
|
||||
for (const auto& f : m.faces) {
|
||||
out << "f";
|
||||
for (int v : f) out << " " << v + 1;
|
||||
out << "\n";
|
||||
}
|
||||
}
|
||||
|
||||
float uniform(float lo, float hi) { return std::uniform_real_distribution<float>(lo, hi)(rng); }
|
||||
int pick(int lo, int hi) { return std::uniform_int_distribution<int>(lo, hi)(rng); }
|
||||
|
||||
void fail(int round, const char* what, const char* step) {
|
||||
++failures;
|
||||
std::printf("FAIL round %d: %s (after %s)\n", round, what, step);
|
||||
}
|
||||
|
||||
// Every face has 2 or more points and each is a point of the mesh; no point is NaN or infinite.
|
||||
bool whole(const Mesh& m) {
|
||||
for (const glm::vec3& v : m.verts)
|
||||
if (!std::isfinite(v.x) || !std::isfinite(v.y) || !std::isfinite(v.z)) return false;
|
||||
for (const auto& f : m.faces) {
|
||||
if (f.size() < 2) return false;
|
||||
for (int v : f)
|
||||
if (v < 0 || size_t(v) >= m.verts.size()) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
Mesh randomShape() {
|
||||
Mesh m;
|
||||
switch (pick(0, 6)) {
|
||||
case 0: m = makeCube(uniform(0.1f, 2)); break;
|
||||
case 1: m = makeCylinder(pick(3, 40), uniform(0.05f, 1), uniform(0.05f, 2)); break;
|
||||
case 2: m = makeSphere(pick(3, 32), pick(2, 16), uniform(0.05f, 1)); break;
|
||||
case 3: m = makeCone(pick(3, 30), uniform(0.05f, 1), uniform(0.05f, 2)); break;
|
||||
case 4: m = makeTorus(pick(3, 32), pick(3, 16), uniform(0.2f, 1), uniform(0.02f, 0.19f)); break;
|
||||
case 5: m = makeFrustum(pick(3, 24), uniform(0.05f, 1), uniform(0.0f, 1), uniform(0.05f, 2)); break;
|
||||
default: m = makePlane(uniform(0.1f, 2)); break;
|
||||
}
|
||||
glm::mat4 t = glm::translate(glm::mat4(1.0f), glm::vec3(uniform(-0.5f, 0.5f), uniform(-0.5f, 0.5f), uniform(-0.5f, 0.5f)));
|
||||
t = glm::rotate(t, uniform(0, 6.3f), glm::normalize(glm::vec3(uniform(-1, 1), uniform(-1, 1), uniform(0.1f, 1))));
|
||||
t = glm::scale(t, glm::vec3(uniform(0.3f, 2), uniform(0.3f, 2), uniform(0.3f, 2)));
|
||||
transform(m, t);
|
||||
return m;
|
||||
}
|
||||
|
||||
std::set<int> someFaces(const Mesh& m) {
|
||||
std::set<int> s;
|
||||
if (m.faces.empty()) return s;
|
||||
for (int i = pick(1, 4); i > 0; --i) s.insert(pick(0, int(m.faces.size()) - 1));
|
||||
return s;
|
||||
}
|
||||
std::set<int> somePoints(const Mesh& m) {
|
||||
std::set<int> s;
|
||||
if (m.verts.empty()) return s;
|
||||
for (int i = pick(1, 6); i > 0; --i) s.insert(pick(0, int(m.verts.size()) - 1));
|
||||
return s;
|
||||
}
|
||||
std::pair<int, int> someEdge(const Mesh& m) {
|
||||
const auto e = edges(m);
|
||||
return e.empty() ? std::pair(0, 0) : e[size_t(pick(0, int(e.size()) - 1))];
|
||||
}
|
||||
|
||||
// One random edit; its name, for when something's wrong after it.
|
||||
const char* randomEdit(Mesh& m) {
|
||||
if (m.faces.empty()) return "nothing";
|
||||
switch (pick(0, 17)) {
|
||||
case 0: {
|
||||
const auto fs = someFaces(m);
|
||||
const float d = uniform(-0.3f, 0.5f);
|
||||
lastEdit = "extrude " + std::to_string(d) + " faces";
|
||||
for (int f : fs) lastEdit += " " + std::to_string(f);
|
||||
extrudeFaces(m, fs, d);
|
||||
return "extrude";
|
||||
}
|
||||
case 1: insetFaces(m, someFaces(m), uniform(0, 0.95f)); return "inset";
|
||||
case 2: flipFaces(m, someFaces(m)); return "flip";
|
||||
case 3: deleteFaces(m, someFaces(m)); return "delete faces";
|
||||
case 4: deleteVerts(m, somePoints(m)); return "delete points";
|
||||
case 5: mergeByDistance(m, uniform(0, 0.2f)); return "merge by distance";
|
||||
case 6: mergeToCenter(m, somePoints(m)); return "merge to centre";
|
||||
case 7: {
|
||||
std::set<std::pair<int, int>> e;
|
||||
for (int i = pick(1, 3); i > 0; --i) e.insert(someEdge(m));
|
||||
bevelEdges(m, e, uniform(0.001f, 0.2f), pick(1, 4));
|
||||
return "bevel";
|
||||
}
|
||||
case 8:
|
||||
if (m.faces.size() < 3000) subdivide(m, pick(0, 1), uniform(0, 180));
|
||||
return "subdivide";
|
||||
case 9: loopCut(m, someEdge(m), pick(1, 3)); return "loop cut";
|
||||
case 10: smoothPoints(m, somePoints(m), uniform(0, 1), pick(1, 3)); return "smooth points";
|
||||
case 11: pushPoints(m, somePoints(m), uniform(-0.2f, 0.2f)); return "push points";
|
||||
case 12: closeHoles(m); return "close holes";
|
||||
case 13: faceOutward(m); return "face outward";
|
||||
case 14: repairForPrint(m); return "repair";
|
||||
case 15: mergeFlatFaces(m); return "merge flat faces";
|
||||
case 16: {
|
||||
const auto fs = someFaces(m);
|
||||
if (fs.size() >= 2) bridgeFaces(m, *fs.begin(), *fs.rbegin());
|
||||
return "bridge";
|
||||
}
|
||||
default: {
|
||||
const int f = pick(0, int(m.faces.size()) - 1);
|
||||
if (m.faces[size_t(f)].size() >= 3) cutHole(m, f, holeOutline(m, f, faceCenter(m, f), uniform(0.01f, 0.4f), pick(3, 24)), uniform(-1, 0.5f));
|
||||
return "hole";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Whatever comes out, looked at the ways the app will (it mustn't crash those either).
|
||||
void lookAt(const Mesh& m) {
|
||||
for (size_t f = 0; f < m.faces.size(); ++f)
|
||||
if (m.faces[f].size() >= 3) (void)triangulate(m, int(f));
|
||||
(void)checkForPrint(m);
|
||||
(void)cornerNormals(m, true, 30);
|
||||
(void)sliceAt(m, (boundsMin(m).z + boundsMax(m).z) * 0.5f);
|
||||
bool loop = false;
|
||||
(void)alongPath(pathOf(m, loop), loop, 7);
|
||||
}
|
||||
|
||||
// Bytes knocked about: some changed, some cut out, some put in twice.
|
||||
std::string mangle(std::string s) {
|
||||
for (int i = pick(1, 12); i > 0 && !s.empty(); --i) {
|
||||
const size_t at = size_t(pick(0, int(s.size()) - 1));
|
||||
switch (pick(0, 3)) {
|
||||
case 0: s[at] = char(pick(0, 255)); break;
|
||||
case 1: s.erase(at, size_t(pick(1, 40))); break;
|
||||
case 2: s.insert(at, s.substr(at, size_t(pick(1, 40)))); break;
|
||||
default: s.resize(at); break;
|
||||
}
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int main(int argc, char** argv) {
|
||||
const int rounds = argc > 1 ? std::max(1, std::atoi(argv[1])) : 150;
|
||||
rng.seed(argc > 2 ? unsigned(std::atoi(argv[2])) : 20261021u);
|
||||
const auto start = std::chrono::steady_clock::now();
|
||||
int combined = 0, closedIn = 0;
|
||||
for (int r = 0; r < rounds; ++r) {
|
||||
// Edits, one after another, on one shape.
|
||||
Mesh m = randomShape();
|
||||
for (int k = pick(1, 6); k > 0; --k) {
|
||||
const Mesh before = m;
|
||||
const char* step = randomEdit(m);
|
||||
if (!whole(m)) {
|
||||
if (std::getenv("FRY_FUZZ_KEEP")) {
|
||||
saveObj(before, std::getenv("FRY_FUZZ_KEEP")), std::printf(" %s\n", lastEdit.c_str());
|
||||
for (const glm::vec3& v : m.verts) std::printf(" (%g %g %g)", v.x, v.y, v.z);
|
||||
std::printf("\n");
|
||||
}
|
||||
fail(r, "a face points at a point that isn't there, or a point isn't a number", step);
|
||||
break;
|
||||
}
|
||||
lookAt(m);
|
||||
}
|
||||
// Two shapes combined: closed going in, closed coming out.
|
||||
Mesh a = randomShape();
|
||||
const Mesh b = randomShape();
|
||||
const bool bothClosed = checkForPrint(a).closed() && checkForPrint(b).closed();
|
||||
const Combine op = Combine(pick(0, 2));
|
||||
const bool any = combine(a, b, op);
|
||||
++combined;
|
||||
if (!whole(a)) fail(r, "combined: broken", "combine");
|
||||
else if (bothClosed && any) {
|
||||
++closedIn;
|
||||
const PrintCheck c = checkForPrint(a);
|
||||
if (!c.closed()) {
|
||||
std::printf(" (op %d: %d open edges, %zu faces)\n", int(op), c.openEdges, a.faces.size());
|
||||
fail(r, "two closed shapes combined into one that isn't closed", "combine");
|
||||
}
|
||||
}
|
||||
lookAt(a);
|
||||
// Spun, as the Spin window does (an open shape: a plane).
|
||||
Mesh s = spin(makePlane(uniform(0.1f, 1)), glm::vec3(uniform(-1, 1), 0, 0), glm::vec3(0, 0, 1), uniform(-720, 720), pick(1, 64), uniform(-1, 1));
|
||||
if (!whole(s)) fail(r, "spun: broken", "spin");
|
||||
}
|
||||
// Files: nonsense, and real ones with bits knocked out, read as each kind.
|
||||
const auto dir = std::filesystem::temp_directory_path() / "fry3dx_fuzz";
|
||||
std::filesystem::create_directories(dir);
|
||||
Scene sc;
|
||||
for (int i = 0; i < 3; ++i) {
|
||||
Object o;
|
||||
o.id = sc.nextId++, o.name = "o" + std::to_string(i), o.mesh = randomShape(), o.mirror = i, o.cuts = i == 2 ? 1 : 0;
|
||||
sc.objects.push_back(o);
|
||||
}
|
||||
const std::string sceneFile = sceneText(sc);
|
||||
std::string objFile = "o thing\n";
|
||||
for (const glm::vec3& v : sc.objects[0].mesh.verts) objFile += "v " + std::to_string(v.x) + " " + std::to_string(v.y) + " " + std::to_string(v.z) + "\n";
|
||||
for (const auto& f : sc.objects[0].mesh.faces) {
|
||||
objFile += "f";
|
||||
for (int v : f) objFile += " " + std::to_string(v + 1);
|
||||
objFile += "\n";
|
||||
}
|
||||
std::string stl = "solid x\n facet normal 0 0 1\n outer loop\n vertex 0 0 0\n vertex 1 0 0\n vertex 0 1 0\n endloop\n endfacet\nendsolid x\n";
|
||||
const int files = std::max(30, rounds);
|
||||
for (int i = 0; i < files; ++i) {
|
||||
const std::string text = mangle(sceneFile);
|
||||
std::ofstream(dir / "last.fry", std::ios::binary) << text; // (left there if it crashes: the file that did it)
|
||||
std::istringstream in(text);
|
||||
if (auto s = parseScene(in, "fuzz.fry")) {
|
||||
for (const Object& o : s->objects)
|
||||
if (!whole(o.mesh)) fail(i, "a scene read with a broken object", "parseScene");
|
||||
(void)withCutsMade(*s);
|
||||
}
|
||||
const char* ext[] = {".obj", ".stl", ".3mf", ".glb", ".gltf"};
|
||||
const int kind = pick(0, 4);
|
||||
std::string bytes = kind == 0 ? mangle(objFile) : kind == 1 ? mangle(stl) : std::string();
|
||||
if (bytes.empty())
|
||||
for (int n = pick(0, 600); n > 0; --n) bytes += char(pick(0, 255));
|
||||
if (kind == 2 && pick(0, 1)) bytes = "PK\x03\x04" + bytes; // (looks like a zip at first)
|
||||
if (kind == 3 && pick(0, 1)) bytes = std::string("glTF\x02\x00\x00\x00", 8) + bytes;
|
||||
const auto path = (dir / ("f" + std::string(ext[kind]))).string();
|
||||
std::ofstream(path, std::ios::binary) << bytes;
|
||||
for (const ImportedMesh& im : importModel(path))
|
||||
if (!whole(im.mesh)) fail(i, "a file read into a broken mesh", ext[kind]);
|
||||
std::vector<uint8_t> raw(bytes.begin(), bytes.end());
|
||||
(void)inflate(raw.data(), raw.size(), size_t(pick(0, 4096)));
|
||||
}
|
||||
std::filesystem::remove_all(dir);
|
||||
const double s = std::chrono::duration<double>(std::chrono::steady_clock::now() - start).count();
|
||||
std::printf("%d rounds, %d combines (%d of closed shapes), %d files: %s in %.1f s\n", rounds, combined, closedIn, files * 2, failures ? "FAILED" : "passed", s);
|
||||
return failures ? 1 : 0;
|
||||
}
|
||||
|
|
@ -1,5 +1,8 @@
|
|||
// The modelling core without a window: shapes are closed and face out, faces with dents
|
||||
// triangulate right, extrude / inset / merge keep the mesh whole, and files round-trip.
|
||||
#include "bvh.h"
|
||||
#include "import.h"
|
||||
#include "text.h"
|
||||
#include "mesh.h"
|
||||
#include "image.h"
|
||||
#include "scene.h"
|
||||
|
|
@ -11,6 +14,7 @@
|
|||
#include <glm/gtc/matrix_transform.hpp>
|
||||
|
||||
#include <algorithm>
|
||||
#include <chrono>
|
||||
#include <filesystem>
|
||||
#include <memory>
|
||||
#include <fstream>
|
||||
|
|
@ -362,6 +366,182 @@ int main() {
|
|||
CHECK(combine(a, drill, Combine::Subtract) && checkForPrint(a).closed() && checkForPrint(a).volume < 1.0f && checkForPrint(a).volume > 0.85f,
|
||||
"a cylinder drilled through a cube: closed, %g m3", checkForPrint(a).volume);
|
||||
}
|
||||
// Joining points is quick on a big mesh: a sphere in loose triangles, as an STL brings it.
|
||||
{
|
||||
const Mesh sphere = makeSphere(200, 100, 1.0f);
|
||||
Mesh soup;
|
||||
for (size_t f = 0; f < sphere.faces.size(); ++f)
|
||||
for (const auto& t : triangulate(sphere, int(f))) {
|
||||
soup.faces.push_back({int(soup.verts.size()), int(soup.verts.size()) + 1, int(soup.verts.size()) + 2});
|
||||
for (int v : t) soup.verts.push_back(sphere.verts[size_t(v)]);
|
||||
}
|
||||
const size_t loose = soup.verts.size();
|
||||
const auto t0 = std::chrono::steady_clock::now();
|
||||
mergeByDistance(soup, 1e-6f);
|
||||
const double ms = std::chrono::duration<double, std::milli>(std::chrono::steady_clock::now() - t0).count();
|
||||
CHECK(soup.verts.size() == sphere.verts.size() && ms < 3000, "%zu loose points joined to %zu in %.0f ms", loose, soup.verts.size(), ms);
|
||||
}
|
||||
// Repair: a cube as an STL brings it (each face its own corners), one face missing, one
|
||||
// turned in, one there twice, an inside wall: closed and the right way out after.
|
||||
{
|
||||
const Mesh good = makeCube(1.0f);
|
||||
Mesh bad;
|
||||
for (size_t f = 0; f < good.faces.size(); ++f) {
|
||||
if (f == 2) continue; // (missing)
|
||||
std::vector<int> face;
|
||||
for (int v : good.faces[f]) face.push_back(int(bad.verts.size())), bad.verts.push_back(good.verts[size_t(v)] + glm::vec3(f == 4 ? 1e-8f : 0.0f));
|
||||
if (f == 3) std::reverse(face.begin(), face.end()); // (turned in)
|
||||
bad.faces.push_back(face);
|
||||
if (f == 5) bad.faces.push_back(face); // (twice)
|
||||
}
|
||||
bad.faces.push_back({0, 1, 1}); // (no area)
|
||||
const int a = int(bad.verts.size());
|
||||
bad.verts.insert(bad.verts.end(), {{-0.5f, 0, -0.5f}, {0.5f, 0, -0.5f}, {0.5f, 0, 0.5f}, {-0.5f, 0, 0.5f}});
|
||||
bad.faces.push_back({a, a + 1, a + 2, a + 3}), bad.faces.push_back({a + 3, a + 2, a + 1, a}); // (an inside wall, back to back)
|
||||
CHECK(!checkForPrint(bad).closed(), "the broken cube is broken");
|
||||
const Repair r = repairForPrint(bad);
|
||||
const PrintCheck c = checkForPrint(bad);
|
||||
CHECK(c.closed() && !c.insideOut && std::abs(c.volume - 1.0f) < 1e-4f && r.closedHoles == 1 && r.doubledFaces == 3 && r.emptyFaces == 1 && r.joinedPoints > 0,
|
||||
"repair: closed, outwards, 1 m3 (joined %d, empty %d, doubled %d, turned %d, holes %d)", r.joinedPoints, r.emptyFaces, r.doubledFaces, r.turnedFaces, r.closedHoles);
|
||||
Mesh fine = makeCube(1.0f);
|
||||
CHECK(repairForPrint(fine).total() == 0 && fine.faces == good.faces, "a good cube: nothing to repair");
|
||||
}
|
||||
// Spin: a square beside the axis turned all the way round is a ring (Pappus: its area times
|
||||
// how far its middle goes); a quarter turn has ends; a screw rises.
|
||||
{
|
||||
const float kPi = 3.14159265f;
|
||||
Mesh sq{{{0.5f, 0, 0}, {1.0f, 0, 0}, {1.0f, 0, 0.5f}, {0.5f, 0, 0.5f}}, {{0, 1, 2, 3}}};
|
||||
const Mesh ring = spin(sq, glm::vec3(0), glm::vec3(0, 0, 1), 360, 96);
|
||||
const float want = 0.25f * 2 * kPi * 0.75f * std::sin(kPi / 96) / (kPi / 96);
|
||||
CHECK(checkForPrint(ring).closed() && std::abs(checkForPrint(ring).volume - want) < want * 2e-3f, "a square spun round: a ring (%.4f m3, want %.4f)", checkForPrint(ring).volume, want);
|
||||
const Mesh quarter = spin(sq, glm::vec3(0), glm::vec3(0, 0, 1), 90, 24);
|
||||
CHECK(checkForPrint(quarter).closed() && checkForPrint(quarter).volume > 0 && std::abs(checkForPrint(quarter).volume - want / 4) < want * 0.01f, "a quarter turn: closed, its ends on (%.4f)", checkForPrint(quarter).volume);
|
||||
const Mesh screw = spin(sq, glm::vec3(0), glm::vec3(0, 0, 1), 720, 96, 2.0f);
|
||||
CHECK(checkForPrint(screw).closed() && std::abs(boundsMax(screw).z - 2.5f) < 1e-4f, "a screw: two turns up 2 m, closed (top %.3f)", boundsMax(screw).z);
|
||||
Mesh tri{{{0, 0, 0}, {0.5f, 0, 0}, {0, 0, 1}}, {{0, 1, 2}}}; // (a side on the axis: a cone)
|
||||
const Mesh cone = spin(tri, glm::vec3(0), glm::vec3(0, 0, 1), 360, 64);
|
||||
const float coneWant = kPi * 0.25f / 3 * std::sin(2 * kPi / 64) / (2 * kPi / 64); // (a 64-sided cone's)
|
||||
CHECK(checkForPrint(cone).closed() && std::abs(checkForPrint(cone).volume - coneWant) < coneWant * 1e-3f, "a triangle on the axis spun: a closed cone (%.4f, want %.4f)", checkForPrint(cone).volume, coneWant);
|
||||
}
|
||||
// Text as a shape: letters with holes (O, A, B) come out closed, as tall as asked.
|
||||
{
|
||||
std::ifstream in(FRY_FONTS_DIR "/InstrumentSans-Bold.ttf", std::ios::binary);
|
||||
const std::vector<uint8_t> font{std::istreambuf_iterator<char>(in), {}};
|
||||
TextStyle st;
|
||||
st.height = 0.05f, st.depth = 0.01f;
|
||||
std::string err;
|
||||
const Mesh t = makeText(font, "Fry 3D", st, &err);
|
||||
const PrintCheck c = checkForPrint(t);
|
||||
CHECK(!t.faces.empty() && c.closed() && std::abs(boundsMax(t).z - 0.01f) < 1e-4f && boundsMax(t).y > 0.045f && boundsMax(t).y < 0.06f,
|
||||
"text \"Fry 3D\": %zu faces, closed %d, %.3f m tall %s", t.faces.size(), int(c.closed()), boundsMax(t).y, err.c_str());
|
||||
// (every face's triangles cover just the face: letters are dented, with points in straight lines)
|
||||
int covered = 0;
|
||||
for (size_t f = 0; f < t.faces.size(); ++f) {
|
||||
float a = 0;
|
||||
for (const auto& tr : triangulate(t, int(f))) a += 0.5f * glm::length(glm::cross(t.verts[size_t(tr[1])] - t.verts[size_t(tr[0])], t.verts[size_t(tr[2])] - t.verts[size_t(tr[0])]));
|
||||
covered += std::abs(a - faceArea(t, int(f))) <= faceArea(t, int(f)) * 1e-2f + 1e-12f; // (a sliver far out: float rounding)
|
||||
}
|
||||
CHECK(covered == int(t.faces.size()), "letters' faces made into triangles that cover them (%d of %zu)", covered, t.faces.size());
|
||||
// (letters drawn as overlapping strokes are whole: an F is as wide as an E, not just its stem)
|
||||
const float fw = boundsMax(makeText(font, "F", st)).x - boundsMin(makeText(font, "F", st)).x, ew = boundsMax(makeText(font, "E", st)).x - boundsMin(makeText(font, "E", st)).x;
|
||||
CHECK(fw > ew * 0.8f && checkForPrint(makeText(font, "F", st)).closed(), "an F is whole (%.1f mm wide, an E %.1f)", fw * 1000, ew * 1000);
|
||||
const Mesh o = makeText(font, "O", st);
|
||||
CHECK(checkForPrint(o).closed() && checkForPrint(o).volume < 0.05f * 0.04f * 0.01f, "an O has its hole (%g m3)", checkForPrint(o).volume);
|
||||
CHECK(makeText({}, "x", st, &err).faces.empty() && !err.empty(), "no font: nothing, and why");
|
||||
}
|
||||
// Creases: a cube smoothed with all its edges creased stays a cube; with none it rounds off;
|
||||
// mirrored, the copy's creases hold too; and they're saved.
|
||||
{
|
||||
Object o;
|
||||
o.id = 1, o.mesh = makeCube(1.0f), o.smoothLevels = 2;
|
||||
const float round = checkForPrint(finishedMesh(o)).volume;
|
||||
for (auto [a, b] : edges(o.mesh)) o.creases.insert({a, b});
|
||||
const float kept = checkForPrint(finishedMesh(o)).volume;
|
||||
CHECK(round < 0.8f && std::abs(kept - 1.0f) < 1e-4f, "creased all round: still a cube (%.3f; smooth %.3f)", kept, round);
|
||||
Object half;
|
||||
half.id = 2, half.mesh = makeCube(1.0f), half.smoothLevels = 2, half.mirror = 1;
|
||||
transform(half.mesh, glm::translate(glm::mat4(1), glm::vec3(0.5f, 0, 0)));
|
||||
for (auto [a, b] : edges(half.mesh)) half.creases.insert({a, b});
|
||||
const float both = checkForPrint(finishedMesh(half)).volume;
|
||||
CHECK(std::abs(both - 2.0f) < 1e-3f, "mirrored, creased: two cubes' worth, square (%.3f)", both);
|
||||
Scene sc;
|
||||
sc.objects.push_back(o);
|
||||
const auto file = (std::filesystem::temp_directory_path() / "fry3dx_test" / "crease.fry").string();
|
||||
std::filesystem::create_directories(std::filesystem::path(file).parent_path());
|
||||
auto back = saveScene(file, sc) ? loadScene(file) : std::nullopt;
|
||||
CHECK(back && back->objects.size() == 1 && back->objects[0].creases == o.creases, "creases saved and back");
|
||||
}
|
||||
// A slicer's layer: a 1 m cube cut halfway is a 4 m outline, a cylinder's a circle's.
|
||||
{
|
||||
float len = 0;
|
||||
for (const auto& [a, b] : sliceAt(makeCube(1.0f), 0.1f)) len += glm::distance(a, b);
|
||||
CHECK(std::abs(len - 4.0f) < 1e-4f, "a cube's slice: 4 m round (%.4f)", len);
|
||||
CHECK(sliceAt(makeCube(1.0f), 2.0f).empty(), "above it: nothing");
|
||||
}
|
||||
// Wall thickness: a box 2 mm thin one way is 2 mm thick on its big faces, 1 m the other ways.
|
||||
{
|
||||
Mesh slab = makeCube(1.0f);
|
||||
transform(slab, glm::scale(glm::mat4(1), glm::vec3(1.0f, 1.0f, 0.002f)));
|
||||
const std::vector<float> t = wallThickness(slab);
|
||||
int thin = 0, wide = 0;
|
||||
for (size_t f = 0; f < t.size(); ++f) thin += std::abs(t[f] - 0.002f) < 1e-5f, wide += std::abs(t[f] - 1.0f) < 1e-4f;
|
||||
CHECK(thin == 2 && wide == 4, "wall thickness: 2 faces 2 mm, 4 faces 1 m (%d, %d)", thin, wide);
|
||||
}
|
||||
// The tree of boxes finds the same nearest hit as trying every triangle (and the same "in the way").
|
||||
{
|
||||
Mesh m = makeSphere(32, 16, 1.0f);
|
||||
const glm::mat4 M = glm::translate(glm::mat4(1), glm::vec3(0.3f, -0.2f, 0.5f)) * glm::rotate(glm::mat4(1), 0.7f, glm::vec3(0, 1, 0));
|
||||
const TriangleTree tree(m, M);
|
||||
uint32_t seed = 7;
|
||||
auto rnd = [&] { return (seed = seed * 1664525u + 1013904223u) / 4294967296.0f * 2 - 1; };
|
||||
int same = 0, tries = 300;
|
||||
for (int i = 0; i < tries; ++i) {
|
||||
const glm::vec3 o(rnd() * 3, rnd() * 3, rnd() * 3 + 3), d = glm::normalize(glm::vec3(rnd(), rnd(), rnd() - 1.5f));
|
||||
float best = 1e30f;
|
||||
for (size_t f = 0; f < m.faces.size(); ++f)
|
||||
for (const auto& t : triangulate(m, int(f)))
|
||||
if (auto h = rayTriangle(o, d, glm::vec3(M * glm::vec4(m.verts[size_t(t[0])], 1)), glm::vec3(M * glm::vec4(m.verts[size_t(t[1])], 1)), glm::vec3(M * glm::vec4(m.verts[size_t(t[2])], 1)))) best = std::min(best, *h);
|
||||
const auto h = tree.nearest(o, d);
|
||||
if ((best > 1e29f && !h) || (h && std::abs(h->t - best) < 1e-4f && tree.blocked(o, d, best + 1e-3f) && !tree.blocked(o, d, best - 1e-3f))) ++same;
|
||||
}
|
||||
CHECK(same == tries, "the tree of boxes finds what trying every triangle does (%d of %d)", same, tries);
|
||||
}
|
||||
// Shapes drawn and cut in, again and again inside the last cut, on a turned object (the shape
|
||||
// carried into its own space, as the Draw tool does). This once built its tree forever and
|
||||
// crashed; and the cutting's rounding left small gaps.
|
||||
{
|
||||
uint32_t seed = 12345;
|
||||
auto rnd = [&] { return (seed = seed * 1664525u + 1013904223u) / 4294967296.0f * 2 - 1; }; // (the same everywhere: -1..1)
|
||||
int cuts = 0, sealedCuts = 0;
|
||||
for (int run = 0; run < 12; ++run) {
|
||||
Mesh m = makeCube(1.0f);
|
||||
const glm::vec3 axis = glm::normalize(glm::vec3(rnd(), rnd(), rnd() + 2));
|
||||
const glm::mat4 M = glm::translate(glm::mat4(1), glm::vec3(rnd(), rnd(), 0.5f)) * glm::rotate(glm::mat4(1), rnd() * 3, axis);
|
||||
float top = 0.5f; // (the floor of the last cut, the object's own space)
|
||||
for (int k = 0; k < 5; ++k) {
|
||||
const glm::vec3 P = glm::vec3(M * glm::vec4(rnd() * 0.03f, rnd() * 0.03f, top, 1)), N = glm::normalize(glm::mat3(M) * glm::vec3(0, 0, 1));
|
||||
const glm::vec3 X = glm::normalize(glm::cross(N, glm::vec3(0.3f, 0.2f, 0.9f))), Y = glm::cross(N, X);
|
||||
const int corners = 3 + k % 4;
|
||||
const float r = 0.25f - 0.04f * float(k);
|
||||
std::vector<glm::vec3> pts;
|
||||
glm::vec3 c(0.0f);
|
||||
for (int i = 0; i < corners; ++i) {
|
||||
const float t = 6.2831f * float(i) / float(corners) + rnd() * 0.3f;
|
||||
pts.push_back(P + X * std::cos(t) * r + Y * std::sin(t) * r), c += pts.back();
|
||||
}
|
||||
c /= float(corners);
|
||||
for (glm::vec3& q : pts) q -= c;
|
||||
const float thick = 0.05f + 0.0002f;
|
||||
Mesh shape = extrudeOutline(pts, N, thick);
|
||||
transform(shape, glm::inverse(M) * glm::translate(glm::mat4(1), c + N * (0.0001f - thick / 2)));
|
||||
Mesh was = m;
|
||||
if (!combine(m, shape, Combine::Subtract)) m = was;
|
||||
++cuts, sealedCuts += checkForPrint(m).closed();
|
||||
top -= 0.05f;
|
||||
}
|
||||
}
|
||||
CHECK(sealedCuts >= cuts - 2, "cuts drawn inside cuts: no crash, %d of %d closed after", sealedCuts, cuts);
|
||||
}
|
||||
// An edge's ring: across a cube's side, the four edges going round it.
|
||||
{
|
||||
const Mesh c = makeCube(1.0f);
|
||||
|
|
@ -393,6 +573,23 @@ int main() {
|
|||
CHECK(exportModel((dir / "a.gltf").string(), s, {}) && std::filesystem::file_size(dir / "a.bin") > 0, "exports glTF");
|
||||
for (const auto& [ext, what] : exportFormats())
|
||||
CHECK(exportModel((dir / ("b" + ext)).string(), s, {}) && std::filesystem::file_size(dir / ("b" + ext)) > 100, "exports %s", ext.c_str());
|
||||
// Read back in: the same shape, the same size (STL and 3MF go by millimetres, OBJ and glTF by Y up).
|
||||
for (const std::string ext : {".stl", ".obj", ".3mf", ".glb", ".gltf"}) {
|
||||
std::string err;
|
||||
const auto in = importModel((dir / ((ext == ".gltf" ? "a" : "b") + ext)).string(), &err);
|
||||
const float want = checkForPrint(cube).volume, got = in.empty() ? 0 : checkForPrint(in[0].mesh).volume;
|
||||
if (ext == ".3mf") CHECK(!in.empty() && in[0].name == o.name, "a 3MF's name, as it was (%s)", in.empty() ? "" : in[0].name.c_str());
|
||||
CHECK(in.size() == 1 && checkForPrint(in[0].mesh).closed() && std::abs(got - want) < want * 1e-3f, "imports %s back (%g m3, %s)", ext.c_str(), got, err.c_str());
|
||||
}
|
||||
{
|
||||
const uint8_t packed[] = {115,43,170,52,118,137,80,40,74,77,76,41,86,48,246,117,83,72,203,204,73,45,214,83,112,27,21,31,21,31,21,31,244,226,12,140,76,204,44,172,108,236,28,156,92,220,60,188,124,252,2,130,66,194,34,162,98,226,18,146,82,210,50,178,114,242,10,138,74,202,42,170,106,234,26,154,90,218,58,186,122,250,6,134,70,198,38,166,102,230,22,150,86,214,54,182,118,246,14,142,78,206,46,174,110,238,30,158,94,222,62,190,126,254,1,129,65,193,33,161,97,225,17,145,81,209,49,177,113,241,9,137,73,201,41,169,105,233,25,153,89,217,57,185,121,249,5,133,69,197,37,165,101,229,21,149,85,213,53,181,117,245,13,141,77,205,45,173,109,237,29,157,93,221,61,189,125,253,19,38,78,154,60,101,234,180,233,51,102,206,154,61,103,238,188,249,11,22,46,90,188,100,233,178,229,43,86,174,90,189,102,237,186,245,27,54,110,218,188,101,235,182,237,59,118,238,218,189,103,239,190,253,7,14,30,58,124,228,232,177,227,39,78,158,58,125,230,236,185,243,23,46,94,186,124,229,234,181,235,55,110,222,186,125,231,238,189,251,15,30,62,122,252,228,233,179,231,47,94,190,122,253,230,237,187,247,31,62,126,250,252,229,235,183,239,63,126,254,250,253,231,239,191,255,0};
|
||||
std::string want;
|
||||
for (int i = 0; i < 40; ++i) want += "Fry3DX reads 3MF files. ";
|
||||
for (int i = 0; i < 256; ++i) want += char(i);
|
||||
const std::vector<uint8_t> out = inflate(packed, sizeof packed);
|
||||
CHECK(std::string(out.begin(), out.end()) == want && out.size() == 1216, "unpacks DEFLATE (%zu bytes)", out.size());
|
||||
CHECK(inflate(packed, sizeof packed / 2).empty(), "a cut-short DEFLATE: nothing");
|
||||
}
|
||||
|
||||
// Its own file: one packed file with its pictures and layers, back the same; old text ones open too.
|
||||
{
|
||||
|
|
@ -512,6 +709,72 @@ int main() {
|
|||
CHECK(!held.empty() && std::abs(boundsMax(g).z - 0.8f) < 0.01f && closed(g), "grab pulls the middle up with it (%.3f)", boundsMax(g).z);
|
||||
}
|
||||
|
||||
{ // combining where faces lie on each other (the BSP's weak spot): touching, flush, the same
|
||||
Mesh a = makeCube(1.0f), b = makeCube(1.0f);
|
||||
transform(b, glm::translate(glm::mat4(1.0f), glm::vec3(1, 0, 0)));
|
||||
bool ok = combine(a, b, Combine::Add);
|
||||
CHECK(ok && checkForPrint(a).closed() && std::abs(checkForPrint(a).volume - 2.0f) < 1e-4f && a.faces.size() == 6, "two cubes side by side: one 2 m3 block, 6 faces (%zu)", a.faces.size());
|
||||
Mesh c = makeCube(1.0f), d = makeCube(0.5f);
|
||||
transform(d, glm::translate(glm::mat4(1.0f), glm::vec3(0, 0, 0.25f))); // (its top flush with the cube's)
|
||||
ok = combine(c, d, Combine::Subtract);
|
||||
CHECK(ok && checkForPrint(c).closed() && std::abs(checkForPrint(c).volume - 0.875f) < 1e-4f, "a pocket flush with the top: 0.875 m3, closed (%.4f)", checkForPrint(c).volume);
|
||||
Mesh e = makeCube(1.0f);
|
||||
CHECK(!combine(e, makeCube(1.0f), Combine::Subtract), "a cube take itself: nothing left");
|
||||
Mesh s1 = makeSphere(32, 16, 0.5f), s2 = makeSphere(32, 16, 0.5f);
|
||||
transform(s2, glm::translate(glm::mat4(1.0f), glm::vec3(0.3f, 0.1f, 0.05f)));
|
||||
const auto t0 = std::chrono::steady_clock::now();
|
||||
ok = combine(s1, s2, Combine::Subtract);
|
||||
const double ms = std::chrono::duration<double, std::milli>(std::chrono::steady_clock::now() - t0).count();
|
||||
CHECK(ok && checkForPrint(s1).closed(), "a sphere bitten by another: closed (%zu faces, %.0f ms)", s1.faces.size(), ms);
|
||||
}
|
||||
|
||||
{ // editable cuts: a box with a bar through it, the bar still an object
|
||||
Scene sc;
|
||||
Object box, bar;
|
||||
box.id = 1, box.name = "Box", box.mesh = makeCube(2.0f);
|
||||
bar.id = 2, bar.name = "Bar", bar.mesh = makeCube(1.0f), bar.scale = {3, 1, 1}, bar.cuts = 1;
|
||||
sc.objects = {box, bar};
|
||||
sc.nextId = 3;
|
||||
CHECK(cutTarget(sc, sc.objects[1]) == &sc.objects[0] && hasCuts(sc, sc.objects[0]) && !hasCuts(sc, sc.objects[1]), "the bar cuts the box");
|
||||
const float v = checkForPrint(cutMesh(sc, sc.objects[0])).volume;
|
||||
CHECK(std::abs(v - 6.0f) < 1e-3f, "a 1x1 tunnel through a 2-box: 6 left (%.3f)", v);
|
||||
sc.objects[1].position = {0, 0, 0.5f};
|
||||
const float moved = checkForPrint(cutMesh(sc, sc.objects[0])).volume;
|
||||
CHECK(std::abs(moved - 6.0f) < 1e-3f && boundsMax(cutMesh(sc, sc.objects[0])).z == 1.0f, "moved up, a slot at the top instead (%.3f)", moved);
|
||||
const Scene made = withCutsMade(sc);
|
||||
CHECK(!made.objects[1].visible && std::abs(checkForPrint(made.objects[0].mesh).volume - 6.0f) < 1e-3f, "exporting: the cut made, the bar hidden");
|
||||
sc.objects[1].cuts = 2;
|
||||
CHECK(!cutTarget(sc, sc.objects[1]) && !hasCuts(sc, sc.objects[0]), "cutting itself: no cut");
|
||||
sc.objects[1].cuts = 1;
|
||||
const auto file = (std::filesystem::temp_directory_path() / "fry3dx_test" / "cuts.fry").string();
|
||||
std::filesystem::create_directories(std::filesystem::path(file).parent_path());
|
||||
auto back = saveScene(file, sc) ? loadScene(file) : std::nullopt;
|
||||
CHECK(back && back->objects.size() == 2 && back->objects[1].cuts == back->objects[0].id && back->objects[0].cuts == 0, "cuts saved and back (to the new ids)");
|
||||
}
|
||||
|
||||
{ // copies along a path: a square's outline, a line, a cube's biggest face
|
||||
Mesh sq;
|
||||
sq.verts = {{0, 0, 0}, {2, 0, 0}, {2, 2, 0}, {0, 2, 0}};
|
||||
sq.faces = {{0, 1, 2, 3}};
|
||||
bool loop = false;
|
||||
const auto p = pathOf(sq, loop);
|
||||
const auto pts = alongPath(p, loop, 8);
|
||||
bool even = pts.size() == 8;
|
||||
for (size_t i = 0; i < pts.size() && even; ++i) even = glm::distance(pts[i].at, pts[(i + 1) % pts.size()].at) > 0.99f && glm::distance(pts[i].at, pts[(i + 1) % pts.size()].at) < 1.42f;
|
||||
CHECK(loop && p.size() == 4 && even, "a square's outline is a closed path, 8 points 1 apart round it");
|
||||
Mesh line;
|
||||
line.verts = {{0, 0, 0}, {1, 0, 0}, {1, 1, 0}, {1, 1, 0}, {3, 1, 0}};
|
||||
line.faces = {{0, 1}, {1, 2}, {3, 4}, {2, 3}};
|
||||
const auto lp = pathOf(line, loop);
|
||||
const auto lpts = alongPath(lp, loop, 5);
|
||||
CHECK(!loop && lp.size() == 5 && lpts.size() == 5 && glm::distance(lpts.front().at, glm::vec3(0)) < 1e-5f && glm::distance(lpts.back().at, glm::vec3(3, 1, 0)) < 1e-5f &&
|
||||
std::abs(lpts[2].at.x - 1) < 1e-5f && std::abs(lpts[2].at.y - 1) < 1e-5f && lpts[4].along == glm::vec3(1, 0, 0),
|
||||
"a line (a piece with no length in it): open, both ends, the middle at its corner, going its way");
|
||||
const auto cp = pathOf(makeCube(1.0f), loop);
|
||||
CHECK(loop && cp.size() == 4, "a closed box: its biggest face's outline");
|
||||
CHECK(alongPath({}, false, 4).empty() && alongPath({glm::vec3(1), glm::vec3(1)}, false, 3).empty(), "no path, or one with no length: nothing");
|
||||
}
|
||||
|
||||
std::printf("%s (%d failure%s)\n", g_failures ? "FAILED" : "passed", g_failures, g_failures == 1 ? "" : "s");
|
||||
return g_failures ? 1 : 0;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@
|
|||
Each unpacks to one Fry3DX/ folder: the program, its content (engine/, Fry3DX/; on a Mac inside
|
||||
the app), LICENSE and a README.txt. (The Android APK: android/build_apk.py.)
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
|
|
@ -36,11 +37,8 @@ Open source under the Apache License 2.0 (LICENSE). Sky pictures: Poly Haven, CC
|
|||
|
||||
|
||||
def version():
|
||||
with open(os.path.join(ROOT, "CMakeLists.txt")) as f:
|
||||
for line in f:
|
||||
if line.startswith("project(Fry3DX VERSION "):
|
||||
return line.split()[2]
|
||||
return "0"
|
||||
with open(os.path.join(ROOT, "project.json")) as f: # (the version and the rest live there)
|
||||
return json.load(f)["version"]
|
||||
|
||||
|
||||
PC_NEEDS = "a graphics card with Vulkan (or OpenGL 4.6)"
|
||||
|
|
|
|||
Loading…
Reference in a new issue