diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 16eb0e1..cb3268d 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -32,11 +32,6 @@ 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 diff --git a/.gitignore b/.gitignore index ca88d80..a1ee074 100644 --- a/.gitignore +++ b/.gitignore @@ -4,7 +4,3 @@ build/ __pycache__/ *.fry~ dist/ - -# crash dumps and stray test programs -core.[0-9]* -a.out diff --git a/CMakeLists.txt b/CMakeLists.txt index e643c5b..a8d67bb 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,6 +1,5 @@ cmake_minimum_required(VERSION 3.25) -include(cmake/Project.cmake) # project.json: the version, links, creators -project(Fry3DX VERSION ${FRY_VERSION} LANGUAGES C CXX) +project(Fry3DX VERSION 0.7.0 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 @@ -39,9 +38,6 @@ 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) @@ -59,4 +55,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 fuzz_test USES_TERMINAL VERBATIM) + DEPENDS mesh_test script_test USES_TERMINAL VERBATIM) diff --git a/README.md b/README.md index 23789d7..46cd424 100644 --- a/README.md +++ b/README.md @@ -39,12 +39,6 @@ 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, @@ -52,13 +46,7 @@ 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: 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). + where it is; Shift scales both sides at once. Ctrl snaps. - **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 @@ -67,9 +55,7 @@ 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, **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. + pull in along the surface (Alt+S), **bridge** two faces with a tunnel. - **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 @@ -78,12 +64,9 @@ 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**: 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 +- **Add together, take one out of another, keep only where they overlap**: always closed, so + always printable (Ctrl+Shift+ and Ctrl+Shift−). +- **Copies** in a row or round a circle; **make it thick** (a surface gets a back, a solid goes hollow); join objects; join flat faces. - **Layers** with folders inside folders, drag and drop, hide and lock whole groups, and a button on every row that brings the camera to it. **Show only the selected** (/ or Shift+H; again: @@ -95,32 +78,20 @@ Five tabs along the top: **Model**, **Paint**, **Layout**, **Render**, **Print** keeping its shape. **Painting** -- 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 +- Paint on the model or on its flat picture. **Paste any picture** (Ctrl+V): it goes on like a sticker that bends over edges and corners; Enter locks it as a layer, clicking it in the Picture panel unlocks it again. - **Layout tab**: move, turn and size the flat frames of the surface yourself; the paint follows. **Seeing it** -- **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 **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. + 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. **Scripts** (the Script panel, F5) - Lua with a `fry` library: add shapes and change their settings, combine them, move points by a @@ -135,10 +106,7 @@ 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, 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. + window. Three come with it: stairs (straight and spiral), scatter copies, and more materials. ```lua fry.plugin{name = "Shelves", about = "A bookcase from the Add menu"} @@ -162,8 +130,7 @@ 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. It stays -sideways, and going to another app and back keeps your work. +still is a right-click. Files open and save through the phone's own file picker. F1 in the app lists every key. diff --git a/a.out b/a.out new file mode 100755 index 0000000..e69de29 diff --git a/android/AndroidManifest.xml b/android/AndroidManifest.xml index 38ab730..856a836 100644 --- a/android/AndroidManifest.xml +++ b/android/AndroidManifest.xml @@ -19,7 +19,7 @@ android:name="io.github.nexoniarz.fry3dx.FryActivity" android:exported="true" android:label="Fry3DX" - android:screenOrientation="sensorLandscape" + android:screenOrientation="fullUser" android:launchMode="singleInstance" android:windowSoftInputMode="adjustResize" android:configChanges="layoutDirection|locale|orientation|uiMode|screenLayout|screenSize|smallestScreenSize|keyboard|keyboardHidden|navigation"> diff --git a/android/build_apk.py b/android/build_apk.py index 288d694..ca10d6e 100644 --- a/android/build_apk.py +++ b/android/build_apk.py @@ -11,7 +11,6 @@ Signed with ~/.local/share/Nexoniarz/Fry3DX-signing/release.keystore (made the f or the key in $FRY_KEYSTORE / $FRY_KEYSTORE_PASS. Keep it safe and keep using it: Android only updates an app signed with the key it was installed with. """ -import json import os import secrets import shutil @@ -31,8 +30,11 @@ def run(*cmd, **kw): def app_version(): - with open(os.path.join(ROOT, "project.json")) as f: # (the version and the rest live there) - return json.load(f)["version"] + with open(os.path.join(ROOT, "CMakeLists.txt")) as f: + for line in f: + if line.startswith("project(Fry3DX VERSION "): + return line.split()[2] + return "0" def main(): diff --git a/cmake/Project.cmake b/cmake/Project.cmake deleted file mode 100644 index 33d93a9..0000000 --- a/cmake/Project.cmake +++ /dev/null @@ -1,81 +0,0 @@ -# project.json, read before project(): FRY_VERSION and the rest as FRY_; 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 \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 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() diff --git a/content/Fry3DX/plugins/stamp.lua b/content/Fry3DX/plugins/stamp.lua deleted file mode 100644 index e4ddb0b..0000000 --- a/content/Fry3DX/plugins/stamp.lua +++ /dev/null @@ -1,22 +0,0 @@ --- Stamp: a mouse tool. Click on anything (or the ground) and a copy of the picked object is put --- there, standing on the surface. Shows how a plug-in adds a tool to the Toolbox (fry.tool). -fry.plugin{name = "Stamp", about = "A tool: click to put copies of the picked object on what you click, standing on it."} - -fry.tool{ - name = "Stamp copies", - help = "Click to put a copy of the picked object there, standing on the surface (pick one first).", - click = function(at, normal, onto) - local picked = fry.selected() - local o = picked[#picked] - if not o or (onto and o.id == onto.id) then - -- nothing picked to copy (or clicking on it): a little ball, to show where - o = fry.add("sphere", {radius = 0.1}) - o.position = at + normal * 0.1 - return - end - local c = o:copy() - local lo, hi = o:bounds() - c.position = at + normal * ((hi.z - lo.z) / 2) - o:select() - end, -} diff --git a/docs/content.js b/docs/content.js index f2c2920..51350d1 100644 --- a/docs/content.js +++ b/docs/content.js @@ -29,25 +29,20 @@ 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. 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: "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: "hole", title: "Holes for screws and nuts", text: "Round, rectangle, triangle or hexagon. M2 to M8 screw and nut sizes ready, with a little room added so the print actually fits." }, - { group: "make", icon: "copies", title: "Copies and thickness", text: "Copies in a row, round in a circle, or along a path, turning as it turns. Make a surface thick, or a solid hollow with walls exactly as thick as you need." }, + { group: "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: "paint", icon: "sculpt", title: "Sculpt it like clay", text: "Raise, puff out, smooth, flatten, pinch and grab the surface with a brush, the same on both sides if you like. A shape with too few points is made finer first, keeping its shape." }, { group: "print", icon: "sphere", title: "Materials you can see", text: "Gold, chrome, brushed steel, wood, ceramic, rubber and more, or set how metal and rough it is yourself; save a look as your own material for every file. Shown in the view, and exported with it." }, - { group: "paint", icon: "brush", title: "Paint and stickers", text: "Paint on the model or on its flat picture, on both sides at once, or only inside a stencil. Paste any picture (Ctrl+V) and it goes on like a sticker that bends over edges and corners." }, + { group: "paint", icon: "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: "layout", title: "Layout tab", text: "Move, turn and size the flat frames of the surface by hand. The paint follows, so the model looks the same." }, - { group: "print", icon: "print", title: "Made for 3D printing", text: "Your printer's plate in the view, sizes in millimetres and a scaler (percent, inches to mm, fit the plate), checks with one repair button, overhangs in red, walls too thin in yellow, a layer-by-layer slice preview, weight and filament." }, + { group: "print", icon: "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: "sun", title: "See it rendered", text: "Solid view for modelling, a real renderer with sky, sun and soft shadows when you want to see it properly. Save a picture with F12." }, - { group: "work", icon: "sun", title: "Get round your way", text: "Fly through it as in a game: right drag looks, W A S D or the arrows move, the wheel goes forward and back, Shift + wheel sets the speed. Or orbit round what you look at, Blender style: Shift+C switches." }, - { group: "work", icon: "search", title: "Find anything", text: "F3 or Ctrl+Space finds any command by name. Hold Space for the tools round the mouse, Shift+Space for the views. Your own keys for any command, the undo history, and workspaces: the panels as you had them." }, { group: "work", icon: "folder", title: "Folders and groups", text: "Layers with folders inside folders, drag and drop, show/hide and lock whole groups, and a button on every row that brings the camera to it." }, { group: "work", icon: "phone", title: "On your phone too", text: "Not a viewer: the whole modeller. Big buttons, one finger turns the view, two move and pinch to zoom, holding still is a right-click. Files through the phone's own picker." }, { group: "work", icon: "code", title: "Scripts, if you want them", text: "Lua in a Script panel: build a town in a loop, turn a profile into a vase, move points by a formula. Kept in the .fry with the model; F5 runs one, Ctrl+Z takes it all back." }, - { group: "work", icon: "plug", title: "Plug-ins", text: "A script in the plug-ins folder stays loaded: it adds commands to the Add, Object or Script menu (with a little form for their settings) and materials to the picker. Or a mouse tool in the Toolbox. Stairs, scatter, stamp and more materials come with it." }, + { group: "work", icon: "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: "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." }, ], @@ -129,8 +124,6 @@ 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"] }, diff --git a/docs/site.js b/docs/site.js index 1c04a17..1a6158e 100644 --- a/docs/site.js +++ b/docs/site.js @@ -25,9 +25,6 @@ phone: '', save: '', code: '', - search: '', - ruler: '', - text: '', sculpt: '', plug: '', sphere: '', diff --git a/engine/engine.cpp b/engine/engine.cpp index bdff3ca..1faad39 100644 --- a/engine/engine.cpp +++ b/engine/engine.cpp @@ -371,16 +371,10 @@ 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); - const bool mode = vid_mode.consumeModified(), width = vid_width.consumeModified(), height = vid_height.consumeModified(); // (each taken, none skipped) - if (mode || width || height) + if (vid_mode.consumeModified() | vid_width.consumeModified() | vid_height.consumeModified()) 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). diff --git a/engine/engine.h b/engine/engine.h index 15f70cc..b0f6ab3 100644 --- a/engine/engine.h +++ b/engine/engine.h @@ -36,9 +36,6 @@ 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& changed) { (void)changed; } diff --git a/engine/render/backend.h b/engine/render/backend.h index bac50ab..3cf9b49 100644 --- a/engine/render/backend.h +++ b/engine/render/backend.h @@ -218,8 +218,6 @@ 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>& mips, diff --git a/engine/render/renderer.cpp b/engine/render/renderer.cpp index 71c91dd..112564d 100644 --- a/engine/render/renderer.cpp +++ b/engine/render/renderer.cpp @@ -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, c.zNear, c.zFar); + glm::mat4 proj = glm::perspective(glm::radians(c.fovY), aspect, 2.0f, 16384.0f); proj[1][1] *= -1.0f; // clip-space Y points down (Vulkan; OpenGL is set up to match) proj[2][0] -= c.shift.x, proj[2][1] -= c.shift.y; return proj * view; @@ -211,28 +211,14 @@ 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::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) { +void Renderer::drawStreak(const glm::vec3& from, const glm::vec3& to, float width, const glm::vec4& color, TextureId texture, bool additive) { 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); 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)}); + // 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)}); } void Renderer::drawModel(ModelId model, const glm::mat4& transform, const glm::vec4& tint, const std::vector* skin, @@ -461,13 +447,11 @@ 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; - // (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); + glm::mat4 lightView = glm::lookAt(glm::vec3(0), -sunDir_, up); + glm::vec4 lc = lightView * glm::vec4(center, 1); lc.x = std::floor(lc.x / texel) * texel; lc.y = std::floor(lc.y / texel) * texel; - center = glm::vec3(glm::inverse(lightView0) * lc); - glm::mat4 lightView; + center = glm::vec3(glm::inverse(lightView) * lc); 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; @@ -761,7 +745,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(), 0.001f); // (small for close-up work: a modeller sets it to its view) + p.post.aoRadius = std::max(r_ssao_radius.f(), 1.0f); p.post.bloomStrength = std::clamp(r_bloom.f(), 0.0f, 1.0f); p.post.saturation = r_saturation.f(); p.post.vignette = r_vignette.f(); @@ -773,16 +757,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 (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`) + 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; 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); }); } @@ -805,7 +789,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, camera_.zNear, camera_.zFar); + p.post.proj = glm::vec4(tanY * float(width) / float(height), tanY, 2.0f, 16384.0f); p.clearColor = glm::vec3(srgbToLinear(sky.r), srgbToLinear(sky.g), srgbToLinear(sky.b)) / p.exposure; // 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). diff --git a/engine/render/renderer.h b/engine/render/renderer.h index be42d7a..89bc5bd 100644 --- a/engine/render/renderer.h +++ b/engine/render/renderer.h @@ -22,7 +22,6 @@ 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. @@ -98,15 +97,9 @@ 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 (`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); + // 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); // 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), @@ -164,9 +157,6 @@ public: void onResize() { if (backend_) backend_->onResize(); } - void onSurfaceLost() { - if (backend_) backend_->onSurfaceLost(); - } private: struct Submesh { diff --git a/engine/rhi/gl/gl_legacy.cpp b/engine/rhi/gl/gl_legacy.cpp index 68ee323..ed5b466 100644 --- a/engine/rhi/gl/gl_legacy.cpp +++ b/engine/rhi/gl/gl_legacy.cpp @@ -366,72 +366,24 @@ 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, u_posSize, u_right, u_up; +uniform vec4 u_color; out vec4 o_color; -// A grid (a sprite with colour.a < 0: -1 dark lines, -2 light ones), worked out for each pixel -// where it lies on its plane: lines every 10^n metres (`metre`: units in one), the four sets from -// the finest that isn't crowded together here, each showing by how far apart its lines are on the -// screen (none where they crowd, stronger where they're far apart: nothing jumps as the view -// moves), about a pixel wide, smooth; the axes through the middle in the colour of the world's -// axis they run along. All from `rel`, where it is from the square's middle (a whole number of -// kilometres from the world's: far out, the world's own numbers are too coarse for lines a -// pixel wide); `eye`: the point under the eye, from the middle; it fades out `reach` from there. -vec3 gridAxisColor(vec3 d) { - d = abs(d); - return d.x > 0.5 ? vec3(0.9, 0.25, 0.25) : d.y > 0.5 ? vec3(0.3, 0.75, 0.25) : vec3(0.25, 0.45, 0.95); -} -vec4 gridColor(vec2 rel, vec3 centre, vec3 u, vec3 v, float metre, float kind, vec2 eye, float reach) { - vec2 base = vec2(dot(centre, u), dot(centre, v)); // (the middle: whole kilometres) - vec2 fw = max(fwidth(rel), vec2(1e-9)); - float finest = floor(log(min(fw.x, fw.y) * 5.0 / metre) / log(10.0)); // (10^this m: 5 pixels apart or more) - vec3 look = kind < -1.5 ? vec3(1.0, 0.05, 0.16) : vec3(0.0, 0.14, 0.4); // (shade, how faint, how strong) - float lines = 0.0; - for (int k = 0; k < 4; ++k) { - float s = metre * pow(10.0, finest + float(k)); - vec2 apart = s / fw; // (pixels between this set's lines, each way) - vec2 off = abs(fract(fract(base / s) + rel / s + 0.5) - 0.5) * apart; // (pixels to the nearest of them) - vec2 strong = smoothstep(20.0, 160.0, apart); - vec2 cover = clamp(0.5 * (1.0 + 0.6 * strong) + 0.5 - off, 0.0, 1.0); - vec2 a = cover * smoothstep(5.0, 20.0, apart) * mix(vec2(look.y), vec2(look.z), strong); - lines = max(lines, max(a.x, a.y)); - } - vec2 axis = clamp(1.25 - abs(base + rel) / fw, 0.0, 1.0) * 0.9; // (x: the line along v, y: along u) - float fade = smoothstep(reach, reach * 0.6, length(rel - eye)); - vec3 col = vec3(look.x) * lines; - float a = lines; - col = mix(col, gridAxisColor(v), axis.x), a = mix(a, 1.0, axis.x); - col = mix(col, gridAxisColor(u), axis.y), a = mix(a, 1.0, axis.y); - return vec4(col / max(a, 1e-6), a * fade); -} void main() { - 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); } diff --git a/engine/rhi/vulkan/vk_backend.cpp b/engine/rhi/vulkan/vk_backend.cpp index b42d213..e599075 100644 --- a/engine/rhi/vulkan/vk_backend.cpp +++ b/engine/rhi/vulkan/vk_backend.cpp @@ -404,7 +404,6 @@ 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 { @@ -1413,17 +1412,6 @@ 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; } diff --git a/engine/rhi/vulkan/vk_device.cpp b/engine/rhi/vulkan/vk_device.cpp index 7cd3c1b..eed0e5f 100644 --- a/engine/rhi/vulkan/vk_device.cpp +++ b/engine/rhi/vulkan/vk_device.cpp @@ -263,24 +263,6 @@ 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); @@ -289,7 +271,6 @@ void Device::destroySwapchainViews() { } VkCommandBuffer Device::beginFrame() { - if (surfaceLost_ && !recreateSurface()) return nullptr; if (swapchainDirty_ && !createSwapchain()) return nullptr; Frame& f = frames_[frameIndex_]; @@ -299,10 +280,6 @@ 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)); @@ -351,7 +328,6 @@ 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; diff --git a/engine/rhi/vulkan/vk_device.h b/engine/rhi/vulkan/vk_device.h index e138cc3..61c7eea 100644 --- a/engine/rhi/vulkan/vk_device.h +++ b/engine/rhi/vulkan/vk_device.h @@ -50,8 +50,6 @@ 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_; } @@ -83,7 +81,6 @@ private: }; bool createSwapchain(); - bool recreateSurface(); void destroySwapchainViews(); const Window* window_ = nullptr; @@ -105,7 +102,7 @@ private: std::vector views_; std::vector renderDone_; // per swapchain image, so present never reuses one in flight bool vsync_ = true; - bool swapchainDirty_ = false, surfaceLost_ = false; + bool swapchainDirty_ = false; Frame frames_[kFramesInFlight]; VkCommandPool immPool_ = VK_NULL_HANDLE; diff --git a/engine/shaders/mesh.vert b/engine/shaders/mesh.vert index 3bb52fe..22e1165 100644 --- a/engine/shaders/mesh.vert +++ b/engine/shaders/mesh.vert @@ -5,9 +5,6 @@ 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); diff --git a/engine/shaders/skinned.vert b/engine/shaders/skinned.vert index a186f36..b5406cc 100644 --- a/engine/shaders/skinned.vert +++ b/engine/shaders/skinned.vert @@ -5,9 +5,6 @@ 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); diff --git a/engine/shaders/sprite.frag b/engine/shaders/sprite.frag index 2ba66e4..a55454b 100644 --- a/engine/shaders/sprite.frag +++ b/engine/shaders/sprite.frag @@ -9,49 +9,8 @@ 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. diff --git a/engine/shaders/sprite.vert b/engine/shaders/sprite.vert index 8b9cfc1..cf238cf 100644 --- a/engine/shaders/sprite.vert +++ b/engine/shaders/sprite.vert @@ -9,20 +9,13 @@ 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); } diff --git a/engine/shaders/ssao.frag b/engine/shaders/ssao.frag index d34479c..b177797 100644 --- a/engine/shaders/ssao.frag +++ b/engine/shaders/ssao.frag @@ -56,8 +56,7 @@ void main() { vec3 v = viewPos(q) - p; float vv = dot(v, v); float falloff = max(0.0, 1.0 - vv / (pc.radius * pc.radius)); - // (+ a little against dividing by nothing: its share of the reach, so any scale looks the same) - occlusion += max(0.0, dot(v, n) - 0.01 * p.z) / (vv + pc.radius * pc.radius * 0.0017) * falloff; + occlusion += max(0.0, dot(v, n) - 0.01 * p.z) / (vv + 1.0) * 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)); diff --git a/project.json b/project.json deleted file mode 100644 index 0455066..0000000 --- a/project.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "_": "Fry3DX's details, in one place: the build reads them (the version, the About box, the Help menu, Windows' file details). Change them here; CMake picks them up on the next build.", - "name": "Fry3DX", - "long": "Frying 3D Extreme", - "version": "0.8.1", - "tagline": "A 3D modeller that does what you'd expect.", - "description": "A 3D modeller that does what you'd expect: pick a tool, click and drag. Model, sculpt, paint, render and get it ready to print, and write a script when clicking gets repetitive.", - "website": "https://nexoniarz.github.io/Fry3DX/", - "repo": "https://github.com/Nexoniarz/Fry3DX", - "issues": "https://github.com/Nexoniarz/Fry3DX/issues", - "releases": "https://github.com/Nexoniarz/Fry3DX/releases/latest", - "license": "Apache License 2.0", - "copyright": "Copyright 2026 Nexoniarz", - "company": "Nexoniarz", - "look": "Niveus", - "creators": [ - { "name": "Nexoniarz", "role": "Made it: the idea, the design, the code, the look", "url": "https://github.com/Nexoniarz" } - ], - "builtWith": ["SDL 3", "Dear ImGui", "Lua 5.4", "GLM", "Manifold", "zstd", "stb", "volk", "VMA"] -} diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index cac0173..32a111d 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -1,35 +1,19 @@ # Fry3DX: the app (app.cpp) on the engine, and its modelling core (mesh, scene) as a library the # tests use too. -# 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) +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) target_include_directories(fry_core PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}) -target_link_libraries(fry_core PUBLIC glm::glm libzstd_static PRIVATE stb cgltf manifold) +target_link_libraries(fry_core PUBLIC glm::glm libzstd_static) target_compile_definitions(fry_core PUBLIC GLM_ENABLE_EXPERIMENTAL) # 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 ${FRY_APP_SOURCES}) + add_library(fry3dx SHARED app.cpp icons.cpp) set_target_properties(fry3dx PROPERTIES OUTPUT_NAME main LIBRARY_OUTPUT_DIRECTORY ${CMAKE_RUNTIME_OUTPUT_DIRECTORY}) elseif(APPLE) # a Mac app: Fry3DX.app, content in Contents/Resources, MoltenVK in Contents/Frameworks set(icns ${PROJECT_SOURCE_DIR}/branding/fry3dx.icns) - add_executable(fry3dx MACOSX_BUNDLE ${FRY_APP_SOURCES} ${icns}) + add_executable(fry3dx MACOSX_BUNDLE app.cpp icons.cpp ${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 @@ -44,16 +28,15 @@ 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 ${FRY_APP_SOURCES}) + add_executable(fry3dx app.cpp icons.cpp) endif() if(WIN32) # a window program (no console window), with its icon - target_sources(fry3dx PRIVATE ${CMAKE_BINARY_DIR}/generated/fry3dx.rc) + target_sources(fry3dx PRIVATE fry3dx.rc) set_target_properties(fry3dx PROPERTIES WIN32_EXECUTABLE ON) target_link_options(fry3dx PRIVATE /ENTRY:mainCRTStartup) endif() target_link_libraries(fry3dx PRIVATE fry_core fry_script keel stb) -fry_project_header(${CMAKE_BINARY_DIR}/generated/fry_project.h ${CMAKE_BINARY_DIR}/generated/fry3dx.rc) # (project.json) -target_include_directories(fry3dx PRIVATE ${CMAKE_BINARY_DIR}/generated) +target_compile_definitions(fry3dx PRIVATE FRY_VERSION="${PROJECT_VERSION}") add_dependencies(fry3dx content) foreach(t fry_core fry_script fry3dx) if(MSVC) diff --git a/src/app.cpp b/src/app.cpp index e39b93f..e972808 100644 --- a/src/app.cpp +++ b/src/app.cpp @@ -1,14 +1,266 @@ -// Fry3DX: the document (opening, saving, undo), files, modes and tools, keys; and main(). -#include "app.h" +// Fry3DX — Frying 3D Extreme. A 3D modeller that works the way you'd expect: pick a tool from the +// toolbox, click and drag. The view in the middle is drawn by the engine's renderer: plain +// and clear while you model (Solid), or properly lit (Rendered). The panels are Dear ImGui. +// +// fry3dx [file.fry] +#include "icons.h" +#include "mesh.h" +#include "scene.h" +#include "script.h" +#include "sculpt.h" +#include "shapes.h" +#include #if defined(__ANDROID__) #include // (main becomes the SDL_main SDL's Java side calls) #endif +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include using namespace fry; namespace stdfs = std::filesystem; -namespace fryapp { +namespace { + +double now() { return double(SDL_GetTicks()) / 1000.0; } // (seconds; works before the UI exists) + +constexpr float kUnits = 39.37f; // the engine's units (inches) in a metre: the view renders in them +constexpr float kPi = 3.14159265358979f; + +enum class Mode { Object, Edit }; +enum class Tool { Select, Move, Rotate, Scale, Draw, Extrude, Inset, Bevel, LoopCut, Knife, Hole }; +enum class Tab { Model, Paint, Layout, Render, Print }; +enum class LayTool { Select, Move, Rotate, Scale }; +enum class PrintTool { None, Measure, LayFlat }; +enum class PaintTool { Brush, Eraser, Fill, Picker, Picture }; +enum class Elem { Vertex, Edge, Face }; +enum class View { Perspective, Front, Back, Left, Right, Top, Bottom }; +enum class Orient { Local, World, Normal }; // which way the gizmo's arrows point +enum class Pivot { Middle, Origin, Opposite, Bottom }; // what stays put when turning / sizing +enum class Shading { Solid, Rendered, Wire }; +enum class SelShape { Box, Brush, Lasso }; + +struct ToolInfo { + Tool tool; + Icon icon; + const char* name; + const char* key; + const char* hint; + bool objectMode, editMode; +}; +const ToolInfo kTools[] = { + {Tool::Select, Icon::Select, "Select", "Q", "Click to select; drag round things. Objects: Shift adds, Ctrl takes away. Shape: Ctrl adds/takes one, Shift+click a whole loop, Shift+Ctrl+click an edge ring.", true, true}, + {Tool::Move, Icon::Move, "Move", "W", "Drag an arrow to move along it, the square to move freely. Hold Ctrl to snap.", true, true}, + {Tool::Rotate, Icon::Rotate, "Rotate", "E", "Drag a ring to turn about it; the outer ring turns it as you see it. Ctrl: 15 degree steps.", true, true}, + {Tool::Scale, Icon::Scale, "Scale", "R", "Drag a side's handle to stretch it (the other side stays, or see Tool options); hold Shift to stretch both sides from the middle. Middle: all ways.", true, true}, + {Tool::Draw, Icon::Draw, "Draw shape", "D", "Click points round a shape (on the ground, or on any face of an object); click the first point (or press Enter) to finish. Esc cancels.", true, false}, + {Tool::Extrude, Icon::Extrude, "Extrude", "X", "Select faces, then drag the arrow to pull them out (along the way they face).", false, true}, + {Tool::Inset, Icon::Inset, "Inset", "I", "Select faces, then drag the handle to make a smaller face inside each.", false, true}, + {Tool::Bevel, Icon::Bevel, "Round edges", "B", "Select edges (or faces), then drag the round handle to round them off.", false, true}, + {Tool::LoopCut, Icon::LoopCut, "Loop cut", "C", "Point at an edge: the cut shows across. Click to cut there.", false, true}, + {Tool::Knife, Icon::Knife, "Knife", "K", "Click points along where to cut, or drag a straight line. Enter or double-click cuts. Cutting right through: Tool options.", false, true}, + {Tool::Hole, Icon::Hole, "Hole", "O", "Press on a face and drag out the size: round, rectangle, triangle or hexagon (nuts); all the way through, a pocket, or only its outline (a new face).", true, true}, +}; + +struct PaintToolInfo { + PaintTool tool; + Icon icon; + const char* name; + const char* key; + const char* hint; +}; +struct LayToolInfo { + LayTool tool; + Icon icon; + const char* name; + const char* key; + const char* hint; +}; +const LayToolInfo kLayTools[] = { + {LayTool::Select, Icon::Select, "Select", "Q", "Click frames (or corners) to pick them, Shift adds; drag round them to pick many. Clicking the model picks its frame too."}, + {LayTool::Move, Icon::Move, "Move", "W", "Drag frames (or corners) about the picture."}, + {LayTool::Rotate, Icon::Rotate, "Turn", "E", "Drag round to turn what's picked (Ctrl: 15 degree steps)."}, + {LayTool::Scale, Icon::Scale, "Size", "R", "Drag out or in to make what's picked bigger or smaller (Shift: only across or only up)."}, +}; +// Printers' build plates (mm, across x deep x high) and what's printed with. +struct PrinterInfo { + const char* name; + float x, y, z; +}; +const PrinterInfo kPrinters[] = { + {"Prusa MK4 / MK4S", 250, 210, 220}, {"Prusa Core One", 250, 220, 270}, {"Prusa MINI+", 180, 180, 180}, {"Bambu Lab X1 / P1 / P2", 256, 256, 256}, + {"Bambu Lab A1", 256, 256, 256}, {"Bambu Lab A1 mini", 180, 180, 180}, {"Creality Ender 3 (V2, V3)", 220, 220, 250}, {"Creality K1", 220, 220, 250}, + {"Elegoo Neptune 4", 225, 225, 265}, {"Anycubic Kobra 3", 250, 250, 260}, {"Your own size", 0, 0, 0}, +}; +struct MaterialInfo { + const char* name; + float density; // g/cm3 +}; +// What the Script panel starts with. +const char* kFirstScript = R"(-- A script builds or changes the scene. F5 or Ctrl+Enter runs it; +-- Ctrl+Z takes back everything it did. The ? button lists what there is. + +local legs = 6 +for i = 1, legs do + local a = math.rad(i * 360 / legs) + local leg = fry.add("cylinder", {radius = 0.03, height = 0.7, color = "#c47a3c"}) + leg.position = {math.cos(a) * 0.45, math.sin(a) * 0.45, 0} +end +local top = fry.add("cylinder", {radius = 0.6, height = 0.05, sides = 48, color = "#d9a066"}) +top.position = {0, 0, 0.7} +fry.frame() +)"; +const MaterialInfo kMaterials[] = {{"PLA", 1.24f}, {"PETG", 1.27f}, {"ABS", 1.04f}, {"ASA", 1.07f}, {"TPU", 1.21f}, {"Nylon", 1.14f}, {"Resin", 1.12f}}; + +struct SculptToolInfo { + SculptBrush brush; + Icon icon; + const char* name; + const char* key; + const char* hint; +}; +const SculptToolInfo kSculptTools[] = { + {SculptBrush::Draw, Icon::Sculpt, "Raise", "1", "Builds the surface up where you brush. Ctrl digs in instead."}, + {SculptBrush::Inflate, Icon::Inflate, "Puff out", "2", "Swells it out all round, like blowing it up. Ctrl shrinks it."}, + {SculptBrush::Smooth, Icon::Smooth, "Smooth", "3", "Evens out bumps. Hold Shift with any brush to smooth."}, + {SculptBrush::Flatten, Icon::Flatten, "Flatten", "4", "Presses it flat, like a palette knife."}, + {SculptBrush::Pinch, Icon::Pinch, "Pinch", "5", "Pulls it in towards the middle of the brush: sharp creases and ridges. Ctrl pushes apart."}, + {SculptBrush::Grab, Icon::Grab, "Grab", "6", "Take hold of a patch and drag it where it should be."}, +}; + +const PaintToolInfo kPaintTools[] = { + {PaintTool::Brush, Icon::PaintBrush, "Brush", "B", "Paint on the model or on the flat picture. Alt+click picks a colour; [ and ] change the size."}, + {PaintTool::Eraser, Icon::Eraser, "Eraser", "E", "Paints its plain colour back."}, + {PaintTool::Fill, Icon::Fill, "Fill", "G", "Fills the patch of about the same colour where you click."}, + {PaintTool::Picker, Icon::Picker, "Pick colour", "I", "Click to take a colour from the model or the picture."}, + {PaintTool::Picture, Icon::Picture, "Place a picture", "Ctrl+V", "Paste a picture (copied in any app) or add one from a file, then drag its corners to fit. Enter places it."}, +}; + +glm::vec3 forwardOf(float yaw, float pitch) { + const float y = glm::radians(yaw), p = glm::radians(pitch); + return {std::cos(p) * std::cos(y), std::cos(p) * std::sin(y), -std::sin(p)}; +} + +// The engine's view-projection (render/renderer.cpp), in metres. +glm::mat4 viewProjection(const glm::vec3& eye, float yaw, float pitch, float fov, float aspect, glm::vec2 shift) { + const glm::vec3 f = forwardOf(yaw, pitch); + glm::mat4 view = glm::lookAt(eye * kUnits, eye * kUnits + f, glm::vec3(0, 0, 1)); + glm::mat4 proj = glm::perspective(glm::radians(fov), aspect, 2.0f, 16384.0f); + proj[1][1] *= -1.0f; + proj[2][0] -= shift.x, proj[2][1] -= shift.y; + return proj * view * glm::scale(glm::mat4(1.0f), glm::vec3(kUnits)); +} + +struct Ray { + glm::vec3 origin, dir; +}; + +// Möller–Trumbore; t along the ray, or nothing. +std::optional hitTriangle(const Ray& r, const glm::vec3& a, const glm::vec3& b, const glm::vec3& c) { + const glm::vec3 e1 = b - a, e2 = c - a, p = glm::cross(r.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 = r.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(r.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(t) : std::nullopt; +} + +std::optional hitPlane(const Ray& r, const glm::vec3& point, const glm::vec3& normal) { + const float d = glm::dot(r.dir, normal); + if (std::abs(d) < 1e-8f) return std::nullopt; + const float t = glm::dot(point - r.origin, normal) / d; + return t > 0 ? std::optional(r.origin + r.dir * t) : std::nullopt; +} + +// Where along the line (from `p`, along unit `axis`) the ray passes closest. +float closestOnAxis(const Ray& r, const glm::vec3& p, const glm::vec3& axis) { + const glm::vec3 w = p - r.origin; + const float b = glm::dot(axis, r.dir), d = glm::dot(axis, w), e = glm::dot(r.dir, w); + const float den = 1.0f - b * b; + return den < 1e-6f ? 0.0f : (b * e - d) / den; +} + +float distToSegment(ImVec2 p, ImVec2 a, ImVec2 b) { + const float vx = b.x - a.x, vy = b.y - a.y, len = vx * vx + vy * vy; + const float t = len > 0 ? std::clamp(((p.x - a.x) * vx + (p.y - a.y) * vy) / len, 0.0f, 1.0f) : 0.0f; + const float dx = a.x + vx * t - p.x, dy = a.y + vy * t - p.y; + return std::sqrt(dx * dx + dy * dy); +} + +bool insidePolygon(ImVec2 p, const std::vector& poly) { + bool in = false; + for (size_t i = 0, j = poly.size() - 1; i < poly.size(); j = i++) + if ((poly[i].y > p.y) != (poly[j].y > p.y) && p.x < (poly[j].x - poly[i].x) * (p.y - poly[i].y) / (poly[j].y - poly[i].y) + poly[i].x) in = !in; + return in; +} + +float snapTo(float v, float step) { return step > 0 ? std::round(v / step) * step : v; } + +using Axes = std::array; +glm::vec3 inFrame(const Axes& a, const glm::vec3& p) { return {glm::dot(p, a[0]), glm::dot(p, a[1]), glm::dot(p, a[2])}; } +glm::vec3 fromFrame(const Axes& a, const glm::vec3& c) { return a[0] * c.x + a[1] * c.y + a[2] * c.z; } + +const ImU32 kAxisCol[3] = {IM_COL32(235, 70, 70, 255), IM_COL32(110, 210, 80, 255), IM_COL32(70, 130, 245, 255)}; +const ImU32 kHot = IM_COL32(255, 225, 90, 255); +constexpr ImU32 kAccent = IM_COL32(255, 120, 40, 255); +// The UI's colours: Niveus's roles, in the appearance that's showing (keel/ui). +ImVec4 roleColor(keel::ui::Role r) { return ImGui::ColorConvertU32ToFloat4(keel::ui::color(r)); } +ImVec4 accentText() { return roleColor(keel::ui::Role::AccentText); } +// The one button that does it, filled with the accent (until popAccent). +void pushAccent() { + using keel::ui::Role; + ImGui::PushStyleColor(ImGuiCol_Button, roleColor(Role::Accent)); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, roleColor(Role::AccentHover)); + ImGui::PushStyleColor(ImGuiCol_ButtonActive, roleColor(Role::AccentActive)); + ImGui::PushStyleColor(ImGuiCol_Text, roleColor(Role::OnAccent)); +} +void popAccent() { ImGui::PopStyleColor(4); } + +// An object as it'll be printed: where it stands, with its finish, checked (kept until it changes). +struct PrintInfo { + uint64_t key = ~0ull; + Mesh world; + PrintCheck check; + glm::vec3 lo{0.0f}, hi{0.0f}; + keel::ModelId overhang{}; + bool hasOverhang = false; +}; keel::Cvar fry_printer("fry_printer", "0", keel::CVAR_ARCHIVE, "Fry3DX: the 3D printer (a number in the Print tab's list)"); keel::Cvar fry_bed_x("fry_bed_x", "220", keel::CVAR_ARCHIVE, "Fry3DX: your own printer's build plate across (mm)"); @@ -21,1786 +273,7007 @@ keel::Cvar fry_touch("fry_touch", "0", keel::CVAR_ARCHIVE, "Fry3DX: the phone la #endif keel::Cvar fry_uiscale("fry_uiscale", "0", 0, "Fry3DX: draw the UI this many times bigger (0: by the screen)"); keel::Cvar fry_uisize("fry_uisize", "1", keel::CVAR_ARCHIVE, "Fry3DX: the phone layout's size, times"); -keel::Cvar fry_movespeed("fry_movespeed", "1", keel::CVAR_ARCHIVE, "Fry3DX: how fast the camera moves (keys, wheel, pan): Shift + wheel changes it"); -keel::Cvar fry_camera("fry_camera", "0", keel::CVAR_ARCHIVE, "Fry3DX: the camera: 0 flies (right drag turns it where it stands, WASD), 1 orbits (right drag round what you look at, the wheel zooms)"); keel::Cvar fry_theme("fry_theme", "0", keel::CVAR_ARCHIVE, "Fry3DX: how it looks: 0 like the desktop, 1 dark, 2 light"); keel::Cvar fry_pluginsoff("fry_pluginsoff", "", keel::CVAR_ARCHIVE, "Fry3DX: plug-ins turned off (their file names, with | between)"); keel::Cvar fry_autosave("fry_autosave", "2", keel::CVAR_ARCHIVE, "Fry3DX: autosave every this many minutes while there are changes (0: off)"); -// "Spiral_stairs" is "Spiral stairs" (typed in the console). -auto App::sameCommand(const std::string& name, std::string typed) -> bool { - std::replace(typed.begin(), typed.end(), '_', ' '); - return materialId(name) == materialId(typed); -} +class App : public keel::Game { +public: + explicit App(std::string path) : startPath_(std::move(path)) {} + const char* name() const override { return "Fry3DX"; } // (its settings folder, and bin/Fry3DX/) + const char* title() const override { return "Fry3DX"; } + bool wantsCursor() const override { return true; } + bool dockingUi() const override { return true; } -auto App::init() -> void { - static App* self = this; - // Console commands: for scripts and tests (the ` key opens the console). - static keel::Cmd open("fry_open", [](const keel::Args& a) { if (a.size() > 1) self->openFile(a[1]); }, "Fry3DX: open a .fry file"); - static keel::Cmd save("fry_save", [](const keel::Args& a) { if (a.size() > 1) self->saveFile(a[1]); }, "Fry3DX: save as"); - static keel::Cmd exp("fry_export", [](const keel::Args& a) { if (a.size() > 1) self->exportFile(a[1]); }, "Fry3DX: export glTF"); - static keel::Cmd run("fry_run", [](const keel::Args& a) { - if (a.size() < 2) return; - std::ifstream in(a[1], std::ios::binary); - if (!in) return keel::log::warn("Couldn't open {}", a[1]); - self->addScript(scriptName(a[1]), std::string(std::istreambuf_iterator(in), {})); - self->scriptPath_ = a[1]; - self->runScriptNow(); - }, "Fry3DX: run a Lua script file (fry_run path/to/script.lua)"); - static keel::Cmd add("fry_add", [](const keel::Args& a) { if (a.size() > 1) self->addShape(a[1]); }, "Fry3DX: add a shape (cube, rounded_cube, table...)"); - static keel::Cmd view("fry_view", [](const keel::Args& a) { - static const char* names[] = {"persp", "front", "back", "left", "right", "top", "bottom"}; - for (int i = 0; i < 7; ++i) - if (a.size() > 1 && a[1] == names[i]) self->setView(View(i)); - }, "Fry3DX: fry_view persp|front|back|left|right|top|bottom"); - static keel::Cmd orbit("fry_orbit", [](const keel::Args& a) { - if (a.size() > 2) self->view_ = View::Perspective, self->fov_ = 50, self->yaw_ = std::stof(a[1]), self->pitch_ = std::stof(a[2]); - if (a.size() > 3) self->dist_ = std::stof(a[3]); - if (a.size() > 6) self->target_ = {std::stof(a[4]), std::stof(a[5]), std::stof(a[6])}; - }, "Fry3DX: fry_orbit yaw pitch [distance [target x y z]]"); - // A sculpt stroke across the view (0..1 of it), for pictures of it: brush 1-6, then from and to. - static keel::Cmd sculptStroke("fry_sculpt", [](const keel::Args& a) { - if (!self->viewReady()) return self->say("fry_sculpt: the view isn't up yet (+wait 1 first)"); - if (a.size() < 6) return; - const int brush = std::clamp(std::stoi(a[1]) - 1, 0, int(std::size(kSculptTools)) - 1); - self->sculptBrush_ = kSculptTools[brush].brush; - const bool other = a.size() > 6 && a[6] == "invert"; - const auto at = [&](float t) { - const ImVec2 lo = self->viewMin_, hi = self->viewMax_; - return ImVec2(lo.x + (hi.x - lo.x) * glm::mix(std::stof(a[2]), std::stof(a[4]), t), lo.y + (hi.y - lo.y) * glm::mix(std::stof(a[3]), std::stof(a[5]), t)); - }; - self->sculptAt(at(0), true, other, false); - for (int i = 1; i <= 60; ++i) self->sculptAt(at(float(i) / 60.0f), false, other, false); - self->endSculpt(); - }, "Fry3DX: fry_sculpt brush x0 y0 x1 y1 [invert] (0..1 of the view)"); - static keel::Cmd sel("fry_select", [](const keel::Args& a) { - self->selected_.clear(); - for (const Object& o : self->scene_.objects) - if (a.size() > 1 && o.name == a[1]) self->selected_.insert(o.id), self->active_ = o.id; - }, "Fry3DX: select an object by name"); - static keel::Cmd place("fry_place", [](const keel::Args& a) { - if (a.size() < 4) return; - for (Object& o : self->scene_.objects) - if (self->selected_.count(o.id)) o.position = {std::stof(a[1]), std::stof(a[2]), std::stof(a[3])}, self->touched(o.id); - }, "Fry3DX: fry_place x y z (metres) for the selection"); - static keel::Cmd turn("fry_turn", [](const keel::Args& a) { - if (a.size() < 4) return; - for (Object& o : self->scene_.objects) - if (self->selected_.count(o.id)) o.rotation = fromEuler({std::stof(a[1]), std::stof(a[2]), std::stof(a[3])}), self->touched(o.id); - }, "Fry3DX: fry_turn x y z (degrees) for the selection"); - static keel::Cmd edit("fry_edit", [](const keel::Args& a) { - self->enterEdit(); - if (a.size() > 1) self->elem_ = a[1] == "vertex" ? Elem::Vertex : a[1] == "edge" ? Elem::Edge : Elem::Face; - if (a.size() > 2 && a[2] == "all") self->selectAllElements(); - if (a.size() > 2 && a[2] != "all") { - self->selFaces_.clear(); - for (size_t i = 2; i < a.size(); ++i) self->selFaces_.insert(std::stoi(a[i])); - } - }, "Fry3DX: fry_edit [vertex|edge|face] [all|face numbers...]"); - static keel::Cmd tool("fry_tool", [](const keel::Args& a) { - for (const ToolInfo& t : kTools) - if (a.size() > 1 && SDL_strcasecmp(a[1].c_str(), t.name) == 0) self->setTool(t.tool); - }, "Fry3DX: pick a tool by name"); - static keel::Cmd shading("fry_shading", [](const keel::Args& a) { - if (a.size() > 1) self->shading_ = a[1] == "rendered" ? Shading::Rendered : a[1] == "wire" ? Shading::Wire : Shading::Solid; - }, "Fry3DX: fry_shading solid|rendered|wire"); - static keel::Cmd finish("fry_finish", [](const keel::Args& a) { - for (Object& o : self->scene_.objects) - if (self->selected_.count(o.id)) { - if (a.size() > 1) o.round = std::stof(a[1]); - if (a.size() > 2) o.roundSteps = std::stoi(a[2]); - if (a.size() > 3) o.smoothLevels = std::stoi(a[3]); - if (a.size() > 4) o.mirror = std::stoi(a[4]); - self->touched(o.id); - } - }, "Fry3DX: fry_finish round steps smooth mirror (for the selection)"); - static keel::Cmd bevel("fry_bevel", [](const keel::Args& a) { - if (a.size() > 1) self->bevelWidth_ = std::stof(a[1]); - if (a.size() > 2) self->bevelSteps_ = std::stoi(a[2]); - self->bevelNow(); - }, "Fry3DX: round the selected edges: fry_bevel width steps"); - static keel::Cmd cut("fry_cut", [](const keel::Args& a) { - if (Object* o = self->editing(); o && a.size() > 2) self->cutAt(*o, {std::stoi(a[1]), std::stoi(a[2])}); - }, "Fry3DX: loop cut through the edge between two points"); - static keel::Cmd pic("fry_picture", [](const keel::Args& a) { if (a.size() > 1) self->addPicture(a[1]); }, "Fry3DX: add a reference picture"); - static keel::Cmd frame("fry_frame", [](const keel::Args&) { self->frameAll(); }, "Fry3DX: frame everything"); - static keel::Cmd tab("fry_tab", [](const keel::Args& a) { - if (a.size() > 1) - self->setTab(a[1] == "paint" || a[1] == "sculpt" ? Tab::Paint : a[1] == "layout" ? Tab::Layout : a[1] == "render" ? Tab::Render : a[1] == "print" ? Tab::Print : Tab::Model); - if (a.size() > 1) self->sculpting_ = a[1] == "sculpt"; - }, "Fry3DX: fry_tab model|paint|sculpt|layout|render|print"); - // (points as parts of the view: 0..1 across, 0..1 down) - auto viewPoint = [](const std::string& x, const std::string& y) { - return ImVec2(self->viewMin_.x + std::stof(x) * (self->viewMax_.x - self->viewMin_.x), self->viewMin_.y + std::stof(y) * (self->viewMax_.y - self->viewMin_.y)); - }; - static auto vp = viewPoint; - static keel::Cmd knife("fry_knife", [](const keel::Args& a) { - if (!self->viewReady()) return self->say("fry_knife: the view isn't up yet (+wait 1 first)"); - if (a.size() < 5) return; - if (a.size() > 5) self->knifeThrough_ = a[5] != "0"; - if (a.size() > 6) self->knifeAfter_ = std::stoi(a[6]); - if (a.size() > 7) self->knifeFill_ = a[7] != "0"; - std::vector pts; - for (size_t i = 1; i + 1 < a.size() && i < 5; i += 2) pts.push_back(vp(a[i], a[i + 1])); - if (Object* o = self->editing()) self->knifeApply(o->id, pts); - }, "Fry3DX: fry_knife x0 y0 x1 y1 [through after fill] (view 0..1)"); - static keel::Cmd combineCmd("fry_combine", [](const keel::Args& a) { - const std::string w = a.size() > 1 ? a[1] : "add"; - self->combineSelected(w == "subtract" ? Combine::Subtract : w == "overlap" ? Combine::Overlap : Combine::Add); - }, "Fry3DX: fry_combine add|subtract|overlap (the picked, the last kept)"); - static keel::Cmd selectCmd("fry_selectall", [](const keel::Args&) { - self->selected_.clear(); - for (const Object& o : self->scene_.objects) self->selected_.insert(o.id), self->active_ = o.id; - }, "Fry3DX: pick every object (the first added last)"); - static keel::Cmd pickCmd("fry_pick", [](const keel::Args& a) { // fry_pick name...: those picked, the first the active one - self->selected_.clear(); - for (size_t i = a.size(); i-- > 1;) + // "Spiral_stairs" is "Spiral stairs" (typed in the console). + static bool sameCommand(const std::string& name, std::string typed) { + std::replace(typed.begin(), typed.end(), '_', ' '); + return materialId(name) == materialId(typed); + } + void init() override { + static App* self = this; + // Console commands: for scripts and tests (the ` key opens the console). + static keel::Cmd open("fry_open", [](const keel::Args& a) { if (a.size() > 1) self->openFile(a[1]); }, "Fry3DX: open a .fry file"); + static keel::Cmd save("fry_save", [](const keel::Args& a) { if (a.size() > 1) self->saveFile(a[1]); }, "Fry3DX: save as"); + static keel::Cmd exp("fry_export", [](const keel::Args& a) { if (a.size() > 1) self->exportFile(a[1]); }, "Fry3DX: export glTF"); + static keel::Cmd run("fry_run", [](const keel::Args& a) { + if (a.size() < 2) return; + std::ifstream in(a[1], std::ios::binary); + if (!in) return keel::log::warn("Couldn't open {}", a[1]); + self->addScript(scriptName(a[1]), std::string(std::istreambuf_iterator(in), {})); + self->scriptPath_ = a[1]; + self->runScriptNow(); + }, "Fry3DX: run a Lua script file (fry_run path/to/script.lua)"); + static keel::Cmd add("fry_add", [](const keel::Args& a) { if (a.size() > 1) self->addShape(a[1]); }, "Fry3DX: add a shape (cube, rounded_cube, table...)"); + static keel::Cmd view("fry_view", [](const keel::Args& a) { + static const char* names[] = {"persp", "front", "back", "left", "right", "top", "bottom"}; + for (int i = 0; i < 7; ++i) + if (a.size() > 1 && a[1] == names[i]) self->setView(View(i)); + }, "Fry3DX: fry_view persp|front|back|left|right|top|bottom"); + static keel::Cmd orbit("fry_orbit", [](const keel::Args& a) { + if (a.size() > 2) self->view_ = View::Perspective, self->fov_ = 50, self->yaw_ = std::stof(a[1]), self->pitch_ = std::stof(a[2]); + if (a.size() > 3) self->dist_ = std::stof(a[3]); + }, "Fry3DX: fry_orbit yaw pitch [distance]"); + // A sculpt stroke across the view (0..1 of it), for pictures of it: brush 1-6, then from and to. + static keel::Cmd sculptStroke("fry_sculpt", [](const keel::Args& a) { + if (!self->viewReady()) return self->say("fry_sculpt: the view isn't up yet (+wait 1 first)"); + if (a.size() < 6) return; + const int brush = std::clamp(std::stoi(a[1]) - 1, 0, int(std::size(kSculptTools)) - 1); + self->sculptBrush_ = kSculptTools[brush].brush; + const bool other = a.size() > 6 && a[6] == "invert"; + const auto at = [&](float t) { + const ImVec2 lo = self->viewMin_, hi = self->viewMax_; + return ImVec2(lo.x + (hi.x - lo.x) * glm::mix(std::stof(a[2]), std::stof(a[4]), t), lo.y + (hi.y - lo.y) * glm::mix(std::stof(a[3]), std::stof(a[5]), t)); + }; + self->sculptAt(at(0), true, other, false); + for (int i = 1; i <= 60; ++i) self->sculptAt(at(float(i) / 60.0f), false, other, false); + self->endSculpt(); + }, "Fry3DX: fry_sculpt brush x0 y0 x1 y1 [invert] (0..1 of the view)"); + static keel::Cmd sel("fry_select", [](const keel::Args& a) { + self->selected_.clear(); for (const Object& o : self->scene_.objects) - if (o.name == a[i]) self->selected_.insert(o.id), self->active_ = o.id; - }, "Fry3DX: fry_pick name... (the first kept when combining)"); - static keel::Cmd copiesCmd("fry_copies", [](const keel::Args& a) { // fry_copies n row dx dy dz | n round angle [joined] - if (a.size() < 3) return; - auto& c = self->copies_; - c.count = std::stoi(a[1]), c.round = a[2] == "round", c.path = a[2] == "path"; - c.follow = std::find(a.begin(), a.end(), "still") == a.end(); - if (c.round && a.size() > 3) c.angle = std::stof(a[3]), c.centre = glm::vec3(0.0f); - if (!c.round && a.size() > 5) c.step = {std::stof(a[3]), std::stof(a[4]), std::stof(a[5])}; - c.joined = a.back() == "joined"; - self->makeCopies(); - }, "Fry3DX: fry_copies n row dx dy dz [joined] | n round angle [joined]"); - static keel::Cmd thickCmd("fry_thicken", [](const keel::Args& a) { - if (a.size() > 1) self->thickness_ = std::abs(std::stof(a[1])), self->thicknessOut_ = std::stof(a[1]) < 0; - self->thickenActive(); - }, "Fry3DX: fry_thicken metres (negative: outwards)"); - static keel::Cmd groupCmd("fry_group", [](const keel::Args& a) { - self->groupSelected(); - if (a.size() > 1 && !self->scene_.folders.empty()) self->scene_.folders.back().name = a[1], self->renamingFolder_ = 0; - }, "Fry3DX: fry_group [name]: the picked into a new folder"); - static keel::Cmd panelsCmd("fry_panels", [](const keel::Args&) { self->panelsOpen_ = !self->panelsOpen_; }, "Fry3DX: phone layout: show / hide the panels"); - static keel::Cmd goCmd("fry_goto", [](const keel::Args&) { self->goTo(self->selected_); }, "Fry3DX: the camera to the picked"); - static keel::Cmd drawCmd("fry_draw", [](const keel::Args& a) { // fry_draw mode thick x y x y x y ... (view 0..1) - if (!self->viewReady()) return self->say("fry_draw: the view isn't up yet (+wait 1 first)"); - if (a.size() < 9) return; - self->setTool(Tool::Draw); - self->drawOnMode_ = std::stoi(a[1]), self->drawThickness_ = std::stof(a[2]); - self->drawPts_.clear(); - const bool snap = self->snap_; - self->snap_ = false; - for (size_t i = 3; i + 1 < a.size(); i += 2) { - ImGuiIO io = ImGui::GetIO(); - io.MousePos = ImVec2(self->viewMin_.x + std::stof(a[i]) * (self->viewMax_.x - self->viewMin_.x), self->viewMin_.y + std::stof(a[i + 1]) * (self->viewMax_.y - self->viewMin_.y)); - if (auto p = self->drawPoint(io)) self->drawPts_.push_back(*p); + if (a.size() > 1 && o.name == a[1]) self->selected_.insert(o.id), self->active_ = o.id; + }, "Fry3DX: select an object by name"); + static keel::Cmd place("fry_place", [](const keel::Args& a) { + if (a.size() < 4) return; + for (Object& o : self->scene_.objects) + if (self->selected_.count(o.id)) o.position = {std::stof(a[1]), std::stof(a[2]), std::stof(a[3])}, self->touched(o.id); + }, "Fry3DX: fry_place x y z (metres) for the selection"); + static keel::Cmd turn("fry_turn", [](const keel::Args& a) { + if (a.size() < 4) return; + for (Object& o : self->scene_.objects) + if (self->selected_.count(o.id)) o.rotation = fromEuler({std::stof(a[1]), std::stof(a[2]), std::stof(a[3])}), self->touched(o.id); + }, "Fry3DX: fry_turn x y z (degrees) for the selection"); + static keel::Cmd edit("fry_edit", [](const keel::Args& a) { + self->enterEdit(); + if (a.size() > 1) self->elem_ = a[1] == "vertex" ? Elem::Vertex : a[1] == "edge" ? Elem::Edge : Elem::Face; + if (a.size() > 2 && a[2] == "all") self->selectAllElements(); + if (a.size() > 2 && a[2] != "all") { + self->selFaces_.clear(); + for (size_t i = 2; i < a.size(); ++i) self->selFaces_.insert(std::stoi(a[i])); + } + }, "Fry3DX: fry_edit [vertex|edge|face] [all|face numbers...]"); + static keel::Cmd tool("fry_tool", [](const keel::Args& a) { + for (const ToolInfo& t : kTools) + if (a.size() > 1 && SDL_strcasecmp(a[1].c_str(), t.name) == 0) self->setTool(t.tool); + }, "Fry3DX: pick a tool by name"); + static keel::Cmd shading("fry_shading", [](const keel::Args& a) { + if (a.size() > 1) self->shading_ = a[1] == "rendered" ? Shading::Rendered : a[1] == "wire" ? Shading::Wire : Shading::Solid; + }, "Fry3DX: fry_shading solid|rendered|wire"); + static keel::Cmd finish("fry_finish", [](const keel::Args& a) { + for (Object& o : self->scene_.objects) + if (self->selected_.count(o.id)) { + if (a.size() > 1) o.round = std::stof(a[1]); + if (a.size() > 2) o.roundSteps = std::stoi(a[2]); + if (a.size() > 3) o.smoothLevels = std::stoi(a[3]); + if (a.size() > 4) o.mirror = std::stoi(a[4]); + self->touched(o.id); + } + }, "Fry3DX: fry_finish round steps smooth mirror (for the selection)"); + static keel::Cmd bevel("fry_bevel", [](const keel::Args& a) { + if (a.size() > 1) self->bevelWidth_ = std::stof(a[1]); + if (a.size() > 2) self->bevelSteps_ = std::stoi(a[2]); + self->bevelNow(); + }, "Fry3DX: round the selected edges: fry_bevel width steps"); + static keel::Cmd cut("fry_cut", [](const keel::Args& a) { + if (Object* o = self->editing(); o && a.size() > 2) self->cutAt(*o, {std::stoi(a[1]), std::stoi(a[2])}); + }, "Fry3DX: loop cut through the edge between two points"); + static keel::Cmd pic("fry_picture", [](const keel::Args& a) { if (a.size() > 1) self->addPicture(a[1]); }, "Fry3DX: add a reference picture"); + static keel::Cmd frame("fry_frame", [](const keel::Args&) { self->frameAll(); }, "Fry3DX: frame everything"); + static keel::Cmd tab("fry_tab", [](const keel::Args& a) { + if (a.size() > 1) + self->setTab(a[1] == "paint" || a[1] == "sculpt" ? Tab::Paint : a[1] == "layout" ? Tab::Layout : a[1] == "render" ? Tab::Render : a[1] == "print" ? Tab::Print : Tab::Model); + if (a.size() > 1) self->sculpting_ = a[1] == "sculpt"; + }, "Fry3DX: fry_tab model|paint|sculpt|layout|render|print"); + // (points as parts of the view: 0..1 across, 0..1 down) + auto viewPoint = [](const std::string& x, const std::string& y) { + return ImVec2(self->viewMin_.x + std::stof(x) * (self->viewMax_.x - self->viewMin_.x), self->viewMin_.y + std::stof(y) * (self->viewMax_.y - self->viewMin_.y)); + }; + static auto vp = viewPoint; + static keel::Cmd knife("fry_knife", [](const keel::Args& a) { + if (!self->viewReady()) return self->say("fry_knife: the view isn't up yet (+wait 1 first)"); + if (a.size() < 5) return; + if (a.size() > 5) self->knifeThrough_ = a[5] != "0"; + if (a.size() > 6) self->knifeAfter_ = std::stoi(a[6]); + if (a.size() > 7) self->knifeFill_ = a[7] != "0"; + std::vector pts; + for (size_t i = 1; i + 1 < a.size() && i < 5; i += 2) pts.push_back(vp(a[i], a[i + 1])); + if (Object* o = self->editing()) self->knifeApply(o->id, pts); + }, "Fry3DX: fry_knife x0 y0 x1 y1 [through after fill] (view 0..1)"); + static keel::Cmd combineCmd("fry_combine", [](const keel::Args& a) { + const std::string w = a.size() > 1 ? a[1] : "add"; + self->combineSelected(w == "subtract" ? Combine::Subtract : w == "overlap" ? Combine::Overlap : Combine::Add); + }, "Fry3DX: fry_combine add|subtract|overlap (the picked, the last kept)"); + static keel::Cmd selectCmd("fry_selectall", [](const keel::Args&) { + self->selected_.clear(); + for (const Object& o : self->scene_.objects) self->selected_.insert(o.id), self->active_ = o.id; + }, "Fry3DX: pick every object (the first added last)"); + static keel::Cmd pickCmd("fry_pick", [](const keel::Args& a) { // fry_pick name...: those picked, the first the active one + self->selected_.clear(); + for (size_t i = a.size(); i-- > 1;) + for (const Object& o : self->scene_.objects) + if (o.name == a[i]) self->selected_.insert(o.id), self->active_ = o.id; + }, "Fry3DX: fry_pick name... (the first kept when combining)"); + static keel::Cmd copiesCmd("fry_copies", [](const keel::Args& a) { // fry_copies n row dx dy dz | n round angle [joined] + if (a.size() < 3) return; + auto& c = self->copies_; + c.count = std::stoi(a[1]), c.round = a[2] == "round"; + if (c.round && a.size() > 3) c.angle = std::stof(a[3]), c.centre = glm::vec3(0.0f); + if (!c.round && a.size() > 5) c.step = {std::stof(a[3]), std::stof(a[4]), std::stof(a[5])}; + c.joined = a.back() == "joined"; + self->makeCopies(); + }, "Fry3DX: fry_copies n row dx dy dz [joined] | n round angle [joined]"); + static keel::Cmd thickCmd("fry_thicken", [](const keel::Args& a) { + if (a.size() > 1) self->thickness_ = std::abs(std::stof(a[1])), self->thicknessOut_ = std::stof(a[1]) < 0; + self->thickenActive(); + }, "Fry3DX: fry_thicken metres (negative: outwards)"); + static keel::Cmd groupCmd("fry_group", [](const keel::Args& a) { + self->groupSelected(); + if (a.size() > 1 && !self->scene_.folders.empty()) self->scene_.folders.back().name = a[1], self->renamingFolder_ = 0; + }, "Fry3DX: fry_group [name]: the picked into a new folder"); + static keel::Cmd panelsCmd("fry_panels", [](const keel::Args&) { self->panelsOpen_ = !self->panelsOpen_; }, "Fry3DX: phone layout: show / hide the panels"); + static keel::Cmd goCmd("fry_goto", [](const keel::Args&) { self->goTo(self->selected_); }, "Fry3DX: the camera to the picked"); + static keel::Cmd drawCmd("fry_draw", [](const keel::Args& a) { // fry_draw mode thick x y x y x y ... (view 0..1) + if (!self->viewReady()) return self->say("fry_draw: the view isn't up yet (+wait 1 first)"); + if (a.size() < 9) return; + self->setTool(Tool::Draw); + self->drawOnMode_ = std::stoi(a[1]), self->drawThickness_ = std::stof(a[2]); + self->drawPts_.clear(); + const bool snap = self->snap_; + self->snap_ = false; + for (size_t i = 3; i + 1 < a.size(); i += 2) { + ImGuiIO io = ImGui::GetIO(); + io.MousePos = ImVec2(self->viewMin_.x + std::stof(a[i]) * (self->viewMax_.x - self->viewMin_.x), self->viewMin_.y + std::stof(a[i + 1]) * (self->viewMax_.y - self->viewMin_.y)); + if (auto p = self->drawPoint(io)) self->drawPts_.push_back(*p); + } + self->snap_ = snap; + self->finishShape(); + }, "Fry3DX: fry_draw mode(0 new,1 add,2 cut) thickness x y x y x y..."); + static keel::Cmd extrudeCmd("fry_extrude", [](const keel::Args& a) { if (a.size() > 1) self->extrudeNow(std::stof(a[1])); }, "Fry3DX: extrude the picked faces this far (m)"); + static keel::Cmd holeCmd("fry_hole", [](const keel::Args& a) { + Object* o = self->editing() ? self->editing() : self->find(self->active_); + if (!o || a.size() < 3) return; + const int face = std::stoi(a[1]); + if (a.size() > 3) { + const float d = std::stof(a[3]); + self->holeMode_ = d < 0 ? 2 : d == 0 ? 0 : 1; + if (d > 0) self->holeDepth_ = d; + } + if (a.size() > 4) { + static const char* names[] = {"round", "rect", "triangle", "hex"}; + for (int i = 0; i < 4; ++i) + if (a[4] == names[i]) self->holeShape_ = i; + } + const float across = std::stof(a[2]); + if (a.size() > 5) self->holeTall_ = std::stof(a[5]), self->holeSize_ = across; + if (face >= 0 && face < int(o->mesh.faces.size())) self->applyHole(o->id, face, faceCenter(o->mesh, face), across); + }, "Fry3DX: fry_hole face across [depth: 0 through, -1 outline only] [round|rect|triangle|hex] [tall] (metres, the object's own)"); + static keel::Cmd color("fry_color", [](const keel::Args& a) { + if (a.size() > 3) self->paintColor_ = {std::stof(a[1]), std::stof(a[2]), std::stof(a[3]), 1.0f}; + }, "Fry3DX: fry_color r g b (painting, 0..1)"); + static keel::Cmd stroke("fry_stroke", [](const keel::Args& a) { + if (!self->viewReady()) return self->say("fry_stroke: the view isn't up yet (+wait 1 first)"); + if (a.size() < 5) return; + const ImVec2 p0 = vp(a[1], a[2]), p1 = vp(a[3], a[4]); + for (int i = 0; i <= 40; ++i) self->paintAt(ImVec2(p0.x + (p1.x - p0.x) * float(i) / 40, p0.y + (p1.y - p0.y) * float(i) / 40), i == 0); + self->endStroke(); + }, "Fry3DX: fry_stroke x0 y0 x1 y1: a brush stroke across the view (0..1)"); + static keel::Cmd paste("fry_pastefile", [](const keel::Args& a) { if (a.size() > 1) self->floatFromFile(a[1]); }, "Fry3DX: place a picture file onto the selected object"); + static keel::Cmd corner("fry_corner", [](const keel::Args& a) { + if (PictureLayer* l = self->editLayer(); l && a.size() > 3) l->corners[size_t(std::clamp(std::stoi(a[1]), 0, 3))] = {std::stof(a[2]), std::stof(a[3])}; + }, "Fry3DX: fry_corner i x y: move a placed picture's corner (picture pixels)"); + static keel::Cmd placePic("fry_lock", [](const keel::Args&) { self->lockLayer(); }, "Fry3DX: lock the picture being moved (Enter)"); + static keel::Cmd flat("fry_pasteflat", [](const keel::Args& a) { if (a.size() > 1) self->pasteOnSurface_ = a[1] == "0"; }, "Fry3DX: fry_pasteflat 1: new pictures go on the flat picture"); + static keel::Cmd unlock("fry_unlock", [](const keel::Args& a) { + Object* o = self->find(self->active_); + if (!o || a.size() < 3 || !self->owner_.count(o->id) || !o->picture) return; + const int x = std::stoi(a[1]), y = std::stoi(a[2]); + if (x < 0 || y < 0 || x >= o->picture->w || y >= o->picture->h) return; + if (const int i = self->owner_[o->id][size_t(y) * size_t(o->picture->w) + size_t(x)]; i > 0) self->unlockLayer(o->id, i - 1); + }, "Fry3DX: fry_unlock x y: click the Picture panel there (picture pixels)"); + static keel::Cmd moveSticker("fry_sticker", [](const keel::Args& a) { + if (!self->viewReady()) return self->say("fry_sticker: the view isn't up yet (+wait 1 first)"); + Object* o = self->find(self->ledit_.object); + PictureLayer* l = self->editLayer(); + if (!o || !l || a.size() < 3) return; + const Ray ray = self->mouseRay(vp(a[1], a[2])); + const Hit h = self->pickObject(ray, o->id, true); + if (!h.object) return self->say("fry_sticker: not on the object there"); + l->face = h.face, l->centre = glm::vec3(glm::inverse(objectMatrix(*o)) * glm::vec4(ray.origin + ray.dir * h.t, 1.0f)); + if (a.size() > 4) l->width = std::stof(a[3]), l->height = std::stof(a[4]); + settleSticker(o->mesh, *l); + }, "Fry3DX: fry_sticker x y [w h]: move the sticker being moved to that point of the view"); + static keel::Cmd capture("fry_capture", [](const keel::Args& a) { if (a.size() > 1) self->savePicture(a[1]); }, "Fry3DX: save a picture of the view"); + static keel::Cmd layout("fry_layout", [](const keel::Args& a) { + Object* o = self->find(self->active_); + if (!o || a.size() < 2) return; + if (a[1] == "start") self->readyToPaint(o->id); + if (a[1] == "all") self->layoutSelectAll(); + if (a[1] == "frame" && a.size() > 2) self->lay_.object = o->id, self->lay_.corners = false, self->lay_.frames = {std::stoi(a[2])}; + if (a[1] == "checker" && a.size() > 2) self->layChecker_ = a[2] == "1"; + if (a[1] == "keep" && a.size() > 2) self->layKeepPaint_ = a[2] == "1"; + if (a[1] == "again") self->layoutAgain(*o); + if (a[1] == "turn" && a.size() > 2) { + const float r = glm::radians(std::stof(a[2])); + self->layoutApply([r](glm::vec2 p, glm::vec2 m) { return m + glm::vec2(std::cos(r) * (p - m).x - std::sin(r) * (p - m).y, std::sin(r) * (p - m).x + std::cos(r) * (p - m).y); }); + } + if (a[1] == "move" && a.size() > 3) { + const glm::vec2 d(std::stof(a[2]), std::stof(a[3])); + self->layoutApply([d](glm::vec2 p, glm::vec2) { return p + d; }); + } + if (a[1] == "scale" && a.size() > 2) { + const float k = std::stof(a[2]); + self->layoutApply([k](glm::vec2 p, glm::vec2 m) { return m + (p - m) * k; }); + } + }, "Fry3DX: fry_layout start|all|frame N|turn deg|move x y|scale k|checker 0/1|keep 0/1|again"); + static keel::Cmd print("fry_print", [](const keel::Args& a) { + if (a.size() < 2) return; + if (a[1] == "bed") self->putOnBed(); + if (a[1] == "middle") self->middleOfBed(); + if (a[1] == "printer" && a.size() > 2) keel::execute("fry_printer " + a[2]); + if (a[1] == "size" && a.size() > 3) self->pushUndo(), self->printResize(std::stoi(a[2]), std::stof(a[3])); + if ((a[1] == "measure" || a[1] == "flat") && !self->viewReady()) return self->say("fry_print: the view isn't up yet (+wait 1 first)"); + if (a[1] == "measure" && a.size() > 5) { + self->printTool_ = PrintTool::Measure, self->measure_.clear(); + for (int i = 0; i < 2; ++i) + if (auto p = self->measurePoint(vp(a[size_t(2 + i * 2)], a[size_t(3 + i * 2)]))) self->measure_.push_back(*p); + } + if (a[1] == "flat" && a.size() > 3) { // (as clicking with Lay flat) + self->printTool_ = PrintTool::LayFlat; + ImGuiIO& io = ImGui::GetIO(); + const ImVec2 keep = io.MousePos; + io.MousePos = vp(a[2], a[3]); + const Hit h = self->pickObject(self->mouseRay(io.MousePos)); + io.MousePos = keep; + if (Object* o = h.object ? self->find(h.object) : nullptr) { + const glm::vec3 n = faceNormal(self->printInfo(*o).world, h.face); + self->pushUndo(); + o->rotation = glm::normalize(glm::rotation(n, glm::vec3(0, 0, -1)) * o->rotation), self->touched(o->id); + o->position.z -= self->printInfo(*o).lo.z, self->touched(o->id); + } + self->printTool_ = PrintTool::None; + } + }, "Fry3DX: fry_print bed|middle|printer N|size axis mm|measure x0 y0 x1 y1|flat x y"); + static keel::Cmd autosaveNow("fry_autosavenow", [](const keel::Args&) { self->autosave(true); }, "Fry3DX: autosave now"); + static keel::Cmd closeAsk("fry_close", [](const keel::Args&) { if (self->allowQuit()) keel::execute("quit"); }, "Fry3DX: as the window's close button"); + static keel::Cmd recover("fry_recover", [](const keel::Args&) { self->findAutosaves(), self->showRecover_ = !self->recover_.empty(); }, "Fry3DX: show the autosaves to recover"); + static keel::Cmd scriptPanel("fry_scriptpanel", [](const keel::Args& a) { self->showScript_ = a.size() < 2 || a[1] != "0"; }, "Fry3DX: show (1) or hide (0) the Script panel"); + static keel::Cmd shapeOp("fry_shapeop", [](const keel::Args& a) { + if (a.size() < 2) return; + if (a[1] == "smooth") self->smoothPointsNow(); + else if (a[1] == "bridge") self->bridgeNow(); + else if (a[1] == "push" && a.size() > 2) { + self->startPush(); + if (Object* o = self->find(self->pushObject_); o && self->showPush_) { + self->pushBy_ = std::stof(a[2]); + pushPoints(o->mesh, self->pushPts_, self->pushBy_); + self->changedShape(*o, true); + self->showPush_ = false; + } + } else if (a[1] == "move" && a.size() > 4) { // the selection (and with soft selection, what's near) moved, world metres + Object* o = self->editing(); + if (!o) return; + self->pushUndo(); + self->drag_.soft.clear(); + if (self->softSel_) self->findSoft(); + const glm::mat4 m = objectMatrix(*o); + const glm::mat4 local = glm::inverse(m) * glm::translate(glm::mat4(1.0f), glm::vec3(std::stof(a[2]), std::stof(a[3]), std::stof(a[4]))) * m; + for (int v : self->selectedPoints()) o->mesh.verts[size_t(v)] = glm::vec3(local * glm::vec4(o->mesh.verts[size_t(v)], 1.0f)); + for (const auto& [v, w] : self->drag_.soft) o->mesh.verts[size_t(v)] = glm::mix(o->mesh.verts[size_t(v)], glm::vec3(local * glm::vec4(o->mesh.verts[size_t(v)], 1.0f)), w); + self->drag_.soft.clear(); + self->changedShape(*o, true); + } else if (a[1] == "soft") self->softSel_ = a.size() < 3 || a[2] != "0", self->softReach_ = a.size() > 3 ? std::stof(a[3]) : self->softReach_; + }, "Fry3DX: shape editing: fry_shapeop smooth | bridge | push metres | soft 1 [reach] | move x y z"); + static keel::Cmd about("fry_about", [](const keel::Args&) { self->showAbout_ = true; }, "Fry3DX: show the About window"); + static keel::Cmd plugins("fry_plugins", [](const keel::Args& a) { + if (a.size() > 1 && a[1] == "reload") self->loadPlugins(); + else self->showPlugins_ = true; + }, "Fry3DX: the Plug-ins window (fry_plugins reload: load them again)"); + static keel::Cmd plugin("fry_plugin", [](const keel::Args& a) { // fry_plugin "Command name" [settings...] + if (a.size() < 2) return; + for (size_t i = 0; i < self->plugins_.size(); ++i) + if (self->plugins_[i].p) + for (size_t c = 0; c < self->plugins_[i].p->commands.size(); ++c) + if (sameCommand(self->plugins_[i].p->commands[c].name, a[1])) { + std::vector v; + for (size_t k = 2; k < a.size(); ++k) v.push_back(std::stod(a[k])); + return self->runPluginCommand(i, c, v); + } + keel::log::warn("No plug-in command called {}", a[1]); + }, "Fry3DX: run a plug-in's command: fry_plugin Spiral_stairs 12 0.18 (_ for spaces, any case)"); + keel::execute("r_autoexposure 0; r_ssr 0; r_volumetric 0; r_shafts 0; r_dynres 0; r_renderscale 1; fps_max 144"); +#if defined(__ANDROID__) // a phone: none of the heavy parts (and its GPU's driver is kept to well-trodden ground) + keel::execute("r_gpuculling 0; r_occlusion 0; r_volumetricfog 0; r_mirrors 0; r_taa 0; r_shadowres 2048; r_lightshadows 0; fps_max 60"); +#endif + newScene(); + loadOwnMaterials(); + loadPlugins(); + if (!startPath_.empty()) openFile(startPath_); + findAutosaves(); + } + + void frame(double) override { autosave(); } + bool allowQuit() override { + if (!dirty_) return dropAutosave(), true; + askSave_ = 1; + return false; + } + void shutdown() override { + if (autosaveJob_.valid()) autosaveJob_.wait(); + } + void onEscape() override { + if (askSave_) askSave_ = 0; + else if (showRecover_) showRecover_ = false; + else if (editLayer()) cancelFloating(); + else if (!knifePts_.empty()) knifePts_.clear(); + else if (!drawPts_.empty()) drawPts_.clear(); + else if (drag_.active) cancelDrag(); + else if (mode_ == Mode::Edit) leaveEdit(); + else selected_.clear(); + } + + void render(keel::Renderer& r) override { + r_ = &r; + captureStep(); + setupUi(); + loadLogo(); + applyShading(); + takeDialogResult(); + ImGuiIO& io = ImGui::GetIO(); + if (touch()) touchFrame(io); + const ImGuiID dock = ImGui::DockSpaceOverViewport(0, nullptr, ImGuiDockNodeFlags_PassthruCentralNode); + if (touch() && (io.DisplaySize.y > io.DisplaySize.x) != portrait_) portrait_ = !portrait_, layoutDone_ = false; // (the phone turned: its layout) + if (!layoutDone_) defaultLayout(dock); + if (!touch()) keepMinSizes(ImGui::DockBuilderGetNode(dock)); + if (ImGuiDockNode* c = ImGui::DockBuilderGetCentralNode(dock)) viewMin_ = c->Pos, viewMax_ = ImVec2(c->Pos.x + c->Size.x, c->Pos.y + c->Size.y); + updateCamera(io); + menuBar(); + toolbox(); + toolOptions(); + if (!touch() || panelsOpen_) { + layersPanel(); + propertiesPanel(); + if (tab_ == Tab::Paint || tab_ == Tab::Layout) picturePanel(); } - self->snap_ = snap; - self->finishShape(); - }, "Fry3DX: fry_draw mode(0 new,1 add,2 cut) thickness x y x y x y..."); - static keel::Cmd extrudeCmd("fry_extrude", [](const keel::Args& a) { if (a.size() > 1) self->extrudeNow(std::stof(a[1])); }, "Fry3DX: extrude the picked faces this far (m)"); - static keel::Cmd holeCmd("fry_hole", [](const keel::Args& a) { - Object* o = self->editing() ? self->editing() : self->find(self->active_); - if (!o || a.size() < 3) return; - const int face = std::stoi(a[1]); - if (a.size() > 3) { - const float d = std::stof(a[3]); - self->holeMode_ = d < 0 ? 2 : d == 0 ? 0 : 1; - if (d > 0) self->holeDepth_ = d; - } - if (a.size() > 4) { - static const char* names[] = {"round", "rect", "triangle", "hex"}; - for (int i = 0; i < 4; ++i) - if (a[4] == names[i]) self->holeShape_ = i; - } - const float across = std::stof(a[2]); - if (a.size() > 5) self->holeTall_ = std::stof(a[5]), self->holeSize_ = across; - if (face >= 0 && face < int(o->mesh.faces.size())) self->applyHole(o->id, face, faceCenter(o->mesh, face), across); - }, "Fry3DX: fry_hole face across [depth: 0 through, -1 outline only] [round|rect|triangle|hex] [tall] (metres, the object's own)"); - static keel::Cmd color("fry_color", [](const keel::Args& a) { - if (a.size() > 3) self->paintColor_ = {std::stof(a[1]), std::stof(a[2]), std::stof(a[3]), 1.0f}; - }, "Fry3DX: fry_color r g b (painting, 0..1)"); - static keel::Cmd stroke("fry_stroke", [](const keel::Args& a) { - if (!self->viewReady()) return self->say("fry_stroke: the view isn't up yet (+wait 1 first)"); - if (a.size() < 5) return; - const ImVec2 p0 = vp(a[1], a[2]), p1 = vp(a[3], a[4]); - for (int i = 0; i <= 40; ++i) self->paintAt(ImVec2(p0.x + (p1.x - p0.x) * float(i) / 40, p0.y + (p1.y - p0.y) * float(i) / 40), i == 0); - self->endStroke(); - }, "Fry3DX: fry_stroke x0 y0 x1 y1: a brush stroke across the view (0..1)"); - static keel::Cmd ptool("fry_tooluse", [](const keel::Args& a) { // a plug-in tool by name, clicked at x y (0..1 of the view) - if (a.size() < 4 || !self->viewReady()) return; - for (const PluginEntry& e : self->plugins_) - if (e.p) - for (const fry::PluginTool& t : e.p->tools) - if (t.name == a[1]) self->pluginTool_ = {e.file, t.name}; - self->clickPluginTool(vp(a[2], a[3])); - }, "Fry3DX: fry_tooluse \"name\" x y: click with a plug-in tool (0..1 of the view)"); - static keel::Cmd stencil("fry_stencil", [](const keel::Args& a) { // a box stencil, 0..1 of the view - if (a.size() < 5 || !self->viewReady()) return; - const ImVec2 p0 = vp(a[1], a[2]), p1 = vp(a[3], a[4]); - self->stencil_ = {p0, ImVec2(p1.x, p0.y), p1, ImVec2(p0.x, p1.y)}, self->stencilOn_ = true; - }, "Fry3DX: fry_stencil x0 y0 x1 y1: a box stencil for painting (0..1 of the view)"); - static keel::Cmd paste("fry_pastefile", [](const keel::Args& a) { if (a.size() > 1) self->floatFromFile(a[1]); }, "Fry3DX: place a picture file onto the selected object"); - static keel::Cmd corner("fry_corner", [](const keel::Args& a) { - if (PictureLayer* l = self->editLayer(); l && a.size() > 3) l->corners[size_t(std::clamp(std::stoi(a[1]), 0, 3))] = {std::stof(a[2]), std::stof(a[3])}; - }, "Fry3DX: fry_corner i x y: move a placed picture's corner (picture pixels)"); - static keel::Cmd placePic("fry_lock", [](const keel::Args&) { self->lockLayer(); }, "Fry3DX: lock the picture being moved (Enter)"); - static keel::Cmd flat("fry_pasteflat", [](const keel::Args& a) { if (a.size() > 1) self->pasteOnSurface_ = a[1] == "0"; }, "Fry3DX: fry_pasteflat 1: new pictures go on the flat picture"); - static keel::Cmd unlock("fry_unlock", [](const keel::Args& a) { - Object* o = self->find(self->active_); - if (!o || a.size() < 3 || !self->owner_.count(o->id) || !o->picture) return; - const int x = std::stoi(a[1]), y = std::stoi(a[2]); - if (x < 0 || y < 0 || x >= o->picture->w || y >= o->picture->h) return; - if (const int i = self->owner_[o->id][size_t(y) * size_t(o->picture->w) + size_t(x)]; i > 0) self->unlockLayer(o->id, i - 1); - }, "Fry3DX: fry_unlock x y: click the Picture panel there (picture pixels)"); - static keel::Cmd moveSticker("fry_sticker", [](const keel::Args& a) { - if (!self->viewReady()) return self->say("fry_sticker: the view isn't up yet (+wait 1 first)"); - Object* o = self->find(self->ledit_.object); - PictureLayer* l = self->editLayer(); - if (!o || !l || a.size() < 3) return; - const Ray ray = self->mouseRay(vp(a[1], a[2])); - const Hit h = self->pickObject(ray, o->id, true); - if (!h.object) return self->say("fry_sticker: not on the object there"); - l->face = h.face, l->centre = glm::vec3(glm::inverse(objectMatrix(*o)) * glm::vec4(ray.origin + ray.dir * h.t, 1.0f)); - if (a.size() > 4) l->width = std::stof(a[3]), l->height = std::stof(a[4]); - settleSticker(o->mesh, *l); - }, "Fry3DX: fry_sticker x y [w h]: move the sticker being moved to that point of the view"); - static keel::Cmd bench("fry_bench", [](const keel::Args& a) { - if (a.size() < 3) return self->say("fry_bench orbit|smooth|angle|round frames"); - self->bench_ = {a[1], std::max(1, std::stoi(a[2])), {}}; - }, "Fry3DX: fry_bench orbit|smooth|angle|round frames: time that many frames while the camera turns or the picked object's setting is dragged"); - static keel::Cmd hold("fry_hold", [](const keel::Args& a) { // a key held down for some frames, as if pressed (for trying keys out) - if (a.size() < 3) return self->say("fry_hold key frames (UpArrow, PageUp, LeftShift...)"); - if (a[1] == "MouseRight") { // (pressed now, let go after that many frames) - ImGui::GetIO().AddMouseButtonEvent(ImGuiMouseButton_Right, true); - return self->holding_.push_back({ImGuiKey_MouseRight, std::stoi(a[2])}); - } - if (a[1] == "Escape") { // (the engine takes this one from the window's own events, before the panels) - SDL_Event e{}; - e.type = SDL_EVENT_KEY_DOWN, e.key.scancode = SDL_SCANCODE_ESCAPE, e.key.key = SDLK_ESCAPE, e.key.down = true; - SDL_PushEvent(&e); + if (!touch()) statusBar(); + if (!capturing() && (!touch() || viewsOpen_)) viewButtons(); + if (!capturing() && touch()) touchBar(); + if (showScript_) scriptPanel(); + if (showHelp_) helpWindow(); + if (showAbout_) aboutWindow(); + if (showPlugins_) pluginsWindow(); + commandForm(); + copiesWindow(); + thickenWindow(); + pushWindow(); + saveQuestion(); + recoverWindow(); + if (!askSave_ && !showRecover_) shortcuts(io); + viewport(io); + draw3D(r); + overlays(); + } + +private: + // ── The document ── + Scene scene_; + std::string startPath_, path_; + bool dirty_ = false; + struct Snapshot { + Scene scene; + std::set selected; + uint32_t active; + Mode mode; + std::set verts, faces; + std::set> edges; + }; + std::vector undo_, redo_; + std::vector redoKept_; // (what redo had before the last undo point: back if that step is dropped) + + Mode mode_ = Mode::Object; + Tool tool_ = Tool::Move; + Elem elem_ = Elem::Face; + std::set selected_; + uint32_t active_ = 0; // the object last picked (edited in shape editing) + uint32_t activeRef_ = 0; // a reference picture picked in the Layers panel + std::set selVerts_, selFaces_; + std::set> selEdges_; + + // ── Tabs: modelling, painting pictures onto it, making a picture of it ── + Tab tab_ = Tab::Model; + + // ── The view ── + keel::Renderer* r_ = nullptr; + glm::vec2 shift_{0.0f}; // (the view's middle on the screen: it sits between the panels) + View view_ = View::Perspective; + glm::vec3 target_{0, 0, 0.4f}; + float yaw_ = -135.0f, pitch_ = 25.0f, dist_ = 4.0f, fov_ = 50.0f; + ImVec2 viewMin_{0, 0}, viewMax_{1, 1}; + glm::mat4 vp_{1.0f}; + glm::vec3 eye_{0.0f}; + bool orbiting_ = false, panning_ = false; + bool xray_ = false, showGrid_ = true, renderGrid_ = false; + std::string appliedLook_; + keel::ModelId ground_{}; + bool haveGround_ = false; + Shading shading_ = Shading::Solid; + int appliedShading_ = -1; + + // ── Tools ── + bool snap_ = false; + float snapMove_ = 0.05f, snapAngle_ = 15.0f, snapScale_ = 0.1f; + float drawThickness_ = 0.05f, extrudeAmount_ = 0.1f, insetAmount_ = 0.2f, mergeDistance_ = 0.001f; + float bevelWidth_ = 0.05f; + int bevelSteps_ = 3, cuts_ = 1; + Orient orientObject_ = Orient::Local, orientEdit_ = Orient::Normal; + Pivot rotatePivot_ = Pivot::Middle, scalePivot_ = Pivot::Opposite; + SelShape selShape_ = SelShape::Box; + float brushRadius_ = 30.0f; + std::vector drawPts_; + // Drawing on an object: the plane of the face the first point went on, and what's made with it + // (0 a new object standing on it, 1 added to it, 2 cut into it). + glm::vec3 drawOnP_{0.0f}, drawOnN_{0.0f, 0.0f, 1.0f}; + uint32_t drawOnto_ = 0; + int drawOnMode_ = 0; + struct Drag { + bool active = false; + int handle = -1; // 0-2 an axis, 3 free / even / about the view, 4-6 an axis's other end + Tool tool = Tool::Select; + Axes axes{}; + glm::vec3 pivot{0.0f}, anchor{0.0f}, handlePoint{0.0f}; + ImVec2 startMouse{0, 0}; + float startT = 0.0f, startDist = 0.0f, startMid = 0.0f; // (the handle's way from what stays put; from the middle) + glm::vec3 startHit{0.0f}; + Scene before; + std::set> bevelEdges; + std::vector> soft; // points near the selection that follow it, and how much + } drag_; + // Soft selection (shape editing): points within `softReach_` of the selection follow it, less + // the further they are (Blender's proportional editing, Maya's soft select). + bool softSel_ = false; + float softReach_ = 0.3f; + // Push out / pull in (Alt+S): the points and the shape as it was when it started. + bool showPush_ = false; + float pushBy_ = 0.0f; + std::set pushPts_; + Mesh pushFrom_; + uint32_t pushObject_ = 0; + bool boxing_ = false, painting_ = false, lassoing_ = false, tapDraw_ = false; + ImVec2 boxStart_{0, 0}; + std::vector lasso_; + std::pair cutHover_{-1, -1}; + // The knife: points clicked on the screen (or a straight drag). + std::vector knifePts_; + bool knifeDown_ = false; + ImVec2 knifePress_{0, 0}; + bool knifeThrough_ = false, knifeFill_ = true; + int knifeAfter_ = 1; // cut right through, then: 0 just the lines, 1 pieces of the one object, 2 objects of their own, 3 the smaller ones gone + // Holes: pressed on a face, dragged out to size. + // (sizes in metres where it stands: across = a round's width, a rectangle's width, a triangle's + // side, a hexagon's flats; tall: a rectangle's height) + float holeSize_ = 0.2f, holeTall_ = 0.1f, holeDepth_ = 0.1f, holeTurn_ = 0.0f, holeRoom_ = 0.0003f; + int holeShape_ = 0, holeSides_ = 32; // a HoleShape + int holeMode_ = 0; // 0 all the way through, 1 a pocket, 2 the outline only (a new face) + struct HoleDrag { + bool active = false; + uint32_t object = 0; + int face = -1; + glm::vec3 centre{0.0f}; // (the object's own space) + float radius = 0.0f; + ImVec2 press{0, 0}; + } hole_; + + // ── Painting ── + PaintTool paintTool_ = PaintTool::Brush; + glm::vec4 paintColor_{0.85f, 0.25f, 0.1f, 1.0f}; // a: how strong + float brushPx_ = 16.0f, hardness_ = 0.5f, fillTolerance_ = 0.15f; + int pictureSize_ = 1024; + bool showUvLines_ = true; + struct PaintStroke { + bool active = false, in2D = false, haveLast = false, waiting = false; // waiting: pressed off the object, starts when the brush gets to it + uint32_t object = 0; + std::shared_ptr image; // (the object's picture: a copy made for this stroke) + Stroke stroke; + glm::vec3 last{0.0f}; + glm::vec2 last2D{0.0f}; + } paint_; + // Sculpting (the Paint tab's Shape side): brushes that push the surface about like clay. + bool sculpting_ = false; + SculptBrush sculptBrush_ = SculptBrush::Draw; + float sculptStrength_ = 0.5f, sculptPx_ = 60.0f; // (its own brush size: sculpting wants a bigger one than painting) + bool sculptMirror_ = false; // the same on the other side (left and right: the object's Y) + struct SculptStroke { + bool active = false, haveLast = false, waiting = false; + uint32_t object = 0; + std::unique_ptr mesh; + glm::vec3 last{0.0f}; + // Grab: where it was taken hold of (the world), and what (the object's own space). + glm::vec3 start{0.0f}; + std::vector> held[2]; + std::vector from[2]; + } sculpt_; + // A picture layer unlocked to be moved: which, and what's being dragged (a corner 0-3, 4 the + // whole of it, 5 the turning handle) from where. + struct LayerEdit { + uint32_t object = 0; + int index = -1, drag = -1; + bool fresh = false; // (just pasted: Esc takes it away again) + glm::vec2 grab{0.0f}, offset{0.0f}; + std::array startCorners{}; + PictureLayer start; + } ledit_; + bool pasteOnSurface_ = true; + uint32_t renamingFolder_ = 0; + // Touch (set when it starts: fry_touch). + bool touchMode_ = false, portrait_ = false, showToolbox_ = false, panelsOpen_ = false, viewsOpen_ = false, shiftLatch_ = false, ctrlLatch_ = false, touchOrbit_ = false; + struct Gesture { + bool two = false, longDone = false, rightUp = false; + ImVec2 mid{}, downPos{}; + float spread = 1; + double downAt = -1; + } gest_; + std::string captureUri_; // (a picture of the view going into a picked file) + struct CopiesSetting { + int count = 3, axis = 2; + bool round = false, joined = false; + glm::vec3 step{0.3f, 0.0f, 0.0f}, centre{0.0f}; + float angle = 360.0f; + } copies_; + bool showCopies_ = false, showThicken_ = false, thicknessOut_ = false; + float thickness_ = 0.002f; + // The Layout tab: what's picked (frames, or corners as face and corner) and what's dragged. + struct LayoutState { + uint32_t object = 0; + bool corners = false; + std::set frames; + std::set> points; + int drag = 0; // 1 moving / turning / sizing, 2 a box + glm::vec2 grab{0.0f}, centre{0.0f}; + ImVec2 boxFrom{}; + std::vector> start; + std::shared_ptr startPicture; + double remapAt = 0; + } lay_; + LayTool layTool_ = LayTool::Move; + bool layKeepPaint_ = true, layChecker_ = false; + // The Print tab. + PrintTool printTool_ = PrintTool::None; + std::vector measure_; + bool measureSnap_ = true, showOverhangs_ = true, sizing_ = false; + float overhangDeg_ = 45.0f, infill_ = 15.0f; + int material_ = 0; + std::map printInfos_; + keel::ModelId bed_{}; + glm::vec3 bedFor_{0.0f}; + // Closing, New or Open with changes not saved: asked first (askSave_), then done; afterSave_: + // what to do once the Save it chose has finished (0 nothing, 1 quit, 2 new, 3 open). + int askSave_ = 0, afterSave_ = 0; // (what to do then: 1 quit, 2 new, 3 open..., 4 open dropped_) + std::string dropped_; + bool dialogCancelled_ = false; + // Autosave: a copy packed small in the background every fry_autosave minutes while there are + // changes, kept until saved or closed properly; offered back after a crash. + std::string autosavePath_; + uint64_t edits_ = 0, autosavedEdits_ = 0; + double lastAutosave_ = 0; + std::future autosaveJob_; + std::vector recover_; + bool showRecover_ = false; + std::map> owner_; // which layer shows at each pixel + struct Uploaded { + uint64_t stamp = 0; + double at = 0; + bool dirty = false; + }; + std::map uploaded_; + std::map paintTex_; + float picZoom_ = 0.0f; // the Picture panel: screen pixels a picture pixel, and where its corner is + ImVec2 picPan_{0, 0}; + uint32_t picFor_ = 0; + + // ── Saving a picture of the view (cropped to it, without the gizmos) ── + int captureFrames_ = 0; + std::string capturePath_; + + // ── GPU side ── + std::map> models_; // object id → its model, the state built + std::map versions_; + uint64_t versionClock_ = 1; + struct Shown { + uint64_t version = 0; + bool mirrorOnly = false; + Mesh mesh; + }; + std::map shown_; + struct Picture { + bool ok = false; + float aspect = 1.0f; + }; + std::map pictures_; + keel::TextureId logo_{}; + bool logoTried_ = false, haveLogo_ = false; + + // ── UI ── + int appliedTheme_ = -1; + // The Script panel: the script being written, where it's from, what it printed. + bool showScript_ = false, showScriptHelp_ = false; + size_t script_ = 0; // the file's script shown in the Script panel + std::string scriptPath_; // where it was last saved or opened from (Save as starts there) + // Plug-ins (Script > Plug-ins): each file, whether it's on, and how loading it went. + struct PluginEntry { + std::string path, file, error; + bool bundled = false, on = true; + std::unique_ptr p; + }; + std::vector plugins_; + bool showPlugins_ = false; + // A plug-in's command waiting for its settings (the form), and a material being named. + struct PendingCommand { + size_t plugin = 0, command = 0; + std::vector values; + }; + std::optional pendingCommand_; + bool nameMaterial_ = false; + std::string materialName_; + bool materialKeepColor_ = false; + std::vector> scriptLog_; // (line, an error) + bool scriptLogToBottom_ = false; + bool styled_ = false, layoutDone_ = false, showHelp_ = false, showAbout_ = false; + std::string iniPath_, message_; + double messageAt_ = -100.0; + char renameBuf_[128] = ""; + uint32_t renaming_ = 0; + std::mutex dialogMutex_; + std::string dialogResult_; + int dialogKind_ = 0; // 1 open, 2 save, 3 export, 4 reference picture, 5 picture of the view, 6 a picture to paint on, 7 save the painted picture + int pendingKind_ = 0, dialogFilter_ = -1; + + // ───────────────────────────── document ───────────────────────────── + + Object* find(uint32_t id) { + for (Object& o : scene_.objects) + if (o.id == id) return &o; + return nullptr; + } + Reference* findRef(uint32_t id) { + for (Reference& r : scene_.references) + if (r.id == id) return &r; + return nullptr; + } + Object* editing() { return mode_ == Mode::Edit ? find(active_) : nullptr; } + void touched(uint32_t id) { versions_[id] = ++versionClock_, dirty_ = true, ++edits_; } + void touchAll() { + for (const Object& o : scene_.objects) versions_[o.id] = ++versionClock_; + } + // Its shape has been changed by hand: the library settings no longer apply. + void handMade(Object& o) { o.recipe.clear(), o.settings.clear(); } + void say(std::string text) { + message_ = std::move(text), messageAt_ = now(); + keel::log::info("{}", message_); + } + + void pushUndo() { + undo_.push_back({scene_, selected_, active_, mode_, selVerts_, selFaces_, selEdges_}); + if (undo_.size() > 200) undo_.erase(undo_.begin()); + redoKept_ = std::move(redo_), redo_.clear(); + dirty_ = true, ++edits_; + } + // The undo point just made, dropped (what it was for didn't happen): redo as it was before. + void dropUndo() { + if (!undo_.empty()) undo_.pop_back(); + redo_ = std::move(redoKept_), redoKept_.clear(); + } + // Back to the undo point just made, and it dropped. + void takeBack() { + if (undo_.empty()) return; + restore(undo_.back()); + dropUndo(); + } + void restore(Snapshot& s) { + // (Not the scripts: they're edited like text, not undone with the model.) + std::vector scripts = std::move(scene_.scripts); + scene_ = s.scene, selected_ = s.selected, active_ = s.active, mode_ = s.mode; + scene_.scripts = std::move(scripts); + selVerts_ = s.verts, selFaces_ = s.faces, selEdges_ = s.edges; + if (mode_ == Mode::Edit && !find(active_)) mode_ = Mode::Object; + touchAll(); + drawPts_.clear(); + } + void undo() { + if (undo_.empty()) return say("Nothing to undo"); + redo_.push_back({scene_, selected_, active_, mode_, selVerts_, selFaces_, selEdges_}); + restore(undo_.back()); + undo_.pop_back(); + redoKept_.clear(); + } + void redo() { + if (redo_.empty()) return say("Nothing to redo"); + undo_.push_back({scene_, selected_, active_, mode_, selVerts_, selFaces_, selEdges_}); + restore(redo_.back()); + redo_.pop_back(); + redoKept_.clear(); + } + + void newScene() { + scene_ = {}; + script_ = 0, scriptPath_.clear(); + path_.clear(); + undo_.clear(), redo_.clear(), selected_.clear(); + mode_ = Mode::Object, active_ = 0; + addShape("cube", false); + dirty_ = false; + target_ = {0, 0, 0.5f}, dist_ = 4.5f, view_ = View::Perspective, fov_ = 50, yaw_ = -135, pitch_ = 25; + } + Object& addObject(const std::string& base, Mesh mesh) { + Object o; + o.id = scene_.nextId++; + o.name = uniqueName(base); + o.mesh = std::move(mesh); + scene_.objects.push_back(std::move(o)); + touched(scene_.objects.back().id); + return scene_.objects.back(); + } + std::string uniqueName(const std::string& base) { + auto taken = [&](const std::string& n) { return std::any_of(scene_.objects.begin(), scene_.objects.end(), [&](const Object& o) { return o.name == n; }); }; + if (!taken(base)) return base; + for (int i = 2;; ++i) + if (!taken(std::format("{} {}", base, i))) return std::format("{} {}", base, i); + } + + // A shape from the library, standing on the ground under the middle of the view. + void addShape(const std::string& id, bool undoable = true) { + const ShapeRecipe* r = findRecipe(id); + if (!r) return say("No shape called " + id); + if (undoable) pushUndo(); + if (mode_ == Mode::Edit) leaveEdit(); + Object& o = addObject(r->name, makeFromRecipe(*r, defaultSettings(*r))); + o.recipe = r->id, o.settings = defaultSettings(*r), o.smooth = r->smooth; + o.position = glm::vec3(std::round(target_.x * 10) / 10, std::round(target_.y * 10) / 10, 0.0f); + if (view_ == View::Front || view_ == View::Back) o.position.x = 0; + if (view_ == View::Left || view_ == View::Right) o.position.y = 0; + selected_ = {o.id}, active_ = o.id; + if (undoable) say(std::format("{} added: its sizes are in Properties", r->name)); + } + + void deleteSelected() { + if (mode_ == Mode::Edit) return deleteElements(); + if (activeRef_ && selected_.empty()) { + pushUndo(); + std::erase_if(scene_.references, [&](const Reference& r) { return r.id == activeRef_; }); + activeRef_ = 0; return; } - for (int k = ImGuiKey_NamedKey_BEGIN; k < ImGuiKey_NamedKey_END; ++k) - if (a[1] == ImGui::GetKeyName(ImGuiKey(k))) { - ImGui::GetIO().AddKeyEvent(ImGuiKey(k), true); - return self->holding_.push_back({ImGuiKey(k), std::stoi(a[2])}); - } - self->say("fry_hold: no key called " + a[1]); - }, "Fry3DX: fry_hold key frames: hold a key down (UpArrow, PageUp...)"); - static keel::Cmd where("fry_where", [](const keel::Args&) { - self->say(std::format("camera: yaw {:.1f} pitch {:.1f} distance {:.3f} target {:.2f} {:.2f} {:.2f} eye {:.2f} {:.2f} {:.2f}", self->yaw_, self->pitch_, self->dist_, - self->target_.x, self->target_.y, self->target_.z, self->eye_.x, self->eye_.y, self->eye_.z)); - }, "Fry3DX: where the camera is"); - static keel::Cmd mouse("fry_mouse", [](const keel::Args& a) { // the mouse to x y (0..1 of the view) - if (a.size() < 3) return; - ImGui::GetIO().AddMousePosEvent(self->viewMin_.x + (self->viewMax_.x - self->viewMin_.x) * std::stof(a[1]), self->viewMin_.y + (self->viewMax_.y - self->viewMin_.y) * std::stof(a[2])); - }, "Fry3DX: fry_mouse x y: the mouse to that point of the view"); - static keel::Cmd wheel("fry_wheel", [](const keel::Args& a) { // the wheel turned at x y (0..1 of the view) - if (a.size() < 4) return; - ImGuiIO& io = ImGui::GetIO(); - io.AddMousePosEvent(self->viewMin_.x + (self->viewMax_.x - self->viewMin_.x) * std::stof(a[1]), self->viewMin_.y + (self->viewMax_.y - self->viewMin_.y) * std::stof(a[2])); - io.AddMouseWheelEvent(0, std::stof(a[3])); - }, "Fry3DX: fry_wheel x y notches: the mouse wheel at that point of the view"); - static keel::Cmd capture("fry_capture", [](const keel::Args& a) { if (a.size() > 1) self->savePicture(a[1]); }, "Fry3DX: save a picture of the view"); - static keel::Cmd layout("fry_layout", [](const keel::Args& a) { - Object* o = self->find(self->active_); - if (!o || a.size() < 2) return; - if (a[1] == "start") self->readyToPaint(o->id); - if (a[1] == "all") self->layoutSelectAll(); - if (a[1] == "frame" && a.size() > 2) self->lay_.object = o->id, self->lay_.corners = false, self->lay_.frames = {std::stoi(a[2])}; - if (a[1] == "checker" && a.size() > 2) self->layChecker_ = a[2] == "1"; - if (a[1] == "keep" && a.size() > 2) self->layKeepPaint_ = a[2] == "1"; - if (a[1] == "again") self->layoutAgain(*o); - if (a[1] == "turn" && a.size() > 2) { - const float r = glm::radians(std::stof(a[2])); - self->layoutApply([r](glm::vec2 p, glm::vec2 m) { return m + glm::vec2(std::cos(r) * (p - m).x - std::sin(r) * (p - m).y, std::sin(r) * (p - m).x + std::cos(r) * (p - m).y); }); - } - if (a[1] == "move" && a.size() > 3) { - const glm::vec2 d(std::stof(a[2]), std::stof(a[3])); - self->layoutApply([d](glm::vec2 p, glm::vec2) { return p + d; }); - } - if (a[1] == "scale" && a.size() > 2) { - const float k = std::stof(a[2]); - self->layoutApply([k](glm::vec2 p, glm::vec2 m) { return m + (p - m) * k; }); - } - }, "Fry3DX: fry_layout start|all|frame N|turn deg|move x y|scale k|checker 0/1|keep 0/1|again"); - static keel::Cmd print("fry_print", [](const keel::Args& a) { - if (a.size() < 2) return; - if (a[1] == "bed") self->putOnBed(); - if (a[1] == "layer" && a.size() > 2) self->showLayer_ = true, self->layer_ = std::stoi(a[2]); - if (a[1] == "middle") self->middleOfBed(); - if (a[1] == "printer" && a.size() > 2) keel::execute("fry_printer " + a[2]); - if (a[1] == "size" && a.size() > 3) self->pushUndo(), self->printResize(std::stoi(a[2]), std::stof(a[3])); - if ((a[1] == "measure" || a[1] == "flat") && !self->viewReady()) return self->say("fry_print: the view isn't up yet (+wait 1 first)"); - if (a[1] == "measure" && a.size() > 5) { - self->printTool_ = PrintTool::Measure, self->measure_.clear(); - for (int i = 0; i < 2; ++i) - if (auto p = self->measurePoint(vp(a[size_t(2 + i * 2)], a[size_t(3 + i * 2)]))) self->measure_.push_back(*p); - } - if (a[1] == "flat" && a.size() > 3) { // (as clicking with Lay flat) - self->printTool_ = PrintTool::LayFlat; - ImGuiIO& io = ImGui::GetIO(); - const ImVec2 keep = io.MousePos; - io.MousePos = vp(a[2], a[3]); - const Hit h = self->pickObject(self->mouseRay(io.MousePos)); - io.MousePos = keep; - if (Object* o = h.object ? self->find(h.object) : nullptr) { - const glm::vec3 n = faceNormal(self->printInfo(*o).world, h.face); - self->pushUndo(); - o->rotation = glm::normalize(glm::rotation(n, glm::vec3(0, 0, -1)) * o->rotation), self->touched(o->id); - o->position.z -= self->printInfo(*o).lo.z, self->touched(o->id); - } - self->printTool_ = PrintTool::None; - } - }, "Fry3DX: fry_print bed|middle|printer N|size axis mm|measure x0 y0 x1 y1|flat x y"); - static keel::Cmd autosaveNow("fry_autosavenow", [](const keel::Args&) { self->autosave(true); }, "Fry3DX: autosave now"); - static keel::Cmd closeAsk("fry_close", [](const keel::Args&) { if (self->allowQuit()) keel::execute("quit"); }, "Fry3DX: as the window's close button"); - static keel::Cmd recover("fry_recover", [](const keel::Args&) { self->findAutosaves(), self->showRecover_ = !self->recover_.empty(); }, "Fry3DX: show the autosaves to recover"); - static keel::Cmd scriptPanel("fry_scriptpanel", [](const keel::Args& a) { self->showScript_ = a.size() < 2 || a[1] != "0"; }, "Fry3DX: show (1) or hide (0) the Script panel"); - static keel::Cmd shapeOp("fry_shapeop", [](const keel::Args& a) { - if (a.size() < 2) return; - if (a[1] == "smooth") self->smoothPointsNow(); - else if (a[1] == "bridge") self->bridgeNow(); - else if (a[1] == "push" && a.size() > 2) { - self->startPush(); - if (Object* o = self->find(self->pushObject_); o && self->showPush_) { - self->pushBy_ = std::stof(a[2]); - pushPoints(o->mesh, self->pushPts_, self->pushBy_); - self->changedShape(*o, true); - self->showPush_ = false; - } - } else if (a[1] == "move" && a.size() > 4) { // the selection (and with soft selection, what's near) moved, world metres - Object* o = self->editing(); - if (!o) return; - self->pushUndo(); - const Mesh before = o->mesh; - self->drag_.soft.clear(); - if (self->softSel_) self->findSoft(); - const glm::mat4 m = objectMatrix(*o); - const glm::mat4 local = glm::inverse(m) * glm::translate(glm::mat4(1.0f), glm::vec3(std::stof(a[2]), std::stof(a[3]), std::stof(a[4]))) * m; - for (int v : self->selectedPoints()) o->mesh.verts[size_t(v)] = glm::vec3(local * glm::vec4(o->mesh.verts[size_t(v)], 1.0f)); - for (const auto& [v, w] : self->drag_.soft) o->mesh.verts[size_t(v)] = glm::mix(o->mesh.verts[size_t(v)], glm::vec3(local * glm::vec4(o->mesh.verts[size_t(v)], 1.0f)), w); - self->drag_.soft.clear(); - if (self->liveMirror_) self->mirrorEdits(*o, before); - self->changedShape(*o, true); - } else if (a[1] == "soft") self->softSel_ = a.size() < 3 || a[2] != "0", self->softReach_ = a.size() > 3 ? std::stof(a[3]) : self->softReach_; - }, "Fry3DX: shape editing: fry_shapeop smooth | bridge | push metres | soft 1 [reach] | move x y z"); - static keel::Cmd search("fry_search", [](const keel::Args& a) { // fry_search [words]: the command search, typed in - self->openSearch(); - if (a.size() > 1) std::snprintf(self->search_.text, sizeof self->search_.text, "%s", a[1].c_str()); - }, "Fry3DX: open the command search (F3), with words typed in"); - static keel::Cmd runCmd("fry_command", [](const keel::Args& a) { if (a.size() > 1) self->buildCommands(), self->runCommand(a[1]); }, "Fry3DX: run a command by its id (file.save, view.top...)"); - static keel::Cmd pie("fry_pie", [](const keel::Args& a) { - self->pie_ = a.size() > 1 && a[1] == "views" ? 2 : 1, self->pieStays_ = true; - self->pieAt_ = ImVec2((self->viewMin_.x + self->viewMax_.x) / 2, (self->viewMin_.y + self->viewMax_.y) / 2); - }, "Fry3DX: show a pie menu (tools | views)"); - static keel::Cmd import("fry_import", [](const keel::Args& a) { if (a.size() > 1) self->importFile(a[1]); }, "Fry3DX: import a model (STL, OBJ, 3MF, glTF)"); - static keel::Cmd text("fry_text", [](const keel::Args& a) { - if (a.size() > 1) std::snprintf(self->textBuf_, sizeof self->textBuf_, "%s", a[1].c_str()), self->addText(); - }, "Fry3DX: add text as a shape"); - static keel::Cmd spinCmd("fry_spin", [](const keel::Args& a) { // fry_spin degrees steps [rise_mm] [world] - if (a.size() > 2) self->spinDegrees_ = std::stof(a[1]), self->spinSteps_ = std::stoi(a[2]); - self->spinRise_ = a.size() > 3 ? std::stof(a[3]) / 1000 : 0.0f, self->spinWorld_ = a.size() > 4 && a[4] == "world"; - self->spinSelected(); - }, "Fry3DX: spin the picked shape round Z: degrees steps [rise mm] [world]"); - static keel::Cmd slideCmd("fry_slide", [](const keel::Args& a) { // (as though the mouse had gone dx, dy since) - self->startSlide(); - if (a.size() > 2) self->slide_.start = ImVec2(ImGui::GetIO().MousePos.x - std::stof(a[1]), ImGui::GetIO().MousePos.y - std::stof(a[2])); - }, "Fry3DX: start sliding the picked points, as though the mouse had moved dx dy"); - static keel::Cmd about("fry_about", [](const keel::Args&) { self->showAbout_ = true; }, "Fry3DX: show the About window"); - static keel::Cmd plugins("fry_plugins", [](const keel::Args& a) { - if (a.size() > 1 && a[1] == "reload") self->loadPlugins(); - else self->showPlugins_ = true; - }, "Fry3DX: the Plug-ins window (fry_plugins reload: load them again)"); - static keel::Cmd plugin("fry_plugin", [](const keel::Args& a) { // fry_plugin "Command name" [settings...] - if (a.size() < 2) return; - for (size_t i = 0; i < self->plugins_.size(); ++i) - if (self->plugins_[i].p) - for (size_t c = 0; c < self->plugins_[i].p->commands.size(); ++c) - if (sameCommand(self->plugins_[i].p->commands[c].name, a[1])) { - std::vector v; - for (size_t k = 2; k < a.size(); ++k) v.push_back(std::stod(a[k])); - return self->runPluginCommand(i, c, v); - } - keel::log::warn("No plug-in command called {}", a[1]); - }, "Fry3DX: run a plug-in's command: fry_plugin Spiral_stairs 12 0.18 (_ for spaces, any case)"); - keel::execute("r_autoexposure 0; r_ssr 0; r_volumetric 0; r_shafts 0; r_dynres 0; r_renderscale 1; fps_max 144"); -#if defined(__ANDROID__) // a phone: none of the heavy parts (and its GPU's driver is kept to well-trodden ground) - keel::execute("r_gpuculling 0; r_occlusion 0; r_volumetricfog 0; r_mirrors 0; r_taa 0; r_shadowres 2048; r_lightshadows 0; fps_max 60"); -#endif - newScene(); - loadOwnMaterials(); - loadPlugins(); - loadMyKeys(); - buildCommands(); - if (!startPath_.empty()) openFile(startPath_); - findAutosaves(); - if (startPath_.empty()) resumeWork(); -} - -auto App::onBackground() -> void { - if (dirty_) autosave(true); - if (autosaveJob_.valid()) autosaveJob_.wait(); -#if defined(__ANDROID__) - std::ofstream(resumeFile()) << (dirty_ ? autosavePath_ : std::string()) << '\n' - << path_ << '\n' - << int(tab_) << ' ' << target_.x << ' ' << target_.y << ' ' << target_.z << ' ' << yaw_ << ' ' << pitch_ << ' ' << dist_ << '\n'; -#endif -} - -auto App::resumeWork() -> void { -#if defined(__ANDROID__) - std::ifstream in(resumeFile()); - if (!in) return; - std::string saved, from; - std::getline(in, saved), std::getline(in, from); - int tab = 0; - glm::vec3 target = target_; - float yaw = yaw_, pitch = pitch_, dist = dist_; - const bool view = bool(in >> tab >> target.x >> target.y >> target.z >> yaw >> pitch >> dist); - in.close(); - std::error_code ec; - stdfs::remove(resumeFile(), ec); - if (!saved.empty() && stdfs::exists(saved, ec)) { // (unsaved work: as recovering it, without asking) - openFile(saved); - path_ = from, dirty_ = true, autosavePath_ = saved; - showRecover_ = false; - } else if (!from.empty()) - openFile(from); - else - return; - if (view) setTab(Tab(std::clamp(tab, 0, 4))), target_ = target, yaw_ = yaw, pitch_ = std::clamp(pitch, -89.0f, 89.0f), dist_ = std::max(dist, 0.01f); - say("Carried on where you left off"); -#endif -} - -auto App::allowQuit() -> bool { - std::error_code ec; - stdfs::remove(resumeFile(), ec); // (closed on purpose: the next start is a fresh one) - if (!dirty_) return dropAutosave(), true; - askSave_ = 1; - return false; -} - -auto App::onEscape() -> void { - // (the engine takes Escape before the panels see it: whatever's open on top goes first) - if (search_.open) search_.open = false; - else if (pie_) pie_ = 0; - else if (askSave_) askSave_ = 0; - else if (showRecover_) showRecover_ = false; - else if (editLayer()) cancelFloating(); - else if (!knifePts_.empty()) knifePts_.clear(); - else if (!drawPts_.empty()) drawPts_.clear(), sketchTyped_.clear(); - else if (drag_.active) cancelDrag(); - else if (slide_.active) cancelSlide(); - else if (printTool_ == PrintTool::Measure) printTool_ = PrintTool::None, measure_.clear(); - else if (!pluginTool_.first.empty()) pluginTool_ = {}; - else if (drawingStencil_ || stencilOn_) drawingStencil_ = stencilOn_ = false, stencil_.clear(); - else if (mode_ == Mode::Edit) leaveEdit(); - else selected_.clear(); -} - -// (fry_bench) Each frame: the change being timed. -auto App::benchStep() -> void { - for (auto& [k, left] : holding_) - if (--left <= 0) k == ImGuiKey_MouseRight ? ImGui::GetIO().AddMouseButtonEvent(ImGuiMouseButton_Right, false) : ImGui::GetIO().AddKeyEvent(k, false); - std::erase_if(holding_, [](const auto& h) { return h.second <= 0; }); - if (bench_.left <= 0) return; - const int i = int(bench_.ms.size()); - Object* o = find(active_); - if (bench_.what == "orbit") yaw_ += 2.0f; - else if (o && bench_.what == "smooth") o->smoothLevels = 1 + i / 4 % 3, o->smooth = true, touched(o->id); - else if (o && bench_.what == "angle") o->smooth = true, o->smoothAngle = 20.0f + float(i % 40), touched(o->id); - else if (o && bench_.what == "round") o->round = 0.01f + 0.001f * float(i % 30), touched(o->id); -} - -auto App::benchEnd(std::chrono::steady_clock::time_point began) -> void { - if (bench_.left <= 0) return; - bench_.ms.push_back(std::chrono::duration(std::chrono::steady_clock::now() - began).count()); - if (--bench_.left) return; - double sum = 0, most = 0; - for (double m : bench_.ms) sum += m, most = std::max(most, m); - say(std::format("fry_bench {}: {} frames, {:.2f} ms each, the slowest {:.2f} ms", bench_.what, bench_.ms.size(), sum / double(bench_.ms.size()), most)); -} - -auto App::render(keel::Renderer& r) -> void { - struct Timed { // (for fry_bench: the whole frame, however it ends) - App* app; - std::chrono::steady_clock::time_point began = std::chrono::steady_clock::now(); - ~Timed() { app->benchEnd(began); } - } timed{this}; - benchStep(); - r_ = &r; - captureStep(); - setupUi(); - loadLogo(); - applyShading(); - takeDialogResult(); - ImGuiIO& io = ImGui::GetIO(); - if (touch()) touchFrame(io); - if (!pendingLayout_.empty()) ImGui::LoadIniSettingsFromMemory(pendingLayout_.data(), pendingLayout_.size()), pendingLayout_.clear(); // (a workspace: before any panel) - const ImGuiID dock = ImGui::DockSpaceOverViewport(0, nullptr, ImGuiDockNodeFlags_PassthruCentralNode); - if (touch() && (io.DisplaySize.y > io.DisplaySize.x) != portrait_) portrait_ = !portrait_, layoutDone_ = false; // (the phone turned: its layout) - if (!layoutDone_) defaultLayout(dock); - if (!touch()) keepMinSizes(ImGui::DockBuilderGetNode(dock)); - if (ImGuiDockNode* c = ImGui::DockBuilderGetCentralNode(dock)) viewMin_ = c->Pos, viewMax_ = ImVec2(c->Pos.x + c->Size.x, c->Pos.y + c->Size.y); - updateCamera(io); - menuBar(); - toolbox(); - toolOptions(); - if (!touch() || panelsOpen_) { - layersPanel(); - propertiesPanel(); - if (tab_ == Tab::Paint || tab_ == Tab::Layout) picturePanel(); - } - if (!touch()) statusBar(); - if (!capturing() && (!touch() || viewsOpen_)) viewButtons(); - if (!capturing() && touch()) touchBar(); - if (showScript_) scriptPanel(); - if (showHelp_) helpWindow(); - if (showAbout_) aboutWindow(); - if (showHistory_) historyWindow(); - if (showWorkspaces_) workspacesWindow(); - if (showPlugins_) pluginsWindow(); - commandForm(); - copiesWindow(); - thickenWindow(); - textWindow(); - printSizeWindow(); - spinWindow(); - pushWindow(); - saveQuestion(); - recoverWindow(); - commandSearch(); - if (!askSave_ && !showRecover_ && !search_.open) shortcuts(io); - if (pie_) return draw3D(r), overlays(), pieFrame(io); - viewport(io); - draw3D(r); - overlays(); -} - -// (each object's shape as last kept: the next step shares it if it's the same) -auto App::snapshot(std::string label, double at) -> Snapshot { - Snapshot s; - s.scene.look = scene_.look, s.scene.folders = scene_.folders, s.scene.references = scene_.references, s.scene.nextId = scene_.nextId; // (not the scripts: not undone) - s.scene.objects.reserve(scene_.objects.size()); - std::map kept; - for (Object& o : scene_.objects) { - Mesh mesh = std::move(o.mesh); - auto uvs = std::move(o.uvs); - s.scene.objects.push_back(o); // (all but its shape) - o.mesh = std::move(mesh), o.uvs = std::move(uvs); - Shape k = lastShapes_.count(o.id) ? lastShapes_[o.id] : Shape{}; - if (!k.mesh || k.mesh->verts != o.mesh.verts || k.mesh->faces != o.mesh.faces) k.mesh = std::make_shared(o.mesh); - if (!k.uvs || *k.uvs != o.uvs) k.uvs = std::make_shared>>(o.uvs); - s.shapes[o.id] = kept[o.id] = k; - } - lastShapes_ = std::move(kept); // (gone objects' shapes: only in the steps that have them) - s.selected = selected_, s.active = active_, s.mode = mode_, s.verts = selVerts_, s.faces = selFaces_, s.edges = selEdges_; - s.label = std::move(label), s.at = at; - return s; -} - -// ───────────────────────────── document ───────────────────────────── - -auto App::find(uint32_t id) -> Object* { - for (Object& o : scene_.objects) - if (o.id == id) return &o; - return nullptr; -} - -auto App::findRef(uint32_t id) -> Reference* { - for (Reference& r : scene_.references) - if (r.id == id) return &r; - return nullptr; -} - -auto App::shapeKey(const Object& o) -> uint64_t { - ShapeKey& k = shapeKeys_[o.id]; - const uint64_t v = versions_[o.id]; - if (k.version == v && v) return k.key; - uint64_t h = 1469598103934665603ull; - auto mix = [&](const void* p, size_t n) { - const auto* b = static_cast(p); - size_t i = 0; - for (; i + 8 <= n; i += 8) { // (8 bytes at a time: a big mesh in a few ms) - uint64_t w; - std::memcpy(&w, b + i, 8); - h = (h ^ w) * 1099511628211ull; - } - for (; i < n; ++i) h = (h ^ b[i]) * 1099511628211ull; - }; - mix(o.mesh.verts.data(), o.mesh.verts.size() * sizeof(glm::vec3)); - for (const auto& f : o.mesh.faces) { - const uint64_t n = f.size(); - mix(&n, 8), mix(f.data(), f.size() * sizeof(int)); - } - for (const auto& [a, b] : o.creases) mix(&a, 4), mix(&b, 4); - for (const auto& f : o.uvs) mix(f.data(), f.size() * sizeof(glm::vec2)); - const uint64_t cut = cutKeys_.count(o.id) ? cutKeys_[o.id] : 0; - const void* pic = o.picture.get(); - mix(&cut, 8), mix(&pic, sizeof pic); - mix(&o.mirror, 4), mix(&o.round, 4), mix(&o.roundAngle, 4), mix(&o.roundSteps, 4), mix(&o.smoothLevels, 4), mix(&o.smoothKeepSharp, 1); - // (not smooth shading and its angle: they change only how it's lit, see modelOf) - k = {v, h ? h : 1}; - return k.key; -} - -auto App::say(std::string text) -> void { - message_ = std::move(text), messageAt_ = now(); - if (!undo_.empty() && undo_.back().label.empty() && now() - undo_.back().at < 1.0) undo_.back().label = message_; // (names the step just done) - keel::log::info("{}", message_); -} - -auto App::pushUndo() -> void { - undo_.push_back(snapshot("", now())); - if (undo_.size() > 200) undo_.erase(undo_.begin()); - redoKept_ = std::move(redo_), redo_.clear(); - dirty_ = true, ++edits_; -} - -// The undo point just made, dropped (what it was for didn't happen): redo as it was before. -auto App::dropUndo() -> void { - if (!undo_.empty()) undo_.pop_back(); - redo_ = std::move(redoKept_), redoKept_.clear(); -} - -// Back to the undo point just made, and it dropped. -auto App::takeBack() -> void { - if (undo_.empty()) return; - restore(undo_.back()); - dropUndo(); -} - -auto App::restore(Snapshot& s) -> void { - // (Not the scripts: they're edited like text, not undone with the model.) - std::vector scripts = std::move(scene_.scripts); - scene_ = s.scene, selected_ = s.selected, active_ = s.active, mode_ = s.mode, followAt_.clear(); - scene_.scripts = std::move(scripts); - for (Object& o : scene_.objects) - if (auto it = s.shapes.find(o.id); it != s.shapes.end()) o.mesh = *it->second.mesh, o.uvs = *it->second.uvs; - lastShapes_ = s.shapes; - selVerts_ = s.verts, selFaces_ = s.faces, selEdges_ = s.edges; - if (mode_ == Mode::Edit && !find(active_)) mode_ = Mode::Object; - touchAll(); - drawPts_.clear(); -} - -auto App::undo() -> void { - if (undo_.empty()) return say("Nothing to undo"); - redo_.push_back(snapshot(undo_.back().label, now())); // (its name: the step it brings back) - restore(undo_.back()); - undo_.pop_back(); - redoKept_.clear(); -} - -auto App::redo() -> void { - if (redo_.empty()) return say("Nothing to redo"); - undo_.push_back(snapshot(redo_.back().label, now() - 10)); - restore(redo_.back()); - redo_.pop_back(); - redoKept_.clear(); -} - -auto App::newScene() -> void { - scene_ = {}, followAt_.clear(); - script_ = 0, scriptPath_.clear(); - path_.clear(); - undo_.clear(), redo_.clear(), selected_.clear(), lastShapes_.clear(); - mode_ = Mode::Object, active_ = 0; - addShape("cube", false); - dirty_ = false; - target_ = {0, 0, 0.5f}, dist_ = 4.5f, view_ = View::Perspective, fov_ = 50, yaw_ = -135, pitch_ = 25; -} - -auto App::addObject(const std::string& base, Mesh mesh) -> Object& { - Object o; - o.id = scene_.nextId++; - o.name = uniqueName(base); - o.mesh = std::move(mesh); - scene_.objects.push_back(std::move(o)); - touched(scene_.objects.back().id); - return scene_.objects.back(); -} - -auto App::uniqueName(const std::string& base) -> std::string { - auto taken = [&](const std::string& n) { return std::any_of(scene_.objects.begin(), scene_.objects.end(), [&](const Object& o) { return o.name == n; }); }; - if (!taken(base)) return base; - for (int i = 2;; ++i) - if (!taken(std::format("{} {}", base, i))) return std::format("{} {}", base, i); -} - -// A shape from the library, standing on the ground under the middle of the view. -auto App::addShape(const std::string& id, bool undoable) -> void { - const ShapeRecipe* r = findRecipe(id); - if (!r) return say("No shape called " + id); - if (undoable) pushUndo(); - if (mode_ == Mode::Edit) leaveEdit(); - Object& o = addObject(r->name, makeFromRecipe(*r, defaultSettings(*r))); - o.recipe = r->id, o.settings = defaultSettings(*r), o.smooth = r->smooth; - o.position = glm::vec3(std::round(target_.x * 10) / 10, std::round(target_.y * 10) / 10, 0.0f); - if (view_ == View::Front || view_ == View::Back) o.position.x = 0; - if (view_ == View::Left || view_ == View::Right) o.position.y = 0; - selected_ = {o.id}, active_ = o.id; - if (undoable) say(std::format("{} added: its sizes are in Properties", r->name)); -} - -auto App::deleteSelected() -> void { - if (mode_ == Mode::Edit) return deleteElements(); - if (activeRef_ && selected_.empty()) { + if (selected_.empty()) return; pushUndo(); - std::erase_if(scene_.references, [&](const Reference& r) { return r.id == activeRef_; }); - activeRef_ = 0; - return; + std::erase_if(scene_.objects, [&](const Object& o) { return selected_.count(o.id) && !o.locked; }); + selected_.clear(); } - if (selected_.empty()) return; - pushUndo(); - std::erase_if(scene_.objects, [&](const Object& o) { return selected_.count(o.id) && !o.locked; }); - selected_.clear(); -} -auto App::duplicateSelected() -> void { - if (mode_ != Mode::Object || selected_.empty()) return; - pushUndo(); - std::set made; - const std::vector copy = scene_.objects; - for (const Object& o : copy) - if (selected_.count(o.id)) { - 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; - made.insert(id); - active_ = id; - } - selected_ = made; - touchAll(); - setTool(Tool::Move); - say("Duplicated: drag the arrows to move the copy"); -} - -// The selected objects become one (the last picked keeps its place, look and name). -auto App::joinSelected() -> void { - Object* into = find(active_); - if (mode_ != Mode::Object || selected_.size() < 2 || !into) return say("Select two or more objects to join (the last picked keeps its name)"); - pushUndo(); - const glm::mat4 toLocal = glm::inverse(objectMatrix(*into)); - for (const Object& o : scene_.objects) - if (selected_.count(o.id) && o.id != into->id) append(into->mesh, o.hasFinish() ? finishedMesh(o) : o.mesh, toLocal * objectMatrix(o)); - handMade(*into); - const uint32_t keep = into->id; - std::erase_if(scene_.objects, [&](const Object& o) { return selected_.count(o.id) && o.id != keep; }); - selected_ = {keep}, active_ = keep; - touched(keep); -} - -// The selected faces become an object of their own. -auto App::separateSelected() -> void { - Object* o = editing(); - if (!o || elem_ != Elem::Face || selFaces_.empty()) return say("Select faces to make into their own object"); - pushUndo(); - Mesh part; - part.verts = o->mesh.verts; - for (int f : selFaces_) part.faces.push_back(o->mesh.faces[size_t(f)]); - removeUnused(part); - deleteFaces(o->mesh, selFaces_); - removeUnused(o->mesh); - handMade(*o); - touched(o->id); - Object copy = *o; - Object& n = addObject(o->name + " part", std::move(part)); - const uint32_t id = n.id; - const std::string name = n.name; - Mesh m = std::move(n.mesh); - n = copy, n.id = id, n.name = name, n.mesh = std::move(m); - clearElements(); - leaveEdit(); - selected_ = {id}, active_ = id; - say("Separated into " + name); -} - -// ───────────────────────────── files ───────────────────────────── - -// A file's name as shown (a picked file's: from the end of its address, decoded). -auto App::fileName(const std::string& p) -> std::string { - if (!isUri(p)) return stdfs::path(p).filename().string(); - std::string d; - for (size_t i = 0; i < p.size(); ++i) - if (p[i] == '%' && i + 2 < p.size()) d += char(std::stoi(p.substr(i + 1, 2), nullptr, 16)), i += 2; - else d += p[i]; - return d.substr(d.find_last_of("/:") + 1); -} - -// A picked file copied to a file of our own to read ("" if it couldn't be). -auto App::fromUri(const std::string& uri, const std::string& name) -> std::string { - size_t n = 0; - void* data = SDL_LoadFile(uri.c_str(), &n); - if (!data) return ""; - std::error_code ec; - const std::string local = (stdfs::temp_directory_path(ec) / name).string(); - std::ofstream(local, std::ios::binary).write(static_cast(data), std::streamsize(n)); - SDL_free(data); - return local; -} - -// A file of ours written into the picked one. -auto App::toUri(const std::string& local, const std::string& uri) -> bool { - std::ifstream in(local, std::ios::binary); - const std::string bytes((std::istreambuf_iterator(in)), std::istreambuf_iterator()); - SDL_IOStream* io = SDL_IOFromFile(uri.c_str(), "wb"); - if (!io) return false; - const bool ok = SDL_WriteIO(io, bytes.data(), bytes.size()) == bytes.size(); - return SDL_CloseIO(io) && ok; -} - -auto App::tempFile(const std::string& name) -> std::string { - std::error_code ec; - return (stdfs::temp_directory_path(ec) / name).string(); -} - -auto App::openFile(const std::string& picked) -> void { - std::string err; - const std::string path = isUri(picked) ? fromUri(picked, "opened.fry") : picked; - if (path.empty()) return say("Couldn't read that file"); - auto s = loadPackage(path, &err); - if (!s) return say("Couldn't open: " + err); - dropAutosave(); - scene_ = std::move(*s), followAt_.clear(); - // (an old plain-text .fry: its pictures are PNGs beside it) - const stdfs::path here = stdfs::path(path).parent_path(); - for (Object& o : scene_.objects) { - if (!o.pictureFile.empty()) { - Image im; - if (loadImageFile((here / o.pictureFile).string(), im)) o.picture = std::make_shared(std::move(im)); - else say("Couldn't load the painted picture " + o.pictureFile); - } - for (PictureLayer& l : o.layers) { - Image im; - if (!l.image && !l.file.empty() && loadImageFile((here / l.file).string(), im)) l.image = std::make_shared(std::move(im)); - } - std::erase_if(o.layers, [](const PictureLayer& l) { return !l.image; }); - } - path_ = picked; - undo_.clear(), redo_.clear(), selected_.clear(), lastShapes_.clear(); - mode_ = Mode::Object, active_ = 0, dirty_ = false, ledit_ = {}; - touchAll(); - frameAll(); - say("Opened " + fileName(picked)); -} - -auto App::saveFile(std::string path) -> bool { - if (path.empty()) return false; - if (!isUri(path) && stdfs::path(path).extension() != ".fry") path += ".fry"; - const std::string local = isUri(path) ? tempFile("saving.fry") : path; - std::string err; - if (!savePackage(local, scene_, 12, &err)) return afterSave_ = 0, say("Couldn't save: " + err), false; - if (isUri(path) && !toUri(local, path)) return afterSave_ = 0, say("Couldn't write the file you picked"), false; - dropAutosave(); - path_ = path, dirty_ = false; - say(std::format("Saved {} ({})", fileName(path), sizeText(stdfs::file_size(local)))); - if (const int then = std::exchange(afterSave_, 0)) doAfterAsking(then); - return true; -} - -auto App::pngBytes(const Image& im) -> std::vector { - std::vector out; - stbi_write_png_to_func([](void* ctx, void* data, int n) { auto* v = static_cast*>(ctx); v->insert(v->end(), (uint8_t*)data, (uint8_t*)data + n); }, - &out, im.w, im.h, 4, im.px.data(), im.w * 4); - return out; -} - -auto App::exportFile(std::string path, int filter, const std::string& asExt) -> void { - if (path.empty()) return; - if (isUri(path)) { // (a picked file: made here, then written into it; one file only) - const auto& formats = exportFormats(); - const std::string ext = !asExt.empty() ? asExt : formats[size_t(filter >= 0 && filter < int(formats.size()) ? filter : 0)].first; - const std::string local = tempFile("export" + ext); - exportFile(local); - if (!toUri(local, path)) say("Couldn't write the file you picked"); - return; - } - std::string ext = stdfs::path(path).extension().string(); - std::transform(ext.begin(), ext.end(), ext.begin(), [](unsigned char c) { return char(std::tolower(c)); }); - const auto& formats = exportFormats(); - if (std::none_of(formats.begin(), formats.end(), [&](const auto& f) { return f.first == ext; })) - path += formats[size_t(filter >= 0 && filter < int(formats.size()) ? filter : 0)].first; - std::vector> pngs(scene_.objects.size()); - for (size_t i = 0; i < scene_.objects.size(); ++i) - if (const Object& o = scene_.objects[i]; o.picture && o.uvsFit()) pngs[i] = pngBytes(o.layers.empty() ? *o.picture : composePicture(o)); - std::string err; - if (!exportModel(path, fry::withCutsMade(scene_), pngs, &err)) return say("Couldn't export: " + err); - say("Exported " + stdfs::path(path).filename().string()); -} - -// ── Closing with changes, autosave ── - -// New, Open, Quit: asks about saving first if there are changes. -auto App::whenSaved(int action) -> void { - if (dirty_) askSave_ = action; - else doAfterAsking(action); -} - -auto App::doAfterAsking(int action) -> void { - if (action == 1) dropAutosave(), keel::execute("quit"); - else if (action == 2) newScene(); - else if (action == 3) dialog(1); - else if (action == 4) openFile(std::exchange(dropped_, std::string())); -} - -// A file dropped on the window (or opened with Fry3DX from the desktop): a .fry opens (after -// asking about changes), a script goes in the Script panel, a picture goes on as a reference -// (or, in the Paint tab, as a sticker on the model). -auto App::onFileDropped(const std::string& path) -> void { - std::string ext = stdfs::path(path).extension().string(); - std::transform(ext.begin(), ext.end(), ext.begin(), [](unsigned char c) { return char(std::tolower(c)); }); - if (ext == ".fry") return (void)(dropped_ = path, whenSaved(4)); - if (ext == ".lua") return openScript(path, fileName(path)); - if (ext == ".png" || ext == ".jpg" || ext == ".jpeg" || ext == ".bmp" || ext == ".tga") return tab_ == Tab::Paint ? floatFromFile(path) : addPicture(path); - say("Fry3DX opens .fry files, scripts (.lua) and pictures"); -} - -auto App::saveQuestion() -> void { - if (!askSave_) return; - if (!ImGui::IsPopupOpen("Save changes?")) ImGui::OpenPopup("Save changes?"); - const ImVec2 c = ImGui::GetMainViewport()->GetCenter(); - ImGui::SetNextWindowPos(c, ImGuiCond_Always, ImVec2(0.5f, 0.5f)); - if (!ImGui::BeginPopupModal("Save changes?", nullptr, ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoMove)) return; - const std::string name = path_.empty() ? "Untitled" : fileName(path_); - ImGui::Text("%s has changes that aren't saved.", name.c_str()); - ImGui::TextDisabled(askSave_ == 1 ? "Save them before closing?" : askSave_ == 2 ? "Save them before starting a new one?" : "Save them before opening another?"); - ImGui::Spacing(); - const float bw = 150.0f; - const int action = askSave_; - pushAccent(); - const bool save = ImGui::Button("Save", ImVec2(bw, 0)) || ImGui::IsKeyPressed(ImGuiKey_Enter) || ImGui::IsKeyPressed(ImGuiKey_KeypadEnter); - popAccent(); - ImGui::SameLine(); - const bool drop = ImGui::Button(action == 1 ? "Don't save, close" : "Don't save", ImVec2(bw, 0)); - ImGui::SameLine(); - const bool cancel = ImGui::Button("Cancel", ImVec2(bw, 0)); - if (save || drop || cancel || !askSave_) askSave_ = 0, ImGui::CloseCurrentPopup(); - ImGui::EndPopup(); - if (save) { // (saveFile goes on with it once saved) - afterSave_ = action; - path_.empty() ? dialog(2) : void(saveFile(path_)); - } else if (drop) { - dropAutosave(); - dirty_ = false; - doAfterAsking(action); - } -} - -// Every fry_autosave minutes (when there are changes and nothing's being dragged): a copy -// packed as small as it goes, in the background. -auto App::autosave(bool force) -> void { - const float minutes = fry_autosave.f(); - if (!force && (minutes <= 0 || keel::isTestMode())) return; - if (!dirty_ || (!force && (edits_ == autosavedEdits_ || now() - lastAutosave_ < minutes * 60.0))) return; - if (!force && (ImGui::IsMouseDown(ImGuiMouseButton_Left) || drag_.active || paint_.active)) return; - if (autosaveJob_.valid() && autosaveJob_.wait_for(std::chrono::seconds(0)) != std::future_status::ready) return; - if (autosaveJob_.valid() && !autosaveJob_.get()) say("Couldn't autosave (is the disk full?)"); - std::error_code ec; - stdfs::create_directories(autosaveDir(), ec); - const std::string stem = path_.empty() ? "Untitled" : stdfs::path(path_).stem().string(); - if (autosavePath_.empty()) autosavePath_ = autosaveDir() + std::format("{}-{}.fry", stem, std::time(nullptr)); - std::ofstream(autosavePath_ + ".from") << path_; // (where it belongs) - lastAutosave_ = now(), autosavedEdits_ = edits_; - autosaveJob_ = std::async(std::launch::async, [copy = scene_, to = autosavePath_] { return savePackage(to, copy, 19); }); -} - -// Its autosave isn't needed (saved, or closed on purpose). -auto App::dropAutosave() -> void { - if (autosaveJob_.valid()) autosaveJob_.wait(); - if (autosavePath_.empty()) return; - std::error_code ec; - stdfs::remove(autosavePath_, ec), stdfs::remove(autosavePath_ + ".from", ec); - autosavePath_.clear(), lastAutosave_ = now(); -} - -// Autosaves left behind (it didn't close properly): offered back. Only the newest 15 are -// kept; older ones are deleted. -auto App::findAutosaves() -> void { - static constexpr size_t kKeep = 15; - recover_.clear(); - std::error_code ec; - std::vector> found; - for (const auto& e : stdfs::directory_iterator(autosaveDir(), ec)) - if (e.path().extension() == ".fry") found.emplace_back(e.last_write_time(ec), e.path().string()); - std::sort(found.begin(), found.end(), [](const auto& a, const auto& b) { return a.first > b.first; }); // newest first - for (size_t i = 0; i < found.size(); ++i) { - if (i < kKeep || found[i].second == autosavePath_) recover_.push_back(found[i].second); - else stdfs::remove(found[i].second, ec), stdfs::remove(found[i].second + ".from", ec); - } - showRecover_ = !recover_.empty() && !keel::isTestMode(); -} - -auto App::recoverWindow() -> void { - if (!showRecover_) return; - if (!ImGui::IsPopupOpen("Recover your work?")) ImGui::OpenPopup("Recover your work?"); - ImGui::SetNextWindowPos(ImGui::GetMainViewport()->GetCenter(), ImGuiCond_Always, ImVec2(0.5f, 0.5f)); - if (!ImGui::BeginPopupModal("Recover your work?", nullptr, ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoMove)) return; - ImGui::TextUnformatted("Fry3DX didn't close properly last time. These were autosaved:"); - ImGui::Spacing(); - std::string open; - for (const std::string& f : recover_) { - std::ifstream in(f + ".from"); - std::string from; - std::getline(in, from); - std::error_code ec; - // (in local time: through time_t, as not every C++ library has time zones yet) - const auto age = stdfs::file_time_type::clock::now() - stdfs::last_write_time(f, ec); // (file clocks differ by library: by age) - const std::time_t t = std::chrono::system_clock::to_time_t(std::chrono::system_clock::now() - std::chrono::duration_cast(age)); - std::tm local{}; -#if defined(_WIN32) - localtime_s(&local, &t); -#else - localtime_r(&t, &local); -#endif - char when[32]; - std::strftime(when, sizeof when, "%Y-%m-%d %H:%M", &local); - const std::string label = std::format("{} {} {}", from.empty() ? "Untitled" : stdfs::path(from).filename().string(), when, sizeText(stdfs::file_size(f, ec))); - ImGui::PushID(f.c_str()); - if (ImGui::Button("Open", ImVec2(70, 0))) open = f; - ImGui::SameLine(); - ImGui::TextUnformatted(label.c_str()); - ImGui::PopID(); - } - ImGui::Spacing(); - if (ImGui::Button("Throw them all away", ImVec2(200, 0))) { - std::error_code ec; - for (const std::string& f : recover_) stdfs::remove(f, ec), stdfs::remove(f + ".from", ec); - recover_.clear(); - } - ImGui::SameLine(); - if (ImGui::Button("Later", ImVec2(120, 0))) showRecover_ = false; - if (recover_.empty() || !showRecover_ || !open.empty()) showRecover_ = false, ImGui::CloseCurrentPopup(); - ImGui::EndPopup(); - if (!open.empty()) { - std::ifstream in(open + ".from"); - std::string from; - std::getline(in, from); - openFile(open); - path_ = from, dirty_ = true, autosavePath_ = open; // (it goes on autosaving there until saved) - say("Recovered" + (from.empty() ? std::string() : " " + stdfs::path(from).filename().string()) + ": save it to keep it (Ctrl+S)"); - } -} - -// A picture of the 3D view only (no panels, gizmos or outlines): the screen is saved a few -// frames on, with those hidden, then cut down to the view. -auto App::savePicture(std::string path) -> void { - if (path.empty()) return; - if (stdfs::path(path).extension() != ".png") path += ".png"; - capturePath_ = path, captureFrames_ = 5; -} - -auto App::captureStep() -> void { - if (!captureFrames_) return; - const std::string tmp = keel::prefPath() + "fry_capture.png"; - if (--captureFrames_ == 3) keel::execute("screenshot \"" + tmp + "\""); - if (captureFrames_) return; - keel::waitForScreenshots(); - int w = 0, h = 0, n = 0; - stbi_uc* px = stbi_load(tmp.c_str(), &w, &h, &n, 4); - if (!px) return say("Couldn't save the picture"); - const ImVec2 size = ImGui::GetIO().DisplaySize; - const float sx = float(w) / std::max(size.x, 1.0f), sy = float(h) / std::max(size.y, 1.0f); - const int x0 = std::clamp(int(viewMin_.x * sx), 0, w - 1), y0 = std::clamp(int(viewMin_.y * sy), 0, h - 1); - const int x1 = std::clamp(int(viewMax_.x * sx), x0 + 1, w), y1 = std::clamp(int(viewMax_.y * sy), y0 + 1, h); - const bool ok = stbi_write_png(capturePath_.c_str(), x1 - x0, y1 - y0, 4, px + (size_t(y0) * size_t(w) + size_t(x0)) * 4, w * 4); - stbi_image_free(px); - std::error_code ec; - stdfs::remove(tmp, ec); - if (const std::string uri = std::exchange(captureUri_, {}); ok && !uri.empty()) - return say(toUri(capturePath_, uri) ? "Saved a picture of the view: " + fileName(uri) : "Couldn't write the file you picked"); - say(ok ? "Saved a picture of the view: " + stdfs::path(capturePath_).filename().string() : "Couldn't write " + capturePath_); -} - -auto App::loadImageFile(const std::string& path, Image& out) -> bool { - int w = 0, h = 0, n = 0; - stbi_uc* px = stbi_load(path.c_str(), &w, &h, &n, 4); - if (!px) return false; - out.w = w, out.h = h, out.px.assign(px, px + size_t(w) * size_t(h) * 4); - stbi_image_free(px); - return true; -} - -auto App::addPicture(const std::string& path) -> void { - pushUndo(); - Reference ref; - ref.id = scene_.nextId++; - ref.path = path; - ref.axis = view_ == View::Front || view_ == View::Back ? Axis::Front : view_ == View::Top || view_ == View::Bottom ? Axis::Top : Axis::Side; - ref.width = 2.0f; - scene_.references.push_back(ref); - activeRef_ = ref.id; - if (view_ == View::Perspective) setView(ref.axis == Axis::Front ? View::Front : ref.axis == Axis::Top ? View::Top : View::Left); - say("Picture added: set its width in Properties to the real size"); -} - -// SDL's own file dialogs (the desktop's): the answer comes back later, maybe on another thread. -auto App::dialog(int kind) -> void { - static const SDL_DialogFileFilter fry[] = {{"Fry3DX files", "fry"}}, pics[] = {{"Pictures", "png;jpg;jpeg;bmp;tga"}}, png[] = {{"PNG picture", "png"}}; - static std::vector names; - static std::vector models; - if (models.empty()) { - for (const auto& [ext, what] : exportFormats()) names.push_back(what + " (" + ext + ")"), names.push_back(ext.substr(1)); - for (size_t i = 0; i < names.size(); i += 2) models.push_back({names[i].c_str(), names[i + 1].c_str()}); - } - pendingKind_ = kind; - auto done = [](void* user, const char* const* files, int filter) { - App* self = static_cast(user); - std::lock_guard lock(self->dialogMutex_); - if (files && files[0]) self->dialogResult_ = files[0], self->dialogKind_ = self->pendingKind_, self->dialogFilter_ = filter; - else self->dialogCancelled_ = true; - }; - SDL_Window* w = SDL_GetKeyboardFocus(); - const std::string here = path_.empty() || isUri(path_) ? std::string() : stdfs::path(path_).parent_path().string(); - const char* at = here.empty() ? nullptr : here.c_str(); - if (kind == 1) SDL_ShowOpenFileDialog(done, this, w, fry, 1, at, false); - else if (kind == 2) SDL_ShowSaveFileDialog(done, this, w, fry, 1, path_.empty() || isUri(path_) ? at : path_.c_str()); - else if (kind == 3) SDL_ShowSaveFileDialog(done, this, w, models.data(), int(models.size()), at); - else if (kind == 8 || kind == 9) { - static const SDL_DialogFileFilter mf[] = {{"3MF for slicers", "3mf"}}, stl[] = {{"STL", "stl"}}; - SDL_ShowSaveFileDialog(done, this, w, kind == 8 ? mf : stl, 1, at); - } - else if (kind == 5 || kind == 7) SDL_ShowSaveFileDialog(done, this, w, png, 1, at); - else if (kind == 10 || kind == 11) { - static const SDL_DialogFileFilter lua[] = {{"Lua scripts", "lua"}}; - if (kind == 10) SDL_ShowOpenFileDialog(done, this, w, lua, 1, at, false); - else SDL_ShowSaveFileDialog(done, this, w, lua, 1, scriptPath_.empty() || isUri(scriptPath_) ? at : scriptPath_.c_str()); // (at: the last folder) - } - else if (kind == 12) { - static const SDL_DialogFileFilter in[] = {{"3D models (STL, OBJ, 3MF, glTF)", "stl;obj;3mf;glb;gltf"}}; - SDL_ShowOpenFileDialog(done, this, w, in, 1, at, true); - } - else if (kind == 13) { - static const SDL_DialogFileFilter fonts[] = {{"Fonts", "ttf;otf"}}; - SDL_ShowOpenFileDialog(done, this, w, fonts, 1, at, false); - } - else SDL_ShowOpenFileDialog(done, this, w, pics, 1, at, false); -} - -auto App::takeDialogResult() -> void { - std::string file; - int kind, filter; - { - std::lock_guard lock(dialogMutex_); - file.swap(dialogResult_), kind = dialogKind_, filter = dialogFilter_; - if (std::exchange(dialogCancelled_, false)) afterSave_ = 0; // (Save, then quit: but the saving was cancelled) - } - if (file.empty()) return; - if (isUri(file) && (kind == 4 || kind == 6)) file = fromUri(file, std::format("picked-{}.img", SDL_GetTicks())); // (pictures: read from a copy) - if (isUri(file) && (kind == 5 || kind == 7)) { // (pictures saved: made here, then written into the picked file) - const std::string uri = file; - file = tempFile("picture.png"); - if (kind == 5) return savePicture(file), void(captureUri_ = uri); // (written into it once it's made) - if (Object* o = find(active_); o && o->picture) writePng(file, composePicture(*o)); - say(toUri(file, uri) ? "Saved " + fileName(uri) : "Couldn't write the file you picked"); - return; - } - if (isUri(file) && (kind == 8 || kind == 9)) return exportFile(file, -1, kind == 8 ? ".3mf" : ".stl"); - if (kind == 12) return importFile(isUri(file) ? fromUri(file, "imported-" + fileName(file)) : file); - if (kind == 13) { - textFontFile_ = isUri(file) ? fromUri(file, "font-" + fileName(file)) : file; - return; - } - if (kind == 10) return openScript(isUri(file) ? fromUri(file, "opened.lua") : file, fileName(file)); - if (kind == 11) return saveScript(file); - if (kind == 1) openFile(file); - else if (kind == 2) saveFile(file); - else if (kind == 3) exportFile(file, filter); - else if (kind == 8 || kind == 9) { - if (stdfs::path(file).extension() != (kind == 8 ? ".3mf" : ".stl")) file += kind == 8 ? ".3mf" : ".stl"; - exportFile(file); - } - else if (kind == 4) addPicture(file); - else if (kind == 5) savePicture(file); - else if (kind == 6) floatFromFile(file); - else if (kind == 7) { - if (stdfs::path(file).extension() != ".png") file += ".png"; - if (Object* o = find(active_); o && o->picture) say(writePng(file, composePicture(*o)) ? "Saved " + stdfs::path(file).filename().string() : "Couldn't write " + file); - } -} - -// ───────────────────────────── modes and tools ───────────────────────────── - -auto App::setTool(Tool t) -> void { - for (const ToolInfo& i : kTools) - if (i.tool == t && ((mode_ == Mode::Object && !i.objectMode) || (mode_ == Mode::Edit && !i.editMode))) { - if (mode_ == Mode::Object && i.editMode) { // (a shape tool: go and edit the shape) - enterEdit(); - if (mode_ != Mode::Edit) return; - break; + void duplicateSelected() { + if (mode_ != Mode::Object || selected_.empty()) return; + pushUndo(); + std::set made; + const std::vector copy = scene_.objects; + for (const Object& o : copy) + if (selected_.count(o.id)) { + 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; + made.insert(id); + active_ = id; } - return say(std::format("{} works on whole objects (Tab leaves shape editing)", i.name)); - } - tool_ = t, pluginTool_ = {}; - drawPts_.clear(), knifePts_.clear(); - if ((t == Tool::Extrude || t == Tool::Inset) && elem_ != Elem::Face) elem_ = Elem::Face, clearElements(); -} - -auto App::enterEdit() -> void { - if (mode_ == Mode::Edit) return; - Object* o = find(active_); - if (!o || !selected_.count(active_)) { - if (selected_.empty()) return say("Select an object first, then edit its shape"); - active_ = *selected_.begin(); - o = find(active_); + selected_ = made; + touchAll(); + setTool(Tool::Move); + say("Duplicated: drag the arrows to move the copy"); } - if (!o || o->locked) return say("That object is locked"); - mode_ = Mode::Edit; - selected_ = {active_}; - clearElements(); - if (tool_ == Tool::Draw) tool_ = Tool::Move; - drawPts_.clear(); - touched(o->id); // (shown without its rounding and smoothing while you edit) -} -auto App::leaveEdit() -> void { - if (mode_ != Mode::Edit) return; - mode_ = Mode::Object; - if (tool_ == Tool::Extrude || tool_ == Tool::Inset || tool_ == Tool::Bevel || tool_ == Tool::LoopCut || tool_ == Tool::Knife) tool_ = Tool::Move; - knifePts_.clear(); - if (Object* o = find(active_)) touched(o->id); -} - -auto App::selectAllElements() -> void { - Object* o = editing(); - if (!o) return; - clearElements(); - for (size_t v = 0; v < o->mesh.verts.size(); ++v) selVerts_.insert(int(v)); - for (size_t f = 0; f < o->mesh.faces.size(); ++f) selFaces_.insert(int(f)); - for (auto e : edges(o->mesh)) selEdges_.insert(e); -} - -auto App::selectAll() -> void { - if (mode_ == Mode::Edit) return selectAllElements(); - selected_.clear(); - for (const Object& o : scene_.objects) - if (o.visible) selected_.insert(o.id), active_ = o.id; -} - -auto App::selectNone() -> void { - if (mode_ == Mode::Edit) clearElements(); - else selected_.clear(); -} - -// The points the selection covers, whichever kind is picked. -auto App::selectedPoints() -> std::set { - std::set pts; - Object* o = editing(); - if (!o) return pts; - if (elem_ == Elem::Vertex) pts = selVerts_; - else if (elem_ == Elem::Edge) - for (auto [a, b] : selEdges_) pts.insert(a), pts.insert(b); - else - for (int f : selFaces_) - if (f < int(o->mesh.faces.size())) - for (int v : o->mesh.faces[size_t(f)]) pts.insert(v); - return pts; -} - -// Selects (in the current kind) what these points cover. -auto App::selectPoints(const std::set& pts) -> void { - Object* o = editing(); - if (!o) return; - clearElements(); - if (elem_ == Elem::Vertex) selVerts_ = pts; - else if (elem_ == Elem::Edge) { - for (auto [a, b] : edges(o->mesh)) - if (pts.count(a) && pts.count(b)) selEdges_.insert({a, b}); - } else - for (size_t f = 0; f < o->mesh.faces.size(); ++f) - if (std::all_of(o->mesh.faces[f].begin(), o->mesh.faces[f].end(), [&](int v) { return pts.count(v); })) selFaces_.insert(int(f)); -} - -auto App::shrinkSelection() -> void { - Object* o = editing(); - if (!o) return; - const std::set sel = selectedPoints(); - std::set rest; - for (size_t v = 0; v < o->mesh.verts.size(); ++v) - if (!sel.count(int(v))) rest.insert(int(v)); - const std::set border = growVerts(o->mesh, rest); - std::set keep; - for (int v : sel) - if (!border.count(v)) keep.insert(v); - selectPoints(keep); -} - -// Like the picked: faces that face the same way, edges as long, points with as many edges. -auto App::selectSimilar() -> void { - Object* o = editing(); - if (!o && mode_ == Mode::Object) { // whole objects: the others of the same shape (copies of one part: every bolt) - std::vector like; - for (const Object& p : scene_.objects) - if (selected_.count(p.id)) like.push_back(&p); - if (like.empty()) return say("Pick an object first"); - auto same = [](const Object& a, const Object& b) { - if (!a.recipe.empty() || !b.recipe.empty()) return a.recipe == b.recipe && a.settings == b.settings; - return a.mesh.verts.size() == b.mesh.verts.size() && a.mesh.faces.size() == b.mesh.faces.size(); - }; - const size_t before = selected_.size(); - for (const Object& p : scene_.objects) - if (p.visible && !p.locked && std::any_of(like.begin(), like.end(), [&](const Object* l) { return same(*l, p); })) selected_.insert(p.id); - const size_t more = selected_.size() - before; - return say(more ? std::format("{} more of the same shape picked", more) : "No others of the same shape"); - } - if (!o) return; - const Mesh& m = o->mesh; - if (elem_ == Elem::Face) { - std::vector ns; - for (int f : selFaces_) ns.push_back(faceNormal(m, f)); - if (ns.empty()) return say("Pick a face first"); - for (size_t f = 0; f < m.faces.size(); ++f) - for (const glm::vec3& n : ns) - if (glm::dot(faceNormal(m, int(f)), n) > 0.996f) selFaces_.insert(int(f)); - } else if (elem_ == Elem::Edge) { - std::vector lens; - for (auto [a, b] : selEdges_) lens.push_back(glm::distance(m.verts[size_t(a)], m.verts[size_t(b)])); - if (lens.empty()) return say("Pick an edge first"); - for (auto [a, b] : edges(m)) { - const float l = glm::distance(m.verts[size_t(a)], m.verts[size_t(b)]); - for (float k : lens) - if (std::abs(l - k) <= k * 0.02f + 1e-6f) selEdges_.insert({a, b}); - } - } else { - std::vector count(m.verts.size(), 0); - for (auto [a, b] : edges(m)) ++count[size_t(a)], ++count[size_t(b)]; - std::set want; - for (int v : selVerts_) want.insert(count[size_t(v)]); - if (want.empty()) return say("Pick a point first"); - for (size_t v = 0; v < m.verts.size(); ++v) - if (want.count(count[size_t(v)])) selVerts_.insert(int(v)); - } - say("Picked the similar ones"); -} - -// The picked edges' whole loops (or the rings across them). -auto App::loopsOfSelected(bool ring) -> void { - Object* o = editing(); - if (!o) return; - if (elem_ != Elem::Edge || selEdges_.empty()) return say("Pick an edge or two first (Edges, 2)"); - std::set> add; - for (auto e : selEdges_) - for (auto f : ring ? edgeRing(o->mesh, e) : edgeLoop(o->mesh, e)) add.insert(f); - selEdges_.insert(add.begin(), add.end()); -} - -auto App::invertSelection() -> void { - if (mode_ == Mode::Object) { - std::set inv; + // The selected objects become one (the last picked keeps its place, look and name). + void joinSelected() { + Object* into = find(active_); + if (mode_ != Mode::Object || selected_.size() < 2 || !into) return say("Select two or more objects to join (the last picked keeps its name)"); + pushUndo(); + const glm::mat4 toLocal = glm::inverse(objectMatrix(*into)); for (const Object& o : scene_.objects) - if (o.visible && !selected_.count(o.id)) inv.insert(o.id), active_ = o.id; - selected_ = inv; - return; + if (selected_.count(o.id) && o.id != into->id) append(into->mesh, o.hasFinish() ? finishedMesh(o) : o.mesh, toLocal * objectMatrix(o)); + handMade(*into); + const uint32_t keep = into->id; + std::erase_if(scene_.objects, [&](const Object& o) { return selected_.count(o.id) && o.id != keep; }); + selected_ = {keep}, active_ = keep; + touched(keep); } - Object* o = editing(); - if (!o) return; - if (elem_ == Elem::Vertex) { - std::set inv; - for (size_t v = 0; v < o->mesh.verts.size(); ++v) - if (!selVerts_.count(int(v))) inv.insert(int(v)); - selVerts_ = inv; - } else if (elem_ == Elem::Edge) { - std::set> inv; - for (auto e : edges(o->mesh)) - if (!selEdges_.count(e)) inv.insert(e); - selEdges_ = inv; - } else { - std::set inv; - for (size_t f = 0; f < o->mesh.faces.size(); ++f) - if (!selFaces_.count(int(f))) inv.insert(int(f)); - selFaces_ = inv; - } -} -// The edges a rounding would work on: those selected, or round the selected faces, or between -// selected points. -auto App::edgesToRound() -> std::set> { - std::set> out; - Object* o = editing(); - if (!o) return out; - if (elem_ == Elem::Edge) return selEdges_; - if (elem_ == Elem::Face) { - for (int f : selFaces_) { - const auto& fv = o->mesh.faces[size_t(f)]; - for (size_t i = 0; i < fv.size(); ++i) out.insert({std::min(fv[i], fv[(i + 1) % fv.size()]), std::max(fv[i], fv[(i + 1) % fv.size()])}); + // The selected faces become an object of their own. + void separateSelected() { + Object* o = editing(); + if (!o || elem_ != Elem::Face || selFaces_.empty()) return say("Select faces to make into their own object"); + pushUndo(); + Mesh part; + part.verts = o->mesh.verts; + for (int f : selFaces_) part.faces.push_back(o->mesh.faces[size_t(f)]); + removeUnused(part); + deleteFaces(o->mesh, selFaces_); + removeUnused(o->mesh); + handMade(*o); + touched(o->id); + Object copy = *o; + Object& n = addObject(o->name + " part", std::move(part)); + const uint32_t id = n.id; + const std::string name = n.name; + Mesh m = std::move(n.mesh); + n = copy, n.id = id, n.name = name, n.mesh = std::move(m); + clearElements(); + leaveEdit(); + selected_ = {id}, active_ = id; + say("Separated into " + name); + } + + // ───────────────────────────── files ───────────────────────────── + + // ── Files a phone's file picker gives (content://...): read and written through SDL ── + static bool isUri(const std::string& p) { return p.starts_with("content://"); } + // A file's name as shown (a picked file's: from the end of its address, decoded). + static std::string fileName(const std::string& p) { + if (!isUri(p)) return stdfs::path(p).filename().string(); + std::string d; + for (size_t i = 0; i < p.size(); ++i) + if (p[i] == '%' && i + 2 < p.size()) d += char(std::stoi(p.substr(i + 1, 2), nullptr, 16)), i += 2; + else d += p[i]; + return d.substr(d.find_last_of("/:") + 1); + } + // A picked file copied to a file of our own to read ("" if it couldn't be). + static std::string fromUri(const std::string& uri, const std::string& name) { + size_t n = 0; + void* data = SDL_LoadFile(uri.c_str(), &n); + if (!data) return ""; + std::error_code ec; + const std::string local = (stdfs::temp_directory_path(ec) / name).string(); + std::ofstream(local, std::ios::binary).write(static_cast(data), std::streamsize(n)); + SDL_free(data); + return local; + } + // A file of ours written into the picked one. + static bool toUri(const std::string& local, const std::string& uri) { + std::ifstream in(local, std::ios::binary); + const std::string bytes((std::istreambuf_iterator(in)), std::istreambuf_iterator()); + SDL_IOStream* io = SDL_IOFromFile(uri.c_str(), "wb"); + if (!io) return false; + const bool ok = SDL_WriteIO(io, bytes.data(), bytes.size()) == bytes.size(); + return SDL_CloseIO(io) && ok; + } + static std::string tempFile(const std::string& name) { + std::error_code ec; + return (stdfs::temp_directory_path(ec) / name).string(); + } + + void openFile(const std::string& picked) { + std::string err; + const std::string path = isUri(picked) ? fromUri(picked, "opened.fry") : picked; + if (path.empty()) return say("Couldn't read that file"); + auto s = loadPackage(path, &err); + if (!s) return say("Couldn't open: " + err); + dropAutosave(); + scene_ = std::move(*s); + // (an old plain-text .fry: its pictures are PNGs beside it) + const stdfs::path here = stdfs::path(path).parent_path(); + for (Object& o : scene_.objects) { + if (!o.pictureFile.empty()) { + Image im; + if (loadImageFile((here / o.pictureFile).string(), im)) o.picture = std::make_shared(std::move(im)); + else say("Couldn't load the painted picture " + o.pictureFile); + } + for (PictureLayer& l : o.layers) { + Image im; + if (!l.image && !l.file.empty() && loadImageFile((here / l.file).string(), im)) l.image = std::make_shared(std::move(im)); + } + std::erase_if(o.layers, [](const PictureLayer& l) { return !l.image; }); + } + path_ = picked; + undo_.clear(), redo_.clear(), selected_.clear(); + mode_ = Mode::Object, active_ = 0, dirty_ = false, ledit_ = {}; + touchAll(); + frameAll(); + say("Opened " + fileName(picked)); + } + bool saveFile(std::string path) { + if (path.empty()) return false; + if (!isUri(path) && stdfs::path(path).extension() != ".fry") path += ".fry"; + const std::string local = isUri(path) ? tempFile("saving.fry") : path; + std::string err; + if (!savePackage(local, scene_, 12, &err)) return afterSave_ = 0, say("Couldn't save: " + err), false; + if (isUri(path) && !toUri(local, path)) return afterSave_ = 0, say("Couldn't write the file you picked"), false; + dropAutosave(); + path_ = path, dirty_ = false; + say(std::format("Saved {} ({})", fileName(path), sizeText(stdfs::file_size(local)))); + if (const int then = std::exchange(afterSave_, 0)) doAfterAsking(then); + return true; + } + static std::string sizeText(uintmax_t n) { + return n < 10240 ? std::format("{} bytes", n) : n < 10 << 20 ? std::format("{} KB", n >> 10) : std::format("{:.1f} MB", double(n) / 1048576.0); + } + static std::vector pngBytes(const Image& im) { + std::vector out; + stbi_write_png_to_func([](void* ctx, void* data, int n) { auto* v = static_cast*>(ctx); v->insert(v->end(), (uint8_t*)data, (uint8_t*)data + n); }, + &out, im.w, im.h, 4, im.px.data(), im.w * 4); + return out; + } + void exportFile(std::string path, int filter = -1, const std::string& asExt = "") { + if (path.empty()) return; + if (isUri(path)) { // (a picked file: made here, then written into it; one file only) + const auto& formats = exportFormats(); + const std::string ext = !asExt.empty() ? asExt : formats[size_t(filter >= 0 && filter < int(formats.size()) ? filter : 0)].first; + const std::string local = tempFile("export" + ext); + exportFile(local); + if (!toUri(local, path)) say("Couldn't write the file you picked"); + return; + } + std::string ext = stdfs::path(path).extension().string(); + std::transform(ext.begin(), ext.end(), ext.begin(), [](unsigned char c) { return char(std::tolower(c)); }); + const auto& formats = exportFormats(); + if (std::none_of(formats.begin(), formats.end(), [&](const auto& f) { return f.first == ext; })) + path += formats[size_t(filter >= 0 && filter < int(formats.size()) ? filter : 0)].first; + std::vector> pngs(scene_.objects.size()); + for (size_t i = 0; i < scene_.objects.size(); ++i) + if (const Object& o = scene_.objects[i]; o.picture && o.uvsFit()) pngs[i] = pngBytes(o.layers.empty() ? *o.picture : composePicture(o)); + std::string err; + if (!exportModel(path, scene_, pngs, &err)) return say("Couldn't export: " + err); + say("Exported " + stdfs::path(path).filename().string()); + } + + // ── Closing with changes, autosave ── + + // New, Open, Quit: asks about saving first if there are changes. + void whenSaved(int action) { + if (dirty_) askSave_ = action; + else doAfterAsking(action); + } + void doAfterAsking(int action) { + if (action == 1) dropAutosave(), keel::execute("quit"); + else if (action == 2) newScene(); + else if (action == 3) dialog(1); + else if (action == 4) openFile(std::exchange(dropped_, std::string())); + } + // A file dropped on the window (or opened with Fry3DX from the desktop): a .fry opens (after + // asking about changes), a script goes in the Script panel, a picture goes on as a reference + // (or, in the Paint tab, as a sticker on the model). + void onFileDropped(const std::string& path) override { + std::string ext = stdfs::path(path).extension().string(); + std::transform(ext.begin(), ext.end(), ext.begin(), [](unsigned char c) { return char(std::tolower(c)); }); + if (ext == ".fry") return (void)(dropped_ = path, whenSaved(4)); + if (ext == ".lua") return openScript(path, fileName(path)); + if (ext == ".png" || ext == ".jpg" || ext == ".jpeg" || ext == ".bmp" || ext == ".tga") return tab_ == Tab::Paint ? floatFromFile(path) : addPicture(path); + say("Fry3DX opens .fry files, scripts (.lua) and pictures"); + } + void saveQuestion() { + if (!askSave_) return; + if (!ImGui::IsPopupOpen("Save changes?")) ImGui::OpenPopup("Save changes?"); + const ImVec2 c = ImGui::GetMainViewport()->GetCenter(); + ImGui::SetNextWindowPos(c, ImGuiCond_Always, ImVec2(0.5f, 0.5f)); + if (!ImGui::BeginPopupModal("Save changes?", nullptr, ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoMove)) return; + const std::string name = path_.empty() ? "Untitled" : fileName(path_); + ImGui::Text("%s has changes that aren't saved.", name.c_str()); + ImGui::TextDisabled(askSave_ == 1 ? "Save them before closing?" : askSave_ == 2 ? "Save them before starting a new one?" : "Save them before opening another?"); + ImGui::Spacing(); + const float bw = 150.0f; + const int action = askSave_; + pushAccent(); + const bool save = ImGui::Button("Save", ImVec2(bw, 0)) || ImGui::IsKeyPressed(ImGuiKey_Enter) || ImGui::IsKeyPressed(ImGuiKey_KeypadEnter); + popAccent(); + ImGui::SameLine(); + const bool drop = ImGui::Button(action == 1 ? "Don't save, close" : "Don't save", ImVec2(bw, 0)); + ImGui::SameLine(); + const bool cancel = ImGui::Button("Cancel", ImVec2(bw, 0)); + if (save || drop || cancel || !askSave_) askSave_ = 0, ImGui::CloseCurrentPopup(); + ImGui::EndPopup(); + if (save) { // (saveFile goes on with it once saved) + afterSave_ = action; + path_.empty() ? dialog(2) : void(saveFile(path_)); + } else if (drop) { + dropAutosave(); + dirty_ = false; + doAfterAsking(action); + } + } + std::string autosaveDir() const { return keel::prefPath() + "autosave/"; } + // Every fry_autosave minutes (when there are changes and nothing's being dragged): a copy + // packed as small as it goes, in the background. + void autosave(bool force = false) { + const float minutes = fry_autosave.f(); + if (!force && (minutes <= 0 || keel::isTestMode())) return; + if (!dirty_ || (!force && (edits_ == autosavedEdits_ || now() - lastAutosave_ < minutes * 60.0))) return; + if (!force && (ImGui::IsMouseDown(ImGuiMouseButton_Left) || drag_.active || paint_.active)) return; + if (autosaveJob_.valid() && autosaveJob_.wait_for(std::chrono::seconds(0)) != std::future_status::ready) return; + if (autosaveJob_.valid() && !autosaveJob_.get()) say("Couldn't autosave (is the disk full?)"); + std::error_code ec; + stdfs::create_directories(autosaveDir(), ec); + const std::string stem = path_.empty() ? "Untitled" : stdfs::path(path_).stem().string(); + if (autosavePath_.empty()) autosavePath_ = autosaveDir() + std::format("{}-{}.fry", stem, std::time(nullptr)); + std::ofstream(autosavePath_ + ".from") << path_; // (where it belongs) + lastAutosave_ = now(), autosavedEdits_ = edits_; + autosaveJob_ = std::async(std::launch::async, [copy = scene_, to = autosavePath_] { return savePackage(to, copy, 19); }); + } + // Its autosave isn't needed (saved, or closed on purpose). + void dropAutosave() { + if (autosaveJob_.valid()) autosaveJob_.wait(); + if (autosavePath_.empty()) return; + std::error_code ec; + stdfs::remove(autosavePath_, ec), stdfs::remove(autosavePath_ + ".from", ec); + autosavePath_.clear(), lastAutosave_ = now(); + } + // Autosaves left behind (it didn't close properly): offered back. Only the newest 15 are + // kept; older ones are deleted. + void findAutosaves() { + static constexpr size_t kKeep = 15; + recover_.clear(); + std::error_code ec; + std::vector> found; + for (const auto& e : stdfs::directory_iterator(autosaveDir(), ec)) + if (e.path().extension() == ".fry") found.emplace_back(e.last_write_time(ec), e.path().string()); + std::sort(found.begin(), found.end(), [](const auto& a, const auto& b) { return a.first > b.first; }); // newest first + for (size_t i = 0; i < found.size(); ++i) { + if (i < kKeep || found[i].second == autosavePath_) recover_.push_back(found[i].second); + else stdfs::remove(found[i].second, ec), stdfs::remove(found[i].second + ".from", ec); + } + showRecover_ = !recover_.empty() && !keel::isTestMode(); + } + void recoverWindow() { + if (!showRecover_) return; + if (!ImGui::IsPopupOpen("Recover your work?")) ImGui::OpenPopup("Recover your work?"); + ImGui::SetNextWindowPos(ImGui::GetMainViewport()->GetCenter(), ImGuiCond_Always, ImVec2(0.5f, 0.5f)); + if (!ImGui::BeginPopupModal("Recover your work?", nullptr, ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoMove)) return; + ImGui::TextUnformatted("Fry3DX didn't close properly last time. These were autosaved:"); + ImGui::Spacing(); + std::string open; + for (const std::string& f : recover_) { + std::ifstream in(f + ".from"); + std::string from; + std::getline(in, from); + std::error_code ec; + // (in local time: through time_t, as not every C++ library has time zones yet) + const auto age = stdfs::file_time_type::clock::now() - stdfs::last_write_time(f, ec); // (file clocks differ by library: by age) + const std::time_t t = std::chrono::system_clock::to_time_t(std::chrono::system_clock::now() - std::chrono::duration_cast(age)); + std::tm local{}; +#if defined(_WIN32) + localtime_s(&local, &t); +#else + localtime_r(&t, &local); +#endif + char when[32]; + std::strftime(when, sizeof when, "%Y-%m-%d %H:%M", &local); + const std::string label = std::format("{} {} {}", from.empty() ? "Untitled" : stdfs::path(from).filename().string(), when, sizeText(stdfs::file_size(f, ec))); + ImGui::PushID(f.c_str()); + if (ImGui::Button("Open", ImVec2(70, 0))) open = f; + ImGui::SameLine(); + ImGui::TextUnformatted(label.c_str()); + ImGui::PopID(); + } + ImGui::Spacing(); + if (ImGui::Button("Throw them all away", ImVec2(200, 0))) { + std::error_code ec; + for (const std::string& f : recover_) stdfs::remove(f, ec), stdfs::remove(f + ".from", ec); + recover_.clear(); + } + ImGui::SameLine(); + if (ImGui::Button("Later", ImVec2(120, 0))) showRecover_ = false; + if (recover_.empty() || !showRecover_ || !open.empty()) showRecover_ = false, ImGui::CloseCurrentPopup(); + ImGui::EndPopup(); + if (!open.empty()) { + std::ifstream in(open + ".from"); + std::string from; + std::getline(in, from); + openFile(open); + path_ = from, dirty_ = true, autosavePath_ = open; // (it goes on autosaving there until saved) + say("Recovered" + (from.empty() ? std::string() : " " + stdfs::path(from).filename().string()) + ": save it to keep it (Ctrl+S)"); + } + } + + // A picture of the 3D view only (no panels, gizmos or outlines): the screen is saved a few + // frames on, with those hidden, then cut down to the view. + void savePicture(std::string path) { + if (path.empty()) return; + if (stdfs::path(path).extension() != ".png") path += ".png"; + capturePath_ = path, captureFrames_ = 5; + } + bool capturing() const { return captureFrames_ > 0; } + void captureStep() { + if (!captureFrames_) return; + const std::string tmp = keel::prefPath() + "fry_capture.png"; + if (--captureFrames_ == 3) keel::execute("screenshot \"" + tmp + "\""); + if (captureFrames_) return; + keel::waitForScreenshots(); + int w = 0, h = 0, n = 0; + stbi_uc* px = stbi_load(tmp.c_str(), &w, &h, &n, 4); + if (!px) return say("Couldn't save the picture"); + const ImVec2 size = ImGui::GetIO().DisplaySize; + const float sx = float(w) / std::max(size.x, 1.0f), sy = float(h) / std::max(size.y, 1.0f); + const int x0 = std::clamp(int(viewMin_.x * sx), 0, w - 1), y0 = std::clamp(int(viewMin_.y * sy), 0, h - 1); + const int x1 = std::clamp(int(viewMax_.x * sx), x0 + 1, w), y1 = std::clamp(int(viewMax_.y * sy), y0 + 1, h); + const bool ok = stbi_write_png(capturePath_.c_str(), x1 - x0, y1 - y0, 4, px + (size_t(y0) * size_t(w) + size_t(x0)) * 4, w * 4); + stbi_image_free(px); + std::error_code ec; + stdfs::remove(tmp, ec); + if (const std::string uri = std::exchange(captureUri_, {}); ok && !uri.empty()) + return say(toUri(capturePath_, uri) ? "Saved a picture of the view: " + fileName(uri) : "Couldn't write the file you picked"); + say(ok ? "Saved a picture of the view: " + stdfs::path(capturePath_).filename().string() : "Couldn't write " + capturePath_); + } + static bool loadImageFile(const std::string& path, Image& out) { + int w = 0, h = 0, n = 0; + stbi_uc* px = stbi_load(path.c_str(), &w, &h, &n, 4); + if (!px) return false; + out.w = w, out.h = h, out.px.assign(px, px + size_t(w) * size_t(h) * 4); + stbi_image_free(px); + return true; + } + static bool writePng(const std::string& path, const Image& im) { return stbi_write_png(path.c_str(), im.w, im.h, 4, im.px.data(), im.w * 4) != 0; } + void addPicture(const std::string& path) { + pushUndo(); + Reference ref; + ref.id = scene_.nextId++; + ref.path = path; + ref.axis = view_ == View::Front || view_ == View::Back ? Axis::Front : view_ == View::Top || view_ == View::Bottom ? Axis::Top : Axis::Side; + ref.width = 2.0f; + scene_.references.push_back(ref); + activeRef_ = ref.id; + if (view_ == View::Perspective) setView(ref.axis == Axis::Front ? View::Front : ref.axis == Axis::Top ? View::Top : View::Left); + say("Picture added: set its width in Properties to the real size"); + } + + // SDL's own file dialogs (the desktop's): the answer comes back later, maybe on another thread. + void dialog(int kind) { + static const SDL_DialogFileFilter fry[] = {{"Fry3DX files", "fry"}}, pics[] = {{"Pictures", "png;jpg;jpeg;bmp;tga"}}, png[] = {{"PNG picture", "png"}}; + static std::vector names; + static std::vector models; + if (models.empty()) { + for (const auto& [ext, what] : exportFormats()) names.push_back(what + " (" + ext + ")"), names.push_back(ext.substr(1)); + for (size_t i = 0; i < names.size(); i += 2) models.push_back({names[i].c_str(), names[i + 1].c_str()}); + } + pendingKind_ = kind; + auto done = [](void* user, const char* const* files, int filter) { + App* self = static_cast(user); + std::lock_guard lock(self->dialogMutex_); + if (files && files[0]) self->dialogResult_ = files[0], self->dialogKind_ = self->pendingKind_, self->dialogFilter_ = filter; + else self->dialogCancelled_ = true; + }; + SDL_Window* w = SDL_GetKeyboardFocus(); + const std::string here = path_.empty() || isUri(path_) ? std::string() : stdfs::path(path_).parent_path().string(); + const char* at = here.empty() ? nullptr : here.c_str(); + if (kind == 1) SDL_ShowOpenFileDialog(done, this, w, fry, 1, at, false); + else if (kind == 2) SDL_ShowSaveFileDialog(done, this, w, fry, 1, path_.empty() || isUri(path_) ? at : path_.c_str()); + else if (kind == 3) SDL_ShowSaveFileDialog(done, this, w, models.data(), int(models.size()), at); + else if (kind == 8 || kind == 9) { + static const SDL_DialogFileFilter mf[] = {{"3MF for slicers", "3mf"}}, stl[] = {{"STL", "stl"}}; + SDL_ShowSaveFileDialog(done, this, w, kind == 8 ? mf : stl, 1, at); + } + else if (kind == 5 || kind == 7) SDL_ShowSaveFileDialog(done, this, w, png, 1, at); + else if (kind == 10 || kind == 11) { + static const SDL_DialogFileFilter lua[] = {{"Lua scripts", "lua"}}; + if (kind == 10) SDL_ShowOpenFileDialog(done, this, w, lua, 1, at, false); + else SDL_ShowSaveFileDialog(done, this, w, lua, 1, scriptPath_.empty() || isUri(scriptPath_) ? at : scriptPath_.c_str()); // (at: the last folder) + } + else SDL_ShowOpenFileDialog(done, this, w, pics, 1, at, false); + } + void takeDialogResult() { + std::string file; + int kind, filter; + { + std::lock_guard lock(dialogMutex_); + file.swap(dialogResult_), kind = dialogKind_, filter = dialogFilter_; + if (std::exchange(dialogCancelled_, false)) afterSave_ = 0; // (Save, then quit: but the saving was cancelled) + } + if (file.empty()) return; + if (isUri(file) && (kind == 4 || kind == 6)) file = fromUri(file, std::format("picked-{}.img", SDL_GetTicks())); // (pictures: read from a copy) + if (isUri(file) && (kind == 5 || kind == 7)) { // (pictures saved: made here, then written into the picked file) + const std::string uri = file; + file = tempFile("picture.png"); + if (kind == 5) return savePicture(file), void(captureUri_ = uri); // (written into it once it's made) + if (Object* o = find(active_); o && o->picture) writePng(file, composePicture(*o)); + say(toUri(file, uri) ? "Saved " + fileName(uri) : "Couldn't write the file you picked"); + return; + } + if (isUri(file) && (kind == 8 || kind == 9)) return exportFile(file, -1, kind == 8 ? ".3mf" : ".stl"); + if (kind == 10) return openScript(isUri(file) ? fromUri(file, "opened.lua") : file, fileName(file)); + if (kind == 11) return saveScript(file); + if (kind == 1) openFile(file); + else if (kind == 2) saveFile(file); + else if (kind == 3) exportFile(file, filter); + else if (kind == 8 || kind == 9) { + if (stdfs::path(file).extension() != (kind == 8 ? ".3mf" : ".stl")) file += kind == 8 ? ".3mf" : ".stl"; + exportFile(file); + } + else if (kind == 4) addPicture(file); + else if (kind == 5) savePicture(file); + else if (kind == 6) floatFromFile(file); + else if (kind == 7) { + if (stdfs::path(file).extension() != ".png") file += ".png"; + if (Object* o = find(active_); o && o->picture) say(writePng(file, composePicture(*o)) ? "Saved " + stdfs::path(file).filename().string() : "Couldn't write " + file); + } + } + + // ───────────────────────────── modes and tools ───────────────────────────── + + void setTool(Tool t) { + for (const ToolInfo& i : kTools) + if (i.tool == t && ((mode_ == Mode::Object && !i.objectMode) || (mode_ == Mode::Edit && !i.editMode))) { + if (mode_ == Mode::Object && i.editMode) { // (a shape tool: go and edit the shape) + enterEdit(); + if (mode_ != Mode::Edit) return; + break; + } + return say(std::format("{} works on whole objects (Tab leaves shape editing)", i.name)); + } + tool_ = t; + drawPts_.clear(), knifePts_.clear(); + if ((t == Tool::Extrude || t == Tool::Inset) && elem_ != Elem::Face) elem_ = Elem::Face, clearElements(); + } + void enterEdit() { + if (mode_ == Mode::Edit) return; + Object* o = find(active_); + if (!o || !selected_.count(active_)) { + if (selected_.empty()) return say("Select an object first, then edit its shape"); + active_ = *selected_.begin(); + o = find(active_); + } + if (!o || o->locked) return say("That object is locked"); + mode_ = Mode::Edit; + selected_ = {active_}; + clearElements(); + if (tool_ == Tool::Draw) tool_ = Tool::Move; + drawPts_.clear(); + touched(o->id); // (shown without its rounding and smoothing while you edit) + } + void leaveEdit() { + if (mode_ != Mode::Edit) return; + mode_ = Mode::Object; + if (tool_ == Tool::Extrude || tool_ == Tool::Inset || tool_ == Tool::Bevel || tool_ == Tool::LoopCut || tool_ == Tool::Knife) tool_ = Tool::Move; + knifePts_.clear(); + if (Object* o = find(active_)) touched(o->id); + } + void clearElements() { selVerts_.clear(), selFaces_.clear(), selEdges_.clear(); } + void selectAllElements() { + Object* o = editing(); + if (!o) return; + clearElements(); + for (size_t v = 0; v < o->mesh.verts.size(); ++v) selVerts_.insert(int(v)); + for (size_t f = 0; f < o->mesh.faces.size(); ++f) selFaces_.insert(int(f)); + for (auto e : edges(o->mesh)) selEdges_.insert(e); + } + void selectAll() { + if (mode_ == Mode::Edit) return selectAllElements(); + selected_.clear(); + for (const Object& o : scene_.objects) + if (o.visible) selected_.insert(o.id), active_ = o.id; + } + void selectNone() { + if (mode_ == Mode::Edit) clearElements(); + else selected_.clear(); + } + // The points the selection covers, whichever kind is picked. + std::set selectedPoints() { + std::set pts; + Object* o = editing(); + if (!o) return pts; + if (elem_ == Elem::Vertex) pts = selVerts_; + else if (elem_ == Elem::Edge) + for (auto [a, b] : selEdges_) pts.insert(a), pts.insert(b); + else + for (int f : selFaces_) + if (f < int(o->mesh.faces.size())) + for (int v : o->mesh.faces[size_t(f)]) pts.insert(v); + return pts; + } + // Selects (in the current kind) what these points cover. + void selectPoints(const std::set& pts) { + Object* o = editing(); + if (!o) return; + clearElements(); + if (elem_ == Elem::Vertex) selVerts_ = pts; + else if (elem_ == Elem::Edge) { + for (auto [a, b] : edges(o->mesh)) + if (pts.count(a) && pts.count(b)) selEdges_.insert({a, b}); + } else + for (size_t f = 0; f < o->mesh.faces.size(); ++f) + if (std::all_of(o->mesh.faces[f].begin(), o->mesh.faces[f].end(), [&](int v) { return pts.count(v); })) selFaces_.insert(int(f)); + } + void growSelection() { + if (Object* o = editing()) selectPoints(growVerts(o->mesh, selectedPoints())); + } + void shrinkSelection() { + Object* o = editing(); + if (!o) return; + const std::set sel = selectedPoints(); + std::set rest; + for (size_t v = 0; v < o->mesh.verts.size(); ++v) + if (!sel.count(int(v))) rest.insert(int(v)); + const std::set border = growVerts(o->mesh, rest); + std::set keep; + for (int v : sel) + if (!border.count(v)) keep.insert(v); + selectPoints(keep); + } + void selectLinked() { + if (Object* o = editing()) selectPoints(linkedVerts(o->mesh, selectedPoints())); + } + void invertSelection() { + if (mode_ == Mode::Object) { + std::set inv; + for (const Object& o : scene_.objects) + if (o.visible && !selected_.count(o.id)) inv.insert(o.id), active_ = o.id; + selected_ = inv; + return; + } + Object* o = editing(); + if (!o) return; + if (elem_ == Elem::Vertex) { + std::set inv; + for (size_t v = 0; v < o->mesh.verts.size(); ++v) + if (!selVerts_.count(int(v))) inv.insert(int(v)); + selVerts_ = inv; + } else if (elem_ == Elem::Edge) { + std::set> inv; + for (auto e : edges(o->mesh)) + if (!selEdges_.count(e)) inv.insert(e); + selEdges_ = inv; + } else { + std::set inv; + for (size_t f = 0; f < o->mesh.faces.size(); ++f) + if (!selFaces_.count(int(f))) inv.insert(int(f)); + selFaces_ = inv; + } + } + // The edges a rounding would work on: those selected, or round the selected faces, or between + // selected points. + std::set> edgesToRound() { + std::set> out; + Object* o = editing(); + if (!o) return out; + if (elem_ == Elem::Edge) return selEdges_; + if (elem_ == Elem::Face) { + for (int f : selFaces_) { + const auto& fv = o->mesh.faces[size_t(f)]; + for (size_t i = 0; i < fv.size(); ++i) out.insert({std::min(fv[i], fv[(i + 1) % fv.size()]), std::max(fv[i], fv[(i + 1) % fv.size()])}); + } + return out; + } + for (auto [a, b] : edges(o->mesh)) + if (selVerts_.count(a) && selVerts_.count(b)) out.insert({a, b}); + return out; + } + + void deleteElements() { + Object* o = editing(); + if (!o) return; + pushUndo(); + if (elem_ == Elem::Face) deleteFaces(o->mesh, selFaces_); + else deleteVerts(o->mesh, selectedPoints()); + clearElements(); + handMade(*o); + touched(o->id); + } + // After a change that renumbers things: nothing selected, the shape is hand-made now. + void changedShape(Object& o, bool keepSelection = false) { + if (!keepSelection) clearElements(); + handMade(o); + touched(o.id); + } + void extrudeNow(float amount) { + Object* o = editing(); + if (!o || elem_ != Elem::Face || selFaces_.empty()) return say("Select faces to extrude (Faces, 3)"); + pushUndo(); + extrudeFaces(o->mesh, selFaces_, amount); + changedShape(*o, true); + } + void insetNow(float amount) { + Object* o = editing(); + if (!o || elem_ != Elem::Face || selFaces_.empty()) return say("Select faces to inset (Faces, 3)"); + pushUndo(); + insetFaces(o->mesh, selFaces_, amount); + changedShape(*o, true); + } + void bevelNow() { + Object* o = editing(); + const auto es = edgesToRound(); + if (!o || es.empty()) return say("Select edges (or faces) to round"); + pushUndo(); + bevelEdges(o->mesh, es, bevelWidth_, bevelSteps_); + changedShape(*o); + if (bevelSteps_ > 1) o->smooth = true; + } + void subdivideNow(bool smooth) { + Object* o = editing(); + if (!o) return; + pushUndo(); + subdivide(o->mesh, smooth); + if (smooth) o->smooth = true; + changedShape(*o); + } + void cutAt(Object& o, std::pair edge) { + pushUndo(); + const auto made = loopCut(o.mesh, edge, cuts_); + if (made.empty()) { + dropUndo(); + return say("Nothing to cut there: loop cuts go across four-sided faces"); + } + changedShape(o); + // The new edges are selected, ready to move (along the faces, with the gizmo). + const std::set pts(made.begin(), made.end()); + elem_ = Elem::Edge; + for (auto [a, b] : edges(o.mesh)) + if (pts.count(a) && pts.count(b)) selEdges_.insert({a, b}); + say(std::format("Cut {} time{} round. W moves the new edges.", cuts_, cuts_ == 1 ? "" : "s")); + } + void makeFaceNow() { + Object* o = editing(); + if (!o) return; + const std::set pts = selectedPoints(); + pushUndo(); + const glm::vec3 towards = glm::inverse(glm::mat3(objectMatrix(*o))) * (eye_ - pivotOfSelection()); + if (!makeFace(o->mesh, std::vector(pts.begin(), pts.end()), towards)) { + dropUndo(); + return say("Pick 3 or more points (not in a line) to make a face"); + } + changedShape(*o); + selFaces_ = {int(o->mesh.faces.size()) - 1}; + elem_ = Elem::Face; + } + void mergeNow() { + Object* o = editing(); + if (!o) return; + const std::set pts = selectedPoints(); + if (pts.size() < 2) return say("Select 2 or more points to merge"); + pushUndo(); + mergeToCenter(o->mesh, pts); + changedShape(*o); + } + void smoothPointsNow() { + Object* o = editing(); + const std::set pts = selectedPoints(); + if (!o || pts.empty()) return say("Select points (or faces, edges) to smooth"); + pushUndo(); + smoothPoints(o->mesh, pts, 0.5f, 2); + changedShape(*o, true); + say("Smoothed (again for more)"); + } + void startPush() { + Object* o = editing(); + if (!o || selectedPoints().empty()) return say("Select points (or faces) to push out or pull in"); + pushUndo(); + pushPts_ = selectedPoints(), pushFrom_ = o->mesh, pushObject_ = o->id, pushBy_ = 0.0f, showPush_ = true; + } + void pushWindow() { + if (!showPush_) return; + Object* o = find(pushObject_); + if (!o || mode_ != Mode::Edit) return void(showPush_ = false); + ImGui::SetNextWindowPos(ImGui::GetMainViewport()->GetCenter(), ImGuiCond_Appearing, ImVec2(0.5f, 0.5f)); + bool open = true; + if (!ImGui::Begin("Push out, pull in", &open, ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoDocking)) return ImGui::End(); + const float w = 260; + float mm = pushBy_ * 1000; + ImGui::SetNextItemWidth(w); + if (ImGui::IsWindowAppearing()) ImGui::SetKeyboardFocusHere(); + if (ImGui::DragFloat("##by", &mm, 0.5f, -1e5f, 1e5f, "%.1f mm (- pulls in)")) { + pushBy_ = mm / 1000; + o->mesh = pushFrom_; + pushPoints(o->mesh, pushPts_, pushBy_); + changedShape(*o, true); + } + ImGui::TextWrapped("Each point along the surface's normal there: fatter or thinner, the same shape."); + pushAccent(); + const bool done = ImGui::Button("Done", ImVec2(w, 0)) || ImGui::IsKeyPressed(ImGuiKey_Enter, false); + popAccent(); + ImGui::End(); + if (done) showPush_ = false; + if (!open) o->mesh = pushFrom_, changedShape(*o, true), showPush_ = false; // (closed: as it was) + } + void bridgeNow() { + Object* o = editing(); + if (!o || selFaces_.size() != 2) return say("Select two faces to bridge (Faces, 3; Join two objects first if they're apart)"); + const int a = *selFaces_.begin(), b = *selFaces_.rbegin(); + if (o->mesh.faces[size_t(a)].size() != o->mesh.faces[size_t(b)].size()) + return say(std::format("Those faces have {} and {} corners: bridging needs as many on each", o->mesh.faces[size_t(a)].size(), o->mesh.faces[size_t(b)].size())); + pushUndo(); + const std::vector made = bridgeFaces(o->mesh, a, b); + if (made.empty()) return takeBack(), say("Couldn't bridge those (they share a point)"); + changedShape(*o); + selFaces_.insert(made.begin(), made.end()); + say(std::format("Bridged: {} faces between them", made.size())); + } + void flipNow() { + Object* o = editing(); + if (!o || selFaces_.empty()) return say("Select faces to flip"); + pushUndo(); + flipFaces(o->mesh, selFaces_); + changedShape(*o, true); + } + // Rotation and scale baked into the points (so the object's turn is none and its size is 1). + void applyTransform(Object& o) { + pushUndo(); + transform(o.mesh, glm::mat4_cast(o.rotation) * glm::scale(glm::mat4(1.0f), o.scale)); + o.rotation = glm::quat(1, 0, 0, 0), o.scale = glm::vec3(1.0f); + handMade(o); + touched(o.id); + } + void applyFinish(Object& o) { + pushUndo(); + o.mesh = finishedMesh(o); + o.mirror = 0, o.round = 0, o.smoothLevels = 0; + handMade(o); + touched(o.id); + } + void originToMiddle(Object& o) { + pushUndo(); + const glm::vec3 c = (boundsMin(o.mesh) + boundsMax(o.mesh)) * 0.5f; + for (glm::vec3& v : o.mesh.verts) v -= c; + o.position += glm::vec3(objectMatrix(o) * glm::vec4(c, 0.0f)); + handMade(o); + touched(o.id); + } + void originToBottom(Object& o) { + pushUndo(); + const glm::vec3 c((boundsMin(o.mesh).x + boundsMax(o.mesh).x) * 0.5f, (boundsMin(o.mesh).y + boundsMax(o.mesh).y) * 0.5f, boundsMin(o.mesh).z); + for (glm::vec3& v : o.mesh.verts) v -= c; + o.position += glm::vec3(objectMatrix(o) * glm::vec4(c, 0.0f)); + handMade(o); + touched(o.id); + } + void mirror(Object& o, int axis) { + pushUndo(); + glm::vec3 s(1.0f); + s[axis] = -1.0f; + transform(o.mesh, glm::scale(glm::mat4(1.0f), s)); + handMade(o); + touched(o.id); + } + + + // ───────────────────────────── tabs ───────────────────────────── + + void setTab(Tab t) { + if (t == tab_) return; + if (drag_.active) cancelDrag(); + lockLayer(); + endStroke(), endSculpt(); + knifePts_.clear(), drawPts_.clear(); + if (t != Tab::Model) leaveEdit(); + if ((t == Tab::Render || t == Tab::Print) && tool_ != Tool::Select && tool_ != Tool::Move && tool_ != Tool::Rotate && tool_ != Tool::Scale) tool_ = Tool::Move; + if ((t == Tab::Paint || t == Tab::Layout) && tab_ != Tab::Paint && tab_ != Tab::Layout) picZoom_ = 0; // (fit the picture again) + lay_.drag = 0, printTool_ = PrintTool::None, measure_.clear(); + tab_ = t; + } + Shading shading() const { return tab_ == Tab::Render ? Shading::Rendered : shading_; } + + // ───────────────────────────── the knife and holes ───────────────────────────── + + // Snapped to steps of 45 degrees from the last point while Ctrl is held. + ImVec2 knifeEnd(const ImGuiIO& io) const { + 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); + } + static Mesh subMesh(const Mesh& m, const std::set& faces) { + 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. + uint32_t objectLike(uint32_t from, Mesh mesh, const std::string& name) { + 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; + } + void knifeApply(uint32_t id, std::vector pts) { + 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 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& 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& a, const std::set& 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 others; + for (size_t i = 1; i < n; ++i) others.insert(pieces[i].begin(), pieces[i].end()); + std::vector 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"); + } + static float objectScale(const Object& o) { return std::max(std::cbrt(std::abs(glm::determinant(glm::mat3(objectMatrix(o))))), 1e-6f); } + // How wide (across) a shape is when it reaches `reach` from its middle (dragging it out). + float holeAcrossFor(float reach) const { + return HoleShape(holeShape_) == HoleShape::Triangle || HoleShape(holeShape_) == HoleShape::Hexagon ? reach * std::sqrt(3.0f) : reach * 2.0f; + } + // The outline, `across` wide in the object's own size (a rectangle keeps its proportions). + std::vector holeShapeOutline(const Object& o, int face, const glm::vec3& centre, float across) const { + const float tall = across * holeTall_ / std::max(holeSize_, 1e-6f); + return holeOutline(o.mesh, face, centre, HoleShape(holeShape_), across, tall, holeSides_, holeTurn_); + } + void applyHole(uint32_t id, int face, const glm::vec3& centre, float across) { + 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. + std::vector holePreview(const ImGuiIO& io) { + 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 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). + std::optional onFacePlane(const Object& o, int face, ImVec2 mouse) { + 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)); + } + + // ───────────────────────────── 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). + Object* readyToPaint(uint32_t id) { + 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(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. + Image& changePicture(Object& o) { + pushUndo(); + auto img = std::make_shared(*o.picture); + o.picture = img; + paint_.image = img; + uploaded_[o.id].dirty = true; + return *img; + } + void beginStroke(Object& o, bool in2D) { + 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_); + } + void endStroke() { + 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. + std::optional uvUnder(const Object& o, const Ray& ray) { + if (!o.uvsFit()) return std::nullopt; + const glm::mat4 m = objectMatrix(o); + float best = 1e30f; + std::optional 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; } - for (auto [a, b] : edges(o->mesh)) - if (selVerts_.count(a) && selVerts_.count(b)) out.insert({a, b}); - 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). + void dab3D(Object& o, const glm::vec3& at, float radius) { + Image& im = *paint_.image; + const glm::mat4 m = objectMatrix(o); + 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; + 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. + void paintAt(ImVec2 mouse, bool start) { + 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); + 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) dab3D(*o, glm::mix(paint_.last, at, float(i) / float(steps)), radius); + } else + dab3D(*o, at, radius); + paint_.last = at, paint_.haveLast = true; + } + void pickColor(const Object& o, glm::vec2 uv) { + const glm::vec4 c = sample(*o.picture, uv * glm::vec2(o.picture->w, o.picture->h)); + paintColor_ = glm::vec4(glm::vec3(c), paintColor_.a); + } + void fillAt(Object& o, glm::vec2 uv) { + 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(); + } -auto App::deleteElements() -> void { - Object* o = editing(); - if (!o) return; - pushUndo(); - if (elem_ == Elem::Face) deleteFaces(o->mesh, selFaces_); - else deleteVerts(o->mesh, selectedPoints()); - clearElements(); - handMade(*o); - touched(o->id); -} + // ── Sculpting ── -// After a change that renumbers things: nothing selected, the shape is hand-made now. -auto App::changedShape(Object& o, bool keepSelection) -> void { - if (!keepSelection) clearElements(); - handMade(o); - touched(o.id); -} + // 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. + Object* readyToSculpt(uint32_t id) { + 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; + } + void endSculpt() { + if (!sculpt_.active) return; + sculpt_ = {}; + } + // The brush at a point of the view: starts a stroke on what's there, or carries one on. + void sculptAt(ImVec2 mouse, bool start, bool ctrl, bool shift) { + 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(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); + } + void sculptViewport(const ImGuiIO& io) { + 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; + } -auto App::extrudeNow(float amount) -> void { - Object* o = editing(); - if (!o || elem_ != Elem::Face || selFaces_.empty()) return say("Select faces to extrude (Faces, 3)"); - pushUndo(); - extrudeFaces(o->mesh, selFaces_, amount); - changedShape(*o, true); -} + // ── Picture layers ── -auto App::insetNow(float amount) -> void { - Object* o = editing(); - if (!o || elem_ != Elem::Face || selFaces_.empty()) return say("Select faces to inset (Faces, 3)"); - pushUndo(); - insetFaces(o->mesh, selFaces_, amount); - changedShape(*o, true); -} + PictureLayer* editLayer() { + 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. + void startFloating(Image img) { + 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(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."); + } + void floatFromFile(const std::string& path) { + Image img; + if (!loadImageFile(path, img)) return say("Couldn't load " + path); + startFloating(std::move(img)); + } + void pasteFromClipboard() { + 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(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). + void lockLayer() { + 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."); + } + void unlockLayer(uint32_t object, int index) { + 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. + void cancelFloating() { + Object* o = find(ledit_.object); + if (o && editLayer() && ledit_.fresh) { + o->layers.erase(o->layers.begin() + ledit_.index); + ledit_ = {}; + return say("Taken away"); + } + lockLayer(); + } + void deleteLayer(Object& o, int i) { + 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). + void mergeLayer(Object& o, int i) { + 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(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. + bool stickerHandles(const Object& o, const PictureLayer& l, std::array& out) { + 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; + } + int stickerHandle(const Object& o, const PictureLayer& l, ImVec2 mouse) { + std::array 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. + void stickerMouse(const ImGuiIO& io) { + 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). + uint64_t pictureStamp(const Object& o) { + uint64_t h = 1469598103934665603ull; + auto mix = [&](const void* p, size_t n) { + for (size_t i = 0; i < n; ++i) h = (h ^ static_cast(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. + void uploadPicture(const Object& o, bool now_ = false) { + 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}; + } -auto App::bevelNow() -> void { - Object* o = editing(); - const auto es = edgesToRound(); - if (!o || es.empty()) return say("Select edges (or faces) to round"); - pushUndo(); - bevelEdges(o->mesh, es, bevelWidth_, bevelSteps_); - changedShape(*o); - if (bevelSteps_ > 1) o->smooth = true; -} + // ───────────────────────────── 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. -auto App::subdivideNow(bool smooth) -> void { - Object* o = editing(); - if (!o) return; - pushUndo(); - subdivide(o->mesh, smooth); - if (smooth) o->smooth = true; - changedShape(*o); -} + // What's picked, as (face, corner) pairs of its layout. + std::vector> layoutPicked(const Object& o, const std::vector>& islands) const { + std::vector> 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. + std::set> layoutCorner(const Object& o, int f, int c) const { + std::set> 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; + } + void layoutSelectAll() { + 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. + void layoutChange(Object& o, const std::vector>& from, const std::shared_ptr& picture, + const std::vector>& picked, const std::function& to, bool final) { + const glm::vec2 size(float(o.picture->w), float(o.picture->h)); + o.uvs = from; + std::set 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(std::move(img)); + lay_.remapAt = now(); + } + } + // Buttons' changes (turn, flip, straighten): about the middle of what's picked. + void layoutApply(const std::function& to) { + 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); + } + void layoutStraighten() { + 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 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(), 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. + void layoutAgain(Object& o) { + 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 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(std::move(fresh)); + } + lay_.frames.clear(), lay_.points.clear(); + touched(o.id); + say("Laid out again" + std::string(layKeepPaint_ ? ", what's painted carried over" : "")); + } -auto App::cutAt(Object& o, std::pair edge) -> void { - pushUndo(); - const auto made = loopCut(o.mesh, edge, cuts_); - if (made.empty()) { + template + void layoutCanvas(Object& o, ImDrawList* dl, F toScreen, glm::vec2 mouse, bool hovered, int W, int H) { + const ImGuiIO& io = ImGui::GetIO(); + if (lay_.object != o.id) lay_ = {}, lay_.object = o.id; + const glm::vec2 size{float(W), float(H)}; + const auto islands = uvIslands(o.mesh, o.uvs); + std::vector islandOf(o.mesh.faces.size(), -1); + for (size_t i = 0; i < islands.size(); ++i) + for (int f : islands[i]) islandOf[size_t(f)] = int(i); + auto screen = [&](int f, int c) { return toScreen(o.uvs[size_t(f)][size_t(c)] * size); }; + auto faceUnder = [&]() { + for (size_t f = 0; f < o.mesh.faces.size(); ++f) { + std::vector poly; + for (size_t c = 0; c < o.uvs[f].size(); ++c) poly.push_back(screen(int(f), int(c))); + if (insidePolygon(io.MousePos, poly)) return int(f); + } + return -1; + }; + auto cornerUnder = [&]() { + std::pair best{-1, -1}; + float d = 9.0f; + for (size_t f = 0; f < o.uvs.size(); ++f) + for (size_t c = 0; c < o.uvs[f].size(); ++c) { + const ImVec2 q = screen(int(f), int(c)); + if (const float e = std::hypot(q.x - io.MousePos.x, q.y - io.MousePos.y); e < d) d = e, best = {int(f), int(c)}; + } + return best; + }; + const auto picked = layoutPicked(o, islands); + std::set pickedFaces; + for (auto [f, c] : picked) pickedFaces.insert(f); + // Pressing: pick (what's under it, or a box), then move / turn / size what's picked. + if (hovered && ImGui::IsMouseClicked(ImGuiMouseButton_Left) && !lay_.drag) { + bool hit = false, wasPicked = false; + if (lay_.corners) { + const auto [f, c] = cornerUnder(); + if (f >= 0) { + const auto same = layoutCorner(o, f, c); + hit = true, wasPicked = lay_.points.count({f, c}) > 0; + if (io.KeyShift && wasPicked) + for (auto p : same) lay_.points.erase(p); + else { + if (!io.KeyShift && !wasPicked) lay_.points.clear(); + lay_.points.insert(same.begin(), same.end()); + } + } + } else if (const int f = faceUnder(); f >= 0) { + const int i = islandOf[size_t(f)]; + hit = true, wasPicked = lay_.frames.count(i) > 0; + if (io.KeyShift && wasPicked) lay_.frames.erase(i); + else { + if (!io.KeyShift && !wasPicked) lay_.frames.clear(); + lay_.frames.insert(i); + } + } + const bool anything = !lay_.frames.empty() || !lay_.points.empty(); + const bool transform = layTool_ != LayTool::Select && anything && !(io.KeyShift && hit) && (hit || layTool_ != LayTool::Move); + if (transform) { + pushUndo(); + lay_.drag = 1, lay_.grab = mouse, lay_.start = o.uvs, lay_.startPicture = o.picture; + glm::vec2 lo(1e30f), hi(-1e30f); + for (auto [f, c] : layoutPicked(o, islands)) 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); + lay_.centre = (lo + hi) * 0.5f; + } else if (!hit) { + if (!io.KeyShift) lay_.frames.clear(), lay_.points.clear(); + lay_.drag = 2, lay_.boxFrom = io.MousePos; + } + } + if (lay_.drag == 1) { + const glm::vec2 g = lay_.grab - lay_.centre, m = mouse - lay_.centre; + std::function to; + if (layTool_ == LayTool::Move || layTool_ == LayTool::Select) { + const glm::vec2 d = mouse - lay_.grab; + to = [d](glm::vec2 p) { return p + d; }; + message_ = std::format("Moved {:.0f}, {:.0f} px", d.x, d.y); + } else if (layTool_ == LayTool::Rotate) { + float a = std::atan2(m.y, m.x) - std::atan2(g.y, g.x); + if (io.KeyCtrl) a = glm::radians(snapTo(glm::degrees(a), 15.0f)); + const glm::vec2 c = lay_.centre; + to = [a, c](glm::vec2 p) { return c + glm::vec2(std::cos(a) * (p - c).x - std::sin(a) * (p - c).y, std::sin(a) * (p - c).x + std::cos(a) * (p - c).y); }; + message_ = std::format("Turned {:.0f} degrees", glm::degrees(a)); + } else { + glm::vec2 k(glm::length(m) / std::max(glm::length(g), 1.0f)); + if (io.KeyShift) // (only across or only up: whichever the drag goes along more) + k = std::abs(g.x) > std::abs(g.y) ? glm::vec2(m.x / (std::abs(g.x) > 1 ? g.x : 1.0f), 1.0f) : glm::vec2(1.0f, m.y / (std::abs(g.y) > 1 ? g.y : 1.0f)); + const glm::vec2 c = lay_.centre; + to = [k, c](glm::vec2 p) { return c + (p - c) * k; }; + message_ = std::format("Size {:.0f}% x {:.0f}%", k.x * 100, k.y * 100); + } + messageAt_ = now(); + const bool done = !ImGui::IsMouseDown(ImGuiMouseButton_Left); + layoutChange(o, lay_.start, lay_.startPicture, layoutPicked(o, islands), to, done); + if (done) lay_.drag = 0, lay_.startPicture.reset(); + } + // Drawing: every face's outline, what's picked filled; corners as dots. + const ImU32 fill = (kAccent & 0x00FFFFFF) | 0x38000000; + for (size_t f = 0; f < o.mesh.faces.size(); ++f) { + std::vector pts; + for (size_t c = 0; c < o.uvs[f].size(); ++c) pts.push_back(screen(int(f), int(c))); + const bool on = pickedFaces.count(int(f)) && !lay_.corners; + if (on) + for (const auto& t : triangulate(o.mesh, int(f))) { + ImVec2 q[3]; + for (int k = 0; k < 3; ++k) q[k] = pts[size_t(std::find(o.mesh.faces[f].begin(), o.mesh.faces[f].end(), t[size_t(k)]) - o.mesh.faces[f].begin())]; + dl->AddTriangleFilled(q[0], q[1], q[2], fill); + } + dl->AddPolyline(pts.data(), int(pts.size()), on ? kAccent : IM_COL32(255, 255, 255, 150), ImDrawFlags_Closed, on ? 2.0f : 1.2f); + } + if (lay_.corners) { + const auto hot = cornerUnder(); + for (size_t f = 0; f < o.uvs.size(); ++f) + for (size_t c = 0; c < o.uvs[f].size(); ++c) { + const ImVec2 q = screen(int(f), int(c)); + const bool on = lay_.points.count({int(f), int(c)}) > 0; + dl->AddCircleFilled(q, on ? 4.5f : 3.0f, on ? kAccent : hot == std::pair{int(f), int(c)} ? kHot : IM_COL32(255, 255, 255, 200)); + } + } else if (hovered && !lay_.drag) + if (const int f = faceUnder(); f >= 0 && !lay_.frames.count(islandOf[size_t(f)])) + for (int g : islands[size_t(islandOf[size_t(f)])]) { + std::vector pts; + for (size_t c = 0; c < o.uvs[size_t(g)].size(); ++c) pts.push_back(screen(g, int(c))); + dl->AddPolyline(pts.data(), int(pts.size()), kHot, ImDrawFlags_Closed, 1.5f); + } + if (lay_.drag == 1 && layTool_ != LayTool::Move && layTool_ != LayTool::Select) { // (the middle it turns or sizes about) + const ImVec2 c = toScreen(lay_.centre); + dl->AddCircle(c, 6, kAccent, 16, 2.0f); + dl->AddLine(c, io.MousePos, (kAccent & 0x00FFFFFF) | 0x90000000, 1.0f); + } + if (lay_.drag == 2) { // a box to pick with + const ImVec2 a = lay_.boxFrom, b = io.MousePos; + dl->AddRectFilled(ImVec2(std::min(a.x, b.x), std::min(a.y, b.y)), ImVec2(std::max(a.x, b.x), std::max(a.y, b.y)), IM_COL32(255, 120, 40, 30)); + dl->AddRect(ImVec2(std::min(a.x, b.x), std::min(a.y, b.y)), ImVec2(std::max(a.x, b.x), std::max(a.y, b.y)), kAccent); + if (!ImGui::IsMouseDown(ImGuiMouseButton_Left)) { + auto in = [&](ImVec2 q) { return q.x >= std::min(a.x, b.x) && q.x <= std::max(a.x, b.x) && q.y >= std::min(a.y, b.y) && q.y <= std::max(a.y, b.y); }; + for (size_t f = 0; f < o.uvs.size(); ++f) + for (size_t c = 0; c < o.uvs[f].size(); ++c) + if (in(screen(int(f), int(c)))) { + if (lay_.corners) lay_.points.insert({int(f), int(c)}); + else lay_.frames.insert(islandOf[f]); + } + lay_.drag = 0; + } + } + } + // In the 3D view: clicking the model picks the frame its face is in (Shift: add). + void layoutViewport(const ImGuiIO& io) { + 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)); + } + } + void layoutOverlay(ImDrawList* dl) { + 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 faces; + for (auto [f, c] : picked) faces.insert(f); + const glm::mat4 m = objectMatrix(*o); + for (int f : faces) { + std::vector 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); + } + } + void layoutToolbox(float w) { + 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(); + } + void layoutOptions(float w) { + 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"); + } + void layoutProperties(float w) { + 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."); + } + + // ───────────────────────────── the Print tab ───────────────────────────── + + glm::vec3 bedSize() const { // (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; + } + const PrintInfo& printInfo(const Object& o) { + PrintInfo& p = printInfos_[o.id]; + uint64_t key = versions_[o.id] * 1000003ull + uint64_t(overhangDeg_ * 10); + 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 = 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); + } + return p; + } + std::vector printTargets() { // (what's picked, or everything shown) + std::vector out; + for (Object& o : scene_.objects) + if (o.visible && (selected_.empty() || selected_.count(o.id))) out.push_back(&o); + return out; + } + void drawPrintBed(keel::Renderer& r, const keel::ModelLighting& light) { + 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) + 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); + } + } + void putOnBed() { + 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"); + } + void middleOfBed() { + 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. + void printResize(int axis, float mm) { + 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); + } + void printToolbox(float w) { + 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); + } + void printOptions(float w) { + 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."); + } + } + void printViewport(const ImGuiIO& io) { + 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. + std::optional measurePoint(ImVec2 mouse) { + 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; + } + void printOverlay(ImDrawList* dl, const ImGuiIO& io) { + if (printTool_ != PrintTool::Measure) return; + std::vector 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()); + } + } + void printProperties(float w) { + 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"); + 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.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)"); + + 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)"); + } + + // ───────────────────────────── layers: folders and going to things ───────────────────────────── + + Folder* findFolder(uint32_t id) { + for (Folder& f : scene_.folders) + if (f.id == id) return &f; + return nullptr; + } + bool inFolder(uint32_t folder, uint32_t within) { // (folder is within, or inside it somewhere) + for (int n = 0; folder && n < 256; ++n) { + if (folder == within) return true; + const Folder* f = findFolder(folder); + folder = f ? f->parent : 0; + } + return false; + } + std::set objectsIn(uint32_t folder) { + std::set out; + for (const Object& o : scene_.objects) + if (inFolder(o.folder, folder)) out.insert(o.id); + return out; + } + // The camera brought round to these (as framing them, but keeping the way it looks). + void goTo(const std::set& ids) { + glm::vec3 lo(1e30f), hi(-1e30f); + for (const Object& o : scene_.objects) + if (ids.count(o.id)) { + const glm::mat4 m = objectMatrix(o); + for (const glm::vec3& v : o.mesh.verts) { + const glm::vec3 w(m * glm::vec4(v, 1.0f)); + lo = glm::min(lo, w), hi = glm::max(hi, w); + } + } + if (lo.x > hi.x) return; + frameBox(lo, hi); + } + // The picked objects into a new folder (Ctrl+G): a group, picked and moved together. + void groupSelected() { + if (mode_ == Mode::Edit) leaveEdit(); + if (selected_.empty()) return say("Pick the objects to put in a folder first"); + pushUndo(); + Folder f; + f.id = scene_.nextId++; + int n = 1; + while (std::any_of(scene_.folders.begin(), scene_.folders.end(), [&](const Folder& g) { return g.name == std::format("Group {}", n); })) ++n; + f.name = std::format("Group {}", n); + if (const Object* a = find(active_)) f.parent = a->folder; // (where the last picked was) + for (Object& o : scene_.objects) + if (selected_.count(o.id)) o.folder = f.id; + scene_.folders.push_back(f); + renamingFolder_ = f.id, std::snprintf(renameBuf_, sizeof renameBuf_, "%s", f.name.c_str()); + say(f.name + ": click it to pick all of it; type a name"); + } + // Ungrouped: what's inside goes up a level, the folder goes. + void ungroup(uint32_t id) { + const Folder* f = findFolder(id); + if (!f) return; + pushUndo(); + const uint32_t up = f->parent; + for (Object& o : scene_.objects) + if (o.folder == id) o.folder = up; + for (Folder& g : scene_.folders) + if (g.parent == id) g.parent = up; + std::erase_if(scene_.folders, [&](const Folder& g) { return g.id == id; }); + } + void deleteFolder(uint32_t id) { + pushUndo(); + const auto ids = objectsIn(id); + std::erase_if(scene_.objects, [&](const Object& o) { return ids.count(o.id) > 0; }); + std::erase_if(scene_.folders, [&](const Folder& g) { return inFolder(g.id, id); }); + for (uint32_t i : ids) selected_.erase(i); + } + // A folder's eye or lock: all in it the same. + void showFolder(Folder& f, bool on) { + pushUndo(); + for (Folder& g : scene_.folders) + if (inFolder(g.id, f.id)) g.visible = on; + for (Object& o : scene_.objects) + if (inFolder(o.folder, f.id)) o.visible = on; + } + void lockFolder(Folder& f, bool on) { + pushUndo(); + for (Folder& g : scene_.folders) + if (inFolder(g.id, f.id)) g.locked = on; + for (Object& o : scene_.objects) + if (inFolder(o.folder, f.id)) o.locked = on; + } + void moveToFolder(const std::set& ids, uint32_t folder) { + pushUndo(); + for (Object& o : scene_.objects) + if (ids.count(o.id)) o.folder = folder; + } + // Only these shown (again: everything shown). + void showOnly(const std::set& ids) { + pushUndo(); + const bool already = std::all_of(scene_.objects.begin(), scene_.objects.end(), [&](const Object& o) { return o.visible == (ids.count(o.id) > 0); }); + for (Object& o : scene_.objects) o.visible = already || ids.count(o.id); + for (Folder& f : scene_.folders) f.visible = true; + say(already ? "Everything shown again" : "Only that shown (do it again to show everything)"); + } + void folderMenu(uint32_t into, const std::set& ids, int depth = 0) { // (Move to folder: the folders inside `into`) + for (const Folder& f : scene_.folders) + if (f.parent == into) { + const std::string label = std::string(size_t(depth) * 3, ' ') + f.name; + if (ImGui::MenuItem(label.c_str())) moveToFolder(ids, f.id); + folderMenu(f.id, ids, depth + 1); + } + } + // What a right-click on an object (or the picked ones) offers. + void objectMenu(Object& o) { + const std::set ids = selected_.count(o.id) ? selected_ : std::set{o.id}; + if (ImGui::MenuItem("Go to it", "F")) goTo(ids); + if (ImGui::MenuItem("Rename")) renaming_ = o.id, std::snprintf(renameBuf_, sizeof renameBuf_, "%s", o.name.c_str()); + if (ImGui::MenuItem("Duplicate", "Ctrl+D")) selected_ = ids, active_ = o.id, duplicateSelected(); + if (ImGui::MenuItem("Show only this (again: all)")) showOnly(ids); + if (ImGui::BeginMenu("Move to folder")) { + if (ImGui::MenuItem("(the top, no folder)")) moveToFolder(ids, 0); + folderMenu(0, ids); + ImGui::Separator(); + if (ImGui::MenuItem("A new folder", "Ctrl+G")) selected_ = ids, active_ = o.id, groupSelected(); + ImGui::EndMenu(); + } + ImGui::Separator(); + if (ImGui::MenuItem("Delete", "Del")) selected_ = ids, deleteSelected(); + } + // A row's show / hide or lock button: the picture of how it is now, dim when it's the usual way. + bool rowToggle(const char* id, Icon icon, bool usual, const char* tip) { + const ImVec2 at = ImGui::GetCursorScreenPos(); + const float h = ImGui::GetFrameHeight() - 4; + const bool pressed = ImGui::InvisibleButton(id, ImVec2(h, h)); + const ImU32 col = ImGui::IsItemHovered() ? kHot : usual ? IM_COL32(200, 200, 205, 150) : ImGui::GetColorU32(accentText()); + drawIcon(ImGui::GetWindowDrawList(), icon, ImVec2(at.x + h * 0.1f, at.y + h * 0.1f), h * 0.8f, col); + if (ImGui::IsItemHovered()) ImGui::SetTooltip("%s", tip); + return pressed; + } + // The ⌖ button at the end of a row: brings the camera to it. + bool goButton(const char* tip) { + const ImVec2 at = ImGui::GetCursorScreenPos(); + const float h = ImGui::GetFrameHeight() - 4; + const bool pressed = ImGui::InvisibleButton("##go", ImVec2(h, h)); + const ImU32 col = ImGui::IsItemHovered() ? kHot : IM_COL32(200, 200, 205, 200); + const ImVec2 c(at.x + h * 0.5f, at.y + h * 0.5f); + ImDrawList* dl = ImGui::GetWindowDrawList(); + dl->AddCircle(c, h * 0.3f, col, 16, 1.5f); + dl->AddLine(ImVec2(c.x - h * 0.48f, c.y), ImVec2(c.x - h * 0.16f, c.y), col, 1.5f), dl->AddLine(ImVec2(c.x + h * 0.16f, c.y), ImVec2(c.x + h * 0.48f, c.y), col, 1.5f); + dl->AddLine(ImVec2(c.x, c.y - h * 0.48f), ImVec2(c.x, c.y - h * 0.16f), col, 1.5f), dl->AddLine(ImVec2(c.x, c.y + h * 0.16f), ImVec2(c.x, c.y + h * 0.48f), col, 1.5f); + if (ImGui::IsItemHovered()) ImGui::SetTooltip("%s", tip); + return pressed; + } + // Rows for what's in a folder (0: the top): its folders, then its objects. + void layerRows(uint32_t parent, int depth) { + const float indent = float(depth) * 14.0f; + // (copies of the ids: rows may change the lists) + std::vector folders, objects; + for (const Folder& f : scene_.folders) + if (f.parent == parent) folders.push_back(f.id); + for (const Object& o : scene_.objects) + if (o.folder == parent) objects.push_back(o.id); + for (uint32_t id : folders) { + Folder* f = findFolder(id); + if (!f) continue; + const auto inside = objectsIn(id); + ImGui::PushID(int(id) + 1000000); + ImGui::TableNextRow(); + ImGui::TableNextColumn(); + if (rowToggle("##eye", f->visible ? Icon::Eye : Icon::EyeShut, f->visible, f->visible ? "Hide all in it" : "Show all in it")) showFolder(*f, !f->visible); + ImGui::TableNextColumn(); + if (rowToggle("##lock", f->locked ? Icon::Lock : Icon::Unlock, !f->locked, f->locked ? "Unlock all in it" : "Lock all in it")) lockFolder(*f, !f->locked); + ImGui::TableNextColumn(); + ImGui::Indent(indent + 1); + if (ImGui::ArrowButton("##open", f->open ? ImGuiDir_Down : ImGuiDir_Right)) f->open = !f->open; + ImGui::SameLine(0, 4); + const bool picked = !inside.empty() && std::all_of(inside.begin(), inside.end(), [&](uint32_t i) { return selected_.count(i) > 0; }); + if (renamingFolder_ == id) { + ImGui::SetNextItemWidth(-1); + if (!ImGui::IsAnyItemActive()) ImGui::SetKeyboardFocusHere(); + if (ImGui::InputText("##rename", renameBuf_, sizeof renameBuf_, ImGuiInputTextFlags_EnterReturnsTrue | ImGuiInputTextFlags_AutoSelectAll)) { + if (renameBuf_[0]) f->name = renameBuf_; + renamingFolder_ = 0, dirty_ = true; + } + if (ImGui::IsItemDeactivated()) renamingFolder_ = 0; + } else { + ImGui::PushStyleColor(ImGuiCol_Text, accentText()); + const std::string label = std::format("{} ({})", f->name, inside.size()); + if (ImGui::Selectable(label.c_str(), picked, ImGuiSelectableFlags_AllowDoubleClick)) { // a folder is a group: all of it picked + if (mode_ == Mode::Edit) leaveEdit(); + if (ImGui::GetIO().KeyCtrl || ImGui::GetIO().KeyShift) selected_.insert(inside.begin(), inside.end()); + else selected_ = inside; + if (!inside.empty()) active_ = *inside.rbegin(); + activeRef_ = 0; + if (ImGui::IsMouseDoubleClicked(ImGuiMouseButton_Left)) renamingFolder_ = id, std::snprintf(renameBuf_, sizeof renameBuf_, "%s", f->name.c_str()); + } + ImGui::PopStyleColor(); + if (ImGui::BeginDragDropSource()) { + ImGui::SetDragDropPayload("FRY_FOLDER", &id, sizeof id); + ImGui::Text("%s", f->name.c_str()); + ImGui::EndDragDropSource(); + } + if (ImGui::BeginDragDropTarget()) { + if (const ImGuiPayload* p = ImGui::AcceptDragDropPayload("FRY_OBJECT")) { + const uint32_t oid = *static_cast(p->Data); + moveToFolder(selected_.count(oid) ? selected_ : std::set{oid}, id); + } + if (const ImGuiPayload* p = ImGui::AcceptDragDropPayload("FRY_FOLDER")) { + const uint32_t fid = *static_cast(p->Data); + if (Folder* g = findFolder(fid); g && !inFolder(id, fid)) pushUndo(), g->parent = id; // (not into itself) + } + ImGui::EndDragDropTarget(); + } + if (ImGui::IsItemHovered() && !ImGui::IsMouseDragging(ImGuiMouseButton_Left)) ImGui::SetTooltip("Click: pick all of it (a group). Double-click: rename. Drag things onto it."); + if (ImGui::BeginPopupContextItem("folder")) { + if (ImGui::MenuItem("Go to it")) goTo(inside); + if (ImGui::MenuItem("Pick all of it")) selected_ = inside; + if (ImGui::MenuItem("Rename")) renamingFolder_ = id, std::snprintf(renameBuf_, sizeof renameBuf_, "%s", f->name.c_str()); + if (ImGui::MenuItem("Show only this (again: all)")) showOnly(inside); + if (ImGui::MenuItem("A folder inside it")) { + pushUndo(); + Folder g; + g.id = scene_.nextId++, g.name = "Folder", g.parent = id; + scene_.folders.push_back(g); + f = findFolder(id), f->open = true; + renamingFolder_ = g.id, std::snprintf(renameBuf_, sizeof renameBuf_, "%s", g.name.c_str()); + } + if (ImGui::MenuItem("Take it to the top") && f->parent) pushUndo(), f->parent = 0; + ImGui::Separator(); + if (ImGui::MenuItem("Ungroup (keep what's in it)")) ungroup(id); + if (ImGui::MenuItem("Delete it and all in it")) deleteFolder(id); + ImGui::EndPopup(); + } + } + ImGui::Unindent(indent + 1); + ImGui::TableNextColumn(); + if (goButton("Go to it: the camera comes round to all of it")) goTo(inside); + ImGui::PopID(); + if ((f = findFolder(id)) && f->open) layerRows(id, depth + 1); + } + for (uint32_t id : objects) { + Object* op = find(id); + if (!op) continue; + Object& o = *op; + ImGui::PushID(int(o.id)); + ImGui::TableNextRow(); + ImGui::TableNextColumn(); + if (rowToggle("##eye", o.visible ? Icon::Eye : Icon::EyeShut, o.visible, o.visible ? "Shown (click to hide)" : "Hidden (click to show)")) o.visible = !o.visible, dirty_ = true; + ImGui::TableNextColumn(); + if (rowToggle("##lock", o.locked ? Icon::Lock : Icon::Unlock, !o.locked, o.locked ? "Locked: can't be moved or changed (click to unlock)" : "Click to lock")) o.locked = !o.locked, dirty_ = true; + ImGui::TableNextColumn(); + ImGui::Indent(indent + 1); + if (renaming_ == o.id) { + ImGui::SetNextItemWidth(-1); + if (!ImGui::IsAnyItemActive()) ImGui::SetKeyboardFocusHere(); + if (ImGui::InputText("##rename", renameBuf_, sizeof renameBuf_, ImGuiInputTextFlags_EnterReturnsTrue | ImGuiInputTextFlags_AutoSelectAll)) { + pushUndo(); + if (renameBuf_[0]) o.name = renameBuf_; + renaming_ = 0; + } + if (ImGui::IsItemDeactivated()) renaming_ = 0; + } else { + ImGui::PushStyleColor(ImGuiCol_Text, o.visible ? ImGui::GetStyleColorVec4(ImGuiCol_Text) : ImGui::GetStyleColorVec4(ImGuiCol_TextDisabled)); + if (ImGui::Selectable(o.name.c_str(), selected_.count(o.id) > 0, ImGuiSelectableFlags_AllowDoubleClick)) { + if (mode_ == Mode::Edit && o.id != active_) leaveEdit(); + if (ImGui::GetIO().KeyCtrl || ImGui::GetIO().KeyShift) selected_.count(o.id) ? (void)selected_.erase(o.id) : (void)selected_.insert(o.id); + else selected_ = {o.id}; + active_ = o.id, activeRef_ = 0; + if (ImGui::IsMouseDoubleClicked(ImGuiMouseButton_Left)) renaming_ = o.id, std::snprintf(renameBuf_, sizeof renameBuf_, "%s", o.name.c_str()); + } + ImGui::PopStyleColor(); + if (ImGui::BeginDragDropSource()) { + ImGui::SetDragDropPayload("FRY_OBJECT", &id, sizeof id); + ImGui::Text("%s", selected_.count(id) && selected_.size() > 1 ? std::format("{} objects", selected_.size()).c_str() : o.name.c_str()); + ImGui::EndDragDropSource(); + } + if (ImGui::IsItemHovered() && !ImGui::IsMouseDragging(ImGuiMouseButton_Left)) ImGui::SetTooltip("Double-click to rename; drag onto a folder; right-click for more"); + if (ImGui::BeginPopupContextItem("object")) { + objectMenu(o); + ImGui::EndPopup(); + } + } + ImGui::Unindent(indent + 1); + ImGui::TableNextColumn(); + if (goButton("Go to it: the camera comes round to it")) goTo({id}); + ImGui::PopID(); + } + } + + // ───────────────────────────── combining, copies, thickness ───────────────────────────── + + // The last picked object with the others added, taken out of it, or only where they overlap + // kept; the others go. + void combineSelected(Combine op) { + 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 others = [&] { + std::set 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())); + } + void makeCopies() { + if (mode_ == Mode::Edit) leaveEdit(); + if (selected_.empty()) return say("Pick what to copy first"); + pushUndo(); + const CopiesSetting& c = copies_; + std::set made = selected_; + const std::vector originals = [&] { + std::vector v; + for (const Object& o : scene_.objects) + if (selected_.count(o.id)) 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 = 1; k <= c.count; ++k) { + glm::mat4 T(1.0f); + 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) n.rotation = glm::normalize(glm::quat_cast(glm::mat3(T)) * o.rotation); + made.insert(id); + } + if (joinInto) changedShape(*joinInto); + } + selected_ = made; + touchAll(); + say(std::format("{} cop{} of each made{}", c.count, c.count == 1 ? "y" : "ies", c.joined ? ", joined into it" : "")); + } + void copiesWindow() { + 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.round ? 1 : 0; + ImGui::SetNextItemWidth(w); + if (ImGui::Combo("##kind", &kind, "In a row\0Round in a circle\0")) c.round = kind == 1; + 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(); + } + void thickenActive() { + 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)" : "")); + } + void thickenWindow() { + 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(); + } + void cleanFlatFaces() { + 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"); + } + + // ───────────────────────────── touch: phones and tablets ───────────────────────────── + // A phone: everything drawn bigger, the side panels only when asked for, a bar of big buttons + // at the bottom (undo, Shift and Ctrl that stay down, editing the shape...), and fingers: + // one drags the view round (or a tool's arrows), two move it and pinch to zoom, holding one + // still is a right-click. + + bool touch() const { return touchMode_; } + // How much bigger than its pixels the UI is drawn. + float touchScale() const { + if (fry_uiscale.f() > 0) return fry_uiscale.f(); + if (!touch()) return 1.0f; + const float density = SDL_GetDisplayContentScale(SDL_GetPrimaryDisplay()); + return std::clamp((density > 0 ? density : 2.0f) * 0.9f, 1.0f, 4.0f) * std::clamp(fry_uisize.f(), 0.5f, 2.0f); + } + void touchFrame(ImGuiIO& io) { + if (shiftLatch_) io.KeyShift = true; + if (ctrlLatch_) io.KeyCtrl = true; + ImGui::GetStyle().FontScaleMain = 1.0f; // (the sizes come from touchScale) + if (gest_.rightUp) { // (the long press's right-click: let go the frame after) + io.AddMouseButtonEvent(ImGuiMouseButton_Right, false); + gest_.rightUp = false; + } + // The fingers on the screen now (where, in the UI's units). + std::vector fingers; + int devices = 0; + if (SDL_TouchID* ids = SDL_GetTouchDevices(&devices)) { + for (int d = 0; d < devices; ++d) { + int n = 0; + if (SDL_Finger** f = SDL_GetTouchFingers(ids[d], &n)) { + for (int i = 0; i < n; ++i) fingers.push_back(ImVec2(f[i]->x * io.DisplaySize.x, f[i]->y * io.DisplaySize.y)); + SDL_free(f); + } + } + SDL_free(ids); + } + auto inView = [&](ImVec2 p) { return p.x >= viewMin_.x && p.y >= viewMin_.y && p.x < viewMax_.x && p.y < viewMax_.y; }; + if (fingers.size() >= 2) { + const ImVec2 mid((fingers[0].x + fingers[1].x) * 0.5f, (fingers[0].y + fingers[1].y) * 0.5f); + const float spread = std::max(std::hypot(fingers[0].x - fingers[1].x, fingers[0].y - fingers[1].y), 1.0f); + if (!gest_.two && !drag_.active && inView(mid) && !ImGui::IsAnyItemActive()) { // two fingers: the first's tap or drag is off + gest_.two = true, gest_.mid = mid, gest_.spread = spread; + boxing_ = lassoing_ = touchOrbit_ = tapDraw_ = false; + io.AddMouseButtonEvent(ImGuiMouseButton_Left, false); + } + if (gest_.two) { + panBy(ImVec2(mid.x - gest_.mid.x, mid.y - gest_.mid.y)); + dist_ = std::clamp(dist_ * gest_.spread / spread, 0.01f, 5000.0f); + gest_.mid = mid, gest_.spread = spread; + } + gest_.downAt = -1; + return; + } + if (fingers.empty()) gest_.two = false; + if (fingers.size() != 1 || gest_.two) return void(gest_.downAt = -1); + // One finger held still: a right-click there (menus in the panels, orbit in the view). + const ImVec2 p = fingers[0]; + if (gest_.downAt < 0) gest_.downAt = now(), gest_.downPos = p, gest_.longDone = false; + if (std::hypot(p.x - gest_.downPos.x, p.y - gest_.downPos.y) > 12) gest_.downAt = now() + 1e9; // (moved: not a long press) + if (!gest_.longDone && now() - gest_.downAt > 0.55 && !drag_.active) { + gest_.longDone = true; + boxing_ = lassoing_ = touchOrbit_ = tapDraw_ = false; + io.AddMouseButtonEvent(ImGuiMouseButton_Left, false); + io.AddMouseButtonEvent(ImGuiMouseButton_Right, true); + gest_.rightUp = true; + } + } + // The bar along the bottom of the view: big buttons, in rows if the view's narrow. + void touchBar() { + const float maxW = std::max(viewMax_.x - viewMin_.x - 16, 120.0f); + ImGui::SetNextWindowPos(ImVec2((viewMin_.x + viewMax_.x) * 0.5f, viewMax_.y - 6), ImGuiCond_Always, ImVec2(0.5f, 1.0f)); + ImGui::SetNextWindowBgAlpha(0.8f); + ImGui::Begin("##touchbar", nullptr, ImGuiWindowFlags_NoDecoration | ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoSavedSettings | ImGuiWindowFlags_NoFocusOnAppearing | + ImGuiWindowFlags_NoNav | ImGuiWindowFlags_NoDocking); + if (now() - messageAt_ < 4.0) ImGui::PushTextWrapPos(maxW), ImGui::TextColored(accentText(), "%s", message_.c_str()), ImGui::PopTextWrapPos(); + ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2(12, 8)); + const ImGuiStyle& st = ImGui::GetStyle(); + float x = 0; // (where the row's got to) + auto button = [&](const char* label, bool on = false, const char* tip = nullptr) { + const float w = ImGui::CalcTextSize(label).x + st.FramePadding.x * 2; + if (x > 0 && x + st.ItemSpacing.x + w <= maxW) ImGui::SameLine(), x += st.ItemSpacing.x; + else x = 0; + x += w; + if (on) pushAccent(); + const bool pressed = ImGui::Button(label); + if (on) popAccent(); + if (tip && ImGui::IsItemHovered()) ImGui::SetTooltip("%s", tip); + return pressed; + }; + if (button("Undo")) undo(); + if (button("Redo")) redo(); + if (button("Shift", shiftLatch_, "Held down until tapped again: adds to the selection, picks loops, scales both sides")) shiftLatch_ = !shiftLatch_; + if (button("Ctrl", ctrlLatch_, "Held down until tapped again: takes from the selection, snaps")) ctrlLatch_ = !ctrlLatch_; + if (tab_ == Tab::Model) { + if (button(mode_ == Mode::Edit ? "Done" : "Edit shape", mode_ == Mode::Edit)) mode_ == Mode::Edit ? leaveEdit() : enterEdit(); + if (button("Delete")) deleteSelected(); + } + if (button("Frame")) frameSelected(); + if (button("Views", viewsOpen_)) viewsOpen_ = !viewsOpen_; + if (button("Panels", panelsOpen_, "Layers, Properties (and the Picture when painting)")) panelsOpen_ = !panelsOpen_; + ImGui::PopStyleVar(); + ImGui::End(); + } + + // ───────────────────────────── the camera ───────────────────────────── + + glm::vec3 viewDir() const { return forwardOf(yaw_, pitch_); } + float viewHeight() const { return 2.0f * dist_ * std::tan(glm::radians(fov_) * 0.5f); } // metres across the view's height at the target + void setView(View v) { + const float size = viewHeight(); + view_ = v; + switch (v) { + case View::Front: yaw_ = 180, pitch_ = 0; break; // from +X, looking back at the front + case View::Back: yaw_ = 0, pitch_ = 0; break; + case View::Left: yaw_ = -90, pitch_ = 0; break; // from +Y (the model's left side) + case View::Right: yaw_ = 90, pitch_ = 0; break; + case View::Top: yaw_ = 0, pitch_ = 89.9f; break; // front up the screen + case View::Bottom: yaw_ = 0, pitch_ = -89.9f; break; + default: break; + } + // Side views: nearly flat (a narrow lens from far away), the same size on screen. + fov_ = v == View::Perspective ? 50.0f : 2.0f; + dist_ = size / (2.0f * std::tan(glm::radians(fov_) * 0.5f)); + } + void leaveFlatView() { + if (view_ == View::Perspective) return; + const float size = viewHeight(); + view_ = View::Perspective, fov_ = 50.0f; + dist_ = size / (2.0f * std::tan(glm::radians(fov_) * 0.5f)); + } + void frameBox(glm::vec3 lo, glm::vec3 hi) { + if (lo.x > hi.x) return; + target_ = (lo + hi) * 0.5f; + const float radius = std::max(glm::length(hi - lo) * 0.5f, 0.05f); + dist_ = radius * 1.4f / std::tan(glm::radians(fov_) * 0.5f); + } + void frameAll() { + glm::vec3 lo(1e30f), hi(-1e30f); + for (const Object& o : scene_.objects) { + if (!o.visible) continue; + const glm::mat4 m = objectMatrix(o); + for (const glm::vec3& v : o.mesh.verts) { + const glm::vec3 w(m * glm::vec4(v, 1.0f)); + lo = glm::min(lo, w), hi = glm::max(hi, w); + } + } + frameBox(lo, hi); + } + void frameSelected() { + glm::vec3 lo(1e30f), hi(-1e30f); + for (const glm::vec3& w : selectionPoints()) lo = glm::min(lo, w), hi = glm::max(hi, w); + if (lo.x > hi.x) return frameAll(); + frameBox(lo, hi); + } + + bool mouseInView(const ImGuiIO& io) const { + const ImVec2 m = io.MousePos; + return m.x >= viewMin_.x && m.y >= viewMin_.y && m.x < viewMax_.x && m.y < viewMax_.y && !io.WantCaptureMouse; + } + + void orbitBy(ImVec2 d) { + if (std::abs(d.x) + std::abs(d.y) > 0.5f) leaveFlatView(); + yaw_ -= d.x * 0.3f; + pitch_ = std::clamp(pitch_ + d.y * 0.3f, -89.9f, 89.9f); + } + void panBy(ImVec2 d) { + 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))); + const glm::vec3 up = glm::cross(right, f); + const float perPixel = viewHeight() / std::max(ImGui::GetIO().DisplaySize.y, 1.0f); + target_ += (-right * d.x + up * d.y) * perPixel; + } + + void updateCamera(ImGuiIO& io) { + const bool inView = mouseInView(io); + // Orbit: right drag. Pan: middle drag (or Shift + right). Zoom: the wheel. + const bool rightDown = ImGui::IsMouseDown(ImGuiMouseButton_Right), midDown = ImGui::IsMouseDown(ImGuiMouseButton_Middle); + if (inView && ImGui::IsMouseClicked(ImGuiMouseButton_Right)) orbiting_ = !io.KeyShift; + if (inView && (ImGui::IsMouseClicked(ImGuiMouseButton_Middle) || (io.KeyShift && ImGui::IsMouseClicked(ImGuiMouseButton_Right)))) panning_ = true, orbiting_ = false; + if (!rightDown) orbiting_ = false; + if (!midDown && !rightDown) panning_ = false; + const ImVec2 d = io.MouseDelta; + if (orbiting_ && (d.x != 0 || d.y != 0)) orbitBy(d); + if (panning_ && (d.x != 0 || d.y != 0)) panBy(d); + if (inView && io.MouseWheel != 0 && drag_.active && softSel_ && mode_ == Mode::Edit) { // the soft selection's reach, not zoom + softReach_ = std::clamp(softReach_ * std::pow(1.15f, io.MouseWheel), 0.001f, 1000.0f); + findSoft(); + message_ = std::format("Soft selection reaches {:.3f} m (wheel)", softReach_), messageAt_ = now(); + } else if (inView && io.MouseWheel != 0) { + // Towards what's under the mouse: the target slides that way a little. + const float k = std::pow(0.85f, io.MouseWheel); + const Ray ray = mouseRay(io.MousePos); + if (auto p = hitPlane(ray, target_, -viewDir())) target_ += (*p - target_) * (1.0f - k); + dist_ = std::clamp(dist_ * k, 0.01f, 5000.0f); + } + const float aspect = io.DisplaySize.x / std::max(io.DisplaySize.y, 1.0f); + shift_ = glm::vec2((viewMin_.x + viewMax_.x) / std::max(io.DisplaySize.x, 1.0f) - 1.0f, (viewMin_.y + viewMax_.y) / std::max(io.DisplaySize.y, 1.0f) - 1.0f); + eye_ = target_ - viewDir() * dist_; + vp_ = viewProjection(eye_, yaw_, pitch_, fov_, aspect, shift_); + } + + // World point → screen (false behind the eye). + // The window and its view are up (commands given at the start run before the first frame). + bool viewReady() const { return ImGui::GetCurrentContext() && viewMax_.x - viewMin_.x > 1 && viewMax_.y - viewMin_.y > 1; } + bool project(const glm::vec3& p, ImVec2& out) const { + const glm::vec4 c = vp_ * glm::vec4(p, 1.0f); + if (c.w < 1e-4f) return false; + const ImVec2 size = ImGui::GetIO().DisplaySize; + out = ImVec2((c.x / c.w * 0.5f + 0.5f) * size.x, (c.y / c.w * 0.5f + 0.5f) * size.y); + return true; + } + Ray mouseRay(ImVec2 m) const { + const ImVec2 size = ImGui::GetIO().DisplaySize; + const glm::vec2 ndc(m.x / size.x * 2.0f - 1.0f, m.y / size.y * 2.0f - 1.0f); + const glm::mat4 inv = glm::inverse(vp_); + glm::vec4 a = inv * glm::vec4(ndc, 0.0f, 1.0f), b = inv * glm::vec4(ndc, 1.0f, 1.0f); + a /= a.w, b /= b.w; + return {glm::vec3(a), glm::normalize(glm::vec3(b - a))}; + } + // How many metres a pixel is, at a point. + float pixelSize(const glm::vec3& at) const { + const float d = std::max(glm::dot(at - eye_, viewDir()), 0.001f); + return 2.0f * d * std::tan(glm::radians(fov_) * 0.5f) / std::max(ImGui::GetIO().DisplaySize.y, 1.0f); + } + + // ───────────────────────────── what's shown ───────────────────────────── + + // The mesh as seen: with its rounding, smoothing and mirror — but while its shape is being + // edited, only the mirror (the rest would hide the points you're moving). + const Mesh& shownMesh(const Object& o) { + const bool editingThis = mode_ == Mode::Edit && o.id == active_; + if (!o.hasFinish() || (editingThis && !o.mirror)) return o.mesh; + Shown& s = shown_[o.id]; + const uint64_t v = versions_[o.id]; + if (!(s.version == v && v && s.mirrorOnly == editingThis)) s.mesh = finishedMesh(o, editingThis), s.version = v, s.mirrorOnly = editingThis; + return s.mesh; + } + + // ───────────────────────────── picking ───────────────────────────── + + struct Hit { + uint32_t object = 0; + int face = -1; + float t = 1e30f; + }; + // base: against its own mesh (face numbers of that), not the rounded or smoothed one shown. + Hit pickObject(const Ray& ray, uint32_t only = 0, bool base = false) { + Hit best; + for (const Object& o : scene_.objects) { + if (!o.visible || (only && o.id != only)) continue; + const Mesh& mesh = base || (mode_ == Mode::Edit && o.id == active_) ? o.mesh : shownMesh(o); + const glm::mat4 m = objectMatrix(o); + std::vector w(mesh.verts.size()); + for (size_t i = 0; i < w.size(); ++i) w[i] = glm::vec3(m * glm::vec4(mesh.verts[i], 1.0f)); + for (size_t f = 0; f < mesh.faces.size(); ++f) + for (const auto& tri : triangulate(mesh, int(f))) + if (auto t = hitTriangle(ray, w[size_t(tri[0])], w[size_t(tri[1])], w[size_t(tri[2])]); t && *t < best.t) best = {o.id, int(f), *t}; + } + return best; + } + // Can the eye see this point of the object (nothing of it in the way)? + bool visible(const Object& o, const glm::vec3& world) { + if (xray_) return true; + const glm::vec3 d = world - eye_; + const float len = glm::length(d); + const Hit h = pickObject({eye_, d / len}, o.id); + return h.t > len - std::max(len * 0.002f, 0.0005f); + } + // Which of an object's points the eye can see, worked out again only when it or the view + // changes. The triangles are sorted into a grid on the screen first: a point is tried only + // against those over its own square (what could be between it and the eye). + struct Visibility { + uint64_t version = 0; + glm::vec3 eye{1e30f}; + glm::mat4 vp{0.0f}; + std::vector point; + }; + std::map, Visibility> visibility_; // (an object's own mesh, or the one shown) + const std::vector& visiblePoints(const Object& o) { return visiblePoints(o, o.mesh); } + const std::vector& visiblePoints(const Object& o, const Mesh& mesh) { + Visibility& v = visibility_[{o.id, &mesh != &o.mesh}]; + const uint64_t version = versions_[o.id]; + if (v.version == version && glm::distance(v.eye, eye_) < 1e-6f && v.vp == vp_ && v.point.size() == mesh.verts.size()) return v.point; + v.version = version, v.eye = eye_, v.vp = vp_; + v.point.assign(mesh.verts.size(), 1); + const glm::mat4 m = objectMatrix(o); + std::vector w(mesh.verts.size()); + std::vector clip(w.size()); + for (size_t i = 0; i < w.size(); ++i) w[i] = glm::vec3(m * glm::vec4(mesh.verts[i], 1.0f)), clip[i] = vp_ * glm::vec4(w[i], 1.0f); + std::vector> tris; + for (size_t f = 0; f < mesh.faces.size(); ++f) + for (const auto& t : triangulate(mesh, int(f))) tris.push_back(t); + constexpr int G = 64; + auto cell = [](float ndc) { return std::clamp(int((ndc * 0.5f + 0.5f) * G), 0, G - 1); }; + std::vector> grid(G * G); + std::vector everywhere; // (reaching behind the eye: in every square) + for (size_t t = 0; t < tris.size(); ++t) { + glm::vec2 lo(1e30f), hi(-1e30f); + bool behind = false; + for (int k : tris[t]) { + const glm::vec4 c = clip[size_t(k)]; + if (c.w < 1e-5f) { behind = true; break; } + lo = glm::min(lo, glm::vec2(c) / c.w), hi = glm::max(hi, glm::vec2(c) / c.w); + } + if (behind) { everywhere.push_back(int(t)); continue; } + if (hi.x < -1 || hi.y < -1 || lo.x > 1 || lo.y > 1) continue; // (off the screen: covers nothing on it) + for (int y = cell(lo.y); y <= cell(hi.y); ++y) + for (int x = cell(lo.x); x <= cell(hi.x); ++x) grid[size_t(y * G + x)].push_back(int(t)); + } + for (size_t i = 0; i < w.size(); ++i) { + const glm::vec4 c = clip[i]; + if (c.w < 1e-5f) continue; // (behind the eye: not drawn anyway) + const glm::vec2 n = glm::vec2(c) / c.w; + if (std::abs(n.x) > 1 || std::abs(n.y) > 1) continue; + const glm::vec3 d = w[i] - eye_; + const float len = glm::length(d); + const Ray ray{eye_, d / len}; + const float limit = len - std::max(len * 0.002f, 0.0005f); + auto hides = [&](int t) { + const auto& tri = tris[size_t(t)]; + if (tri[0] == int(i) || tri[1] == int(i) || tri[2] == int(i)) return false; + auto hit = hitTriangle(ray, w[size_t(tri[0])], w[size_t(tri[1])], w[size_t(tri[2])]); + return hit && *hit < limit; + }; + if (std::any_of(everywhere.begin(), everywhere.end(), hides)) { v.point[i] = 0; continue; } + const auto& here = grid[size_t(cell(n.y) * G + cell(n.x))]; + if (std::any_of(here.begin(), here.end(), hides)) v.point[i] = 0; + } + return v.point; + } + bool pointSeen(const Object& o, int v) { return xray_ || visiblePoints(o)[size_t(v)]; } + + // The edge of the edited object nearest the mouse on screen (that can be seen), or (-1, -1). + std::pair nearestEdge(Object& o, ImVec2 mouse, float within) { + const glm::mat4 m = objectMatrix(o); + std::pair best{-1, -1}; + for (auto [a, b] : edges(o.mesh)) { + ImVec2 sa, sb; + if (!project(glm::vec3(m * glm::vec4(o.mesh.verts[size_t(a)], 1.0f)), sa) || !project(glm::vec3(m * glm::vec4(o.mesh.verts[size_t(b)], 1.0f)), sb)) continue; + const float d = distToSegment(mouse, sa, sb); + if (d < within && (pointSeen(o, a) || pointSeen(o, b))) within = d, best = {a, b}; + } + return best; + } + + void clickSelect(const ImGuiIO& io) { + const Ray ray = mouseRay(io.MousePos); + bool add = io.KeyShift, remove = io.KeyCtrl; + if (Object* o = editing()) { + // Shapes: Shift picks the whole loop (Shift+Ctrl on an edge: the ring across it), Ctrl + // adds or takes away one. + if (io.KeyShift) return loopSelect(io, io.KeyCtrl); + add = io.KeyCtrl, remove = false; + const glm::mat4 m = objectMatrix(*o); + auto toggle = [&](auto& set, auto key) { + if (remove) set.erase(key); + else if (add && set.count(key)) set.erase(key); + else set.insert(key); + }; + if (!add && !remove) clearElements(); + if (elem_ == Elem::Vertex) { + int best = -1; + float bestD = 14.0f; + for (size_t v = 0; v < o->mesh.verts.size(); ++v) { + ImVec2 s; + if (!project(glm::vec3(m * glm::vec4(o->mesh.verts[v], 1.0f)), s)) continue; + const float d = std::hypot(s.x - io.MousePos.x, s.y - io.MousePos.y); + if (d < bestD && pointSeen(*o, int(v))) bestD = d, best = int(v); + } + if (best >= 0) toggle(selVerts_, best); + } else if (elem_ == Elem::Edge) { + const auto e = nearestEdge(*o, io.MousePos, 10.0f); + if (e.first >= 0) toggle(selEdges_, e); + } else { + const Hit h = pickObject(ray, o->id); + if (h.face >= 0) toggle(selFaces_, h.face); + } + return; + } + const Hit h = pickObject(ray); + if (!add && !remove) selected_.clear(); + if (h.object) { + if (remove || (add && selected_.count(h.object))) selected_.erase(h.object); + else selected_.insert(h.object), active_ = h.object; + activeRef_ = 0; + } + } + + // A whole loop: of edges (points), or a ring of faces, through the edge under the mouse. + void loopSelect(const ImGuiIO& io, bool ring = false) { + Object* o = editing(); + if (!o) return; + if (!io.KeyShift) clearElements(); + if (elem_ == Elem::Face) { + const Hit h = pickObject(mouseRay(io.MousePos), o->id); + if (h.face < 0) return; + // The face's edge nearest the mouse: the ring runs across it. + const auto& fv = o->mesh.faces[size_t(h.face)]; + const glm::mat4 m = objectMatrix(*o); + std::pair best{-1, -1}; + float bestD = 1e30f; + for (size_t i = 0; i < fv.size(); ++i) { + ImVec2 sa, sb; + if (project(glm::vec3(m * glm::vec4(o->mesh.verts[size_t(fv[i])], 1.0f)), sa) && + project(glm::vec3(m * glm::vec4(o->mesh.verts[size_t(fv[(i + 1) % fv.size()])], 1.0f)), sb)) + if (const float d = distToSegment(io.MousePos, sa, sb); d < bestD) bestD = d, best = {fv[i], fv[(i + 1) % fv.size()]}; + } + if (best.first < 0) return; + const auto ring = faceRing(o->mesh, best); + selFaces_.insert(ring.begin(), ring.end()); + if (ring.empty()) selFaces_.insert(h.face); + return; + } + const auto e = nearestEdge(*o, io.MousePos, 14.0f); + if (e.first < 0) return; + const auto loop = ring ? edgeRing(o->mesh, e) : edgeLoop(o->mesh, e); + if (ring) say(std::format("A ring of {} edges", loop.size())); + if (elem_ == Elem::Edge) selEdges_.insert(loop.begin(), loop.end()); + else + for (auto [a, b] : loop) selVerts_.insert(a), selVerts_.insert(b); + } + + // Selects what's on screen inside a shape (a box, a lasso, the brush). + void regionSelect(const std::function& inside, bool add, bool remove, bool clearFirst) { + auto in = [&](const glm::vec3& w) { + ImVec2 s; + return project(w, s) && inside(s); + }; + if (Object* o = editing()) { + if (clearFirst) clearElements(); + const glm::mat4 m = objectMatrix(*o); + std::vector hit(o->mesh.verts.size()); + for (size_t v = 0; v < hit.size(); ++v) hit[v] = in(glm::vec3(m * glm::vec4(o->mesh.verts[v], 1.0f))) && pointSeen(*o, int(v)); + auto apply = [&](auto& set, auto key, bool yes) { + if (!yes) return; + if (remove) set.erase(key); + else set.insert(key); + }; + if (elem_ == Elem::Vertex) + for (size_t v = 0; v < hit.size(); ++v) apply(selVerts_, int(v), bool(hit[v])); + else if (elem_ == Elem::Edge) + for (auto e : edges(o->mesh)) { + const glm::vec3 mid(m * glm::vec4((o->mesh.verts[size_t(e.first)] + o->mesh.verts[size_t(e.second)]) * 0.5f, 1.0f)); + apply(selEdges_, e, (hit[size_t(e.first)] && hit[size_t(e.second)]) || (in(mid) && pointSeen(*o, e.first) && pointSeen(*o, e.second))); + } + else + for (size_t f = 0; f < o->mesh.faces.size(); ++f) { + const glm::vec3 c(m * glm::vec4(faceCenter(o->mesh, int(f)), 1.0f)); + if (!in(c)) continue; + const glm::vec3 n = glm::transpose(glm::inverse(glm::mat3(m))) * faceNormal(o->mesh, int(f)); + apply(selFaces_, int(f), xray_ || (glm::dot(n, eye_ - c) > 0 && visible(*o, c))); + } + return; + } + if (clearFirst) selected_.clear(); + (void)add; + for (const Object& o : scene_.objects) { + if (!o.visible) continue; + const glm::vec3 c(objectMatrix(o) * glm::vec4((boundsMin(o.mesh) + boundsMax(o.mesh)) * 0.5f, 1.0f)); + if (!in(c)) continue; + if (remove) selected_.erase(o.id); + else selected_.insert(o.id), active_ = o.id; + } + } + + // ───────────────────────────── the gizmo ───────────────────────────── + + bool hasSelection() { + if (mode_ == Mode::Edit) return !selectedPoints().empty(); + return std::any_of(selected_.begin(), selected_.end(), [&](uint32_t id) { const Object* o = find(id); return o && !o->locked; }); + } + // Every point of the selection, in the world. + std::vector selectionPoints() { + std::vector out; + if (Object* o = editing()) { + const glm::mat4 m = objectMatrix(*o); + for (int v : selectedPoints()) out.push_back(glm::vec3(m * glm::vec4(o->mesh.verts[size_t(v)], 1.0f))); + return out; + } + for (const Object& o : scene_.objects) { + if (!selected_.count(o.id) || o.locked) continue; + const glm::mat4 m = objectMatrix(o); + for (const glm::vec3& v : shownMesh(o).verts) out.push_back(glm::vec3(m * glm::vec4(v, 1.0f))); + } + return out; + } + glm::vec3 pivotOfSelection() { + glm::vec3 c(0.0f); + int n = 0; + if (Object* o = editing()) { + const glm::mat4 m = objectMatrix(*o); + for (int v : selectedPoints()) c += glm::vec3(m * glm::vec4(o->mesh.verts[size_t(v)], 1.0f)), ++n; + } else { + for (const Object& o : scene_.objects) + if (selected_.count(o.id)) c += o.position, ++n; + } + return n ? c / float(n) : glm::vec3(0.0f); + } + // The selection's box, in a gizmo's axes. + std::pair selectionBox(const Axes& a) { + glm::vec3 lo(1e30f), hi(-1e30f); + for (const glm::vec3& p : selectionPoints()) { + const glm::vec3 c = inFrame(a, p); + lo = glm::min(lo, c), hi = glm::max(hi, c); + } + if (lo.x > hi.x) lo = hi = inFrame(a, pivotOfSelection()); + return {lo, hi}; + } + glm::vec3 selectionMiddle(const Axes& a) { + const auto [lo, hi] = selectionBox(a); + return fromFrame(a, (lo + hi) * 0.5f); + } + Orient& orient() { return mode_ == Mode::Edit ? orientEdit_ : orientObject_; } + // The gizmo's axes: the object's own (turning with it), the world's, or (shape editing) the + // way the selected faces face — so "up" on a side face is out of that face. + Axes gizmoAxes() { + Orient o = orient(); + if (mode_ == Mode::Object && tool_ == Tool::Scale) o = Orient::Local; // (sizes are along an object's own axes) + const Object* a = find(active_); + if (!a && !selected_.empty()) a = find(*selected_.begin()); + const glm::quat q = a && o != Orient::World ? a->rotation : glm::quat(1, 0, 0, 0); + Axes local{q * glm::vec3(1, 0, 0), q * glm::vec3(0, 1, 0), q * glm::vec3(0, 0, 1)}; + if (o != Orient::Normal || mode_ != Mode::Edit) return local; + Object* e = editing(); + if (!e) return local; + const glm::mat4 m = objectMatrix(*e); + const glm::mat3 nm = glm::transpose(glm::inverse(glm::mat3(m))); + const std::set pts = selectedPoints(); + glm::vec3 n(0.0f), along(0.0f); + for (size_t f = 0; f < e->mesh.faces.size(); ++f) { + const auto& fv = e->mesh.faces[f]; + const bool whole = elem_ == Elem::Face ? selFaces_.count(int(f)) > 0 : std::any_of(fv.begin(), fv.end(), [&](int v) { return pts.count(v); }); + if (!whole) continue; + n += nm * faceNormal(e->mesh, int(f)) * std::max(faceArea(e->mesh, int(f)), 1e-6f); + if (glm::length(along) < 1e-9f) // the first face's longest edge: "across" it + for (size_t i = 0; i < fv.size(); ++i) { + const glm::vec3 d = glm::mat3(m) * (e->mesh.verts[size_t(fv[(i + 1) % fv.size()])] - e->mesh.verts[size_t(fv[i])]); + if (glm::length(d) > glm::length(along)) along = d; + } + } + if (elem_ == Elem::Edge && !selEdges_.empty()) { + const auto [a0, b0] = *selEdges_.begin(); + along = glm::mat3(m) * (e->mesh.verts[size_t(b0)] - e->mesh.verts[size_t(a0)]); + } + if (glm::length(n) < 1e-9f) return local; + const glm::vec3 z = glm::normalize(n); + glm::vec3 x = along - z * glm::dot(along, z); + if (glm::length(x) < 1e-6f) x = glm::cross(std::abs(z.z) < 0.9f ? glm::vec3(0, 0, 1) : glm::vec3(1, 0, 0), z); + x = glm::normalize(x); + return {x, glm::cross(z, x), z}; + } + // The extrude / inset handle: the selected faces' middle, their normal. + bool faceHandle(glm::vec3& at, glm::vec3& normal) { + Object* o = editing(); + if (!o || elem_ != Elem::Face || selFaces_.empty()) return false; + const glm::mat4 m = objectMatrix(*o); + glm::vec3 c(0.0f), n(0.0f); + for (int f : selFaces_) { + if (f >= int(o->mesh.faces.size())) return false; + c += glm::vec3(m * glm::vec4(faceCenter(o->mesh, f), 1.0f)); + n += glm::normalize(glm::transpose(glm::inverse(glm::mat3(m))) * faceNormal(o->mesh, f)); + } + at = c / float(selFaces_.size()); + normal = glm::length(n) > 1e-6f ? glm::normalize(n) : glm::vec3(0, 0, 1); + return true; + } + // The round-edges handle: off to the right of the selection's middle. + bool bevelHandle(ImVec2& centre, ImVec2& handle) { + if (!editing() || edgesToRound().empty()) return false; + if (!project(drag_.active ? drag_.pivot : pivotOfSelection(), centre)) return false; + handle = ImVec2(centre.x + 80.0f + (drag_.active ? ImGui::GetIO().MousePos.x - drag_.startMouse.x : 0.0f), centre.y); + return true; + } + // The scale box's handles: 0-2 the far side along each axis, 4-6 the near side, 3 the middle. + // (Pushed out a little where the box is flat, so they don't sit on top of each other.) + glm::vec3 scaleHandle(const Axes& a, glm::vec3 lo, glm::vec3 hi, int h) { + const glm::vec3 c = (lo + hi) * 0.5f; + if (h == 3) return fromFrame(a, c); + const int k = h % 4 == 3 ? 0 : (h < 3 ? h : h - 4); + glm::vec3 q = c; + const float minHalf = pixelSize(fromFrame(a, c)) * 22.0f; + const float half = std::max((hi[k] - lo[k]) * 0.5f, minHalf); + q[k] = c[k] + (h < 3 ? half : -half); + return fromFrame(a, q); + } + + // Which handle is under the mouse (-1 none). + int hoverHandle(ImVec2 mouse) { + if (drag_.active) return drag_.handle; + if (tool_ == Tool::Extrude || tool_ == Tool::Inset) { + glm::vec3 at, n; + if (!faceHandle(at, n)) return -1; + ImVec2 a, b; + if (!project(at, a) || !project(at + n * pixelSize(at) * 90.0f, b)) return -1; + return distToSegment(mouse, a, b) < 10.0f ? 0 : -1; + } + if (tool_ == Tool::Bevel) { + ImVec2 c, h; + return bevelHandle(c, h) && std::hypot(mouse.x - h.x, mouse.y - h.y) < 12.0f ? 0 : -1; + } + if (tool_ != Tool::Move && tool_ != Tool::Rotate && tool_ != Tool::Scale) return -1; + if (!hasSelection()) return -1; + const Axes axes = gizmoAxes(); + if (tool_ == Tool::Scale) { + const auto [lo, hi] = selectionBox(axes); + for (int h : {3, 0, 1, 2, 4, 5, 6}) { + ImVec2 s; + if (project(scaleHandle(axes, lo, hi, h), s) && std::abs(mouse.x - s.x) < 9 && std::abs(mouse.y - s.y) < 9) return h; + } + return -1; + } + const glm::vec3 p = tool_ == Tool::Rotate ? rotatePivot(axes) : selectionMiddle(axes); + ImVec2 c; + if (!project(p, c)) return -1; + const float len = pixelSize(p) * 90.0f; + if (tool_ == Tool::Rotate) { + int best = -1; + float bestD = 9.0f; + for (int k = 0; k < 3; ++k) { + const glm::vec3 u = glm::normalize(glm::cross(axes[size_t(k)], std::abs(axes[size_t(k)].z) < 0.9f ? glm::vec3(0, 0, 1) : glm::vec3(1, 0, 0))); + const glm::vec3 v = glm::cross(axes[size_t(k)], u); + ImVec2 prev; + bool havePrev = false; + for (int i = 0; i <= 48; ++i) { + const float a = 2 * kPi * float(i) / 48.0f; + ImVec2 s; + const glm::vec3 w = p + (u * std::cos(a) + v * std::sin(a)) * len; + if (!project(w, s)) { + havePrev = false; + continue; + } + if (havePrev && glm::dot(w - p, eye_ - p) > -0.2f * len * glm::length(eye_ - p)) // (the front half only) + if (const float d = distToSegment(mouse, prev, s); d < bestD) bestD = d, best = k; + prev = s, havePrev = true; + } + } + const float r = std::hypot(mouse.x - c.x, mouse.y - c.y); + if (best < 0 && r > 95.0f && r < 110.0f) best = 3; // the outer ring: about the view + return best; + } + if (std::abs(mouse.x - c.x) < 9 && std::abs(mouse.y - c.y) < 9) return 3; + for (int k = 0; k < 3; ++k) { + ImVec2 e; + if (project(p + axes[size_t(k)] * len, e) && distToSegment(mouse, c, e) < 9.0f) return k; + } + return -1; + } + + glm::vec3 originPoint() { + if (Object* o = editing()) return o->position; + glm::vec3 c(0.0f); + int n = 0; + for (const Object& o : scene_.objects) + if (selected_.count(o.id)) c += o.position, ++n; + return n ? c / float(n) : c; + } + glm::vec3 rotatePivot(const Axes& a) { return rotatePivot_ == Pivot::Origin ? originPoint() : selectionMiddle(a); } + + void startDrag(const ImGuiIO& io, int handle) { + drag_ = {}; + drag_.active = true, drag_.handle = handle, drag_.tool = tool_; + drag_.startMouse = io.MousePos; + drag_.before = scene_; + pushUndo(); + if (softSel_ && (tool_ == Tool::Move || tool_ == Tool::Rotate || tool_ == Tool::Scale)) findSoft(); + const Ray ray = mouseRay(io.MousePos); + if (tool_ == Tool::Extrude || tool_ == Tool::Inset) { + glm::vec3 at, n; + faceHandle(at, n); + drag_.pivot = at, drag_.startT = closestOnAxis(ray, at, n); + drag_.startHit = n; + return; + } + if (tool_ == Tool::Bevel) { + drag_.pivot = pivotOfSelection(); + drag_.bevelEdges = edgesToRound(); + return; + } + drag_.axes = gizmoAxes(); + const Axes& a = drag_.axes; + if (tool_ == Tool::Scale) { + const auto [lo, hi] = selectionBox(a); + const glm::vec3 c = (lo + hi) * 0.5f; + drag_.handlePoint = scaleHandle(a, lo, hi, handle); + const int k = handle < 3 ? handle : handle - 4; + glm::vec3 q = c; + switch (scalePivot_) { + case Pivot::Opposite: + if (handle == 3) q[2] = lo[2]; + else q[k] = handle < 3 ? lo[k] : hi[k]; + drag_.anchor = fromFrame(a, q); + break; + case Pivot::Bottom: q[2] = lo[2], drag_.anchor = fromFrame(a, q); break; + case Pivot::Origin: drag_.anchor = originPoint(); break; + default: drag_.anchor = fromFrame(a, c); break; + } + drag_.pivot = fromFrame(a, c); + if (handle != 3) drag_.startDist = glm::dot(drag_.handlePoint - drag_.anchor, a[size_t(k)]), drag_.startMid = glm::dot(drag_.handlePoint - drag_.pivot, a[size_t(k)]); + return; + } + drag_.pivot = tool_ == Tool::Rotate ? rotatePivot(a) : selectionMiddle(a); + if (handle < 3) drag_.startT = closestOnAxis(ray, drag_.pivot, a[size_t(handle)]); + if (auto hit = hitPlane(ray, drag_.pivot, -viewDir())) drag_.startHit = *hit; + } + // The points within reach of the selection (in the world: the object's size counts), each with + // how much it follows: a grid of the selected points, so a big mesh stays quick. + void findSoft() { + drag_.soft.clear(); + const Object* o = editing(); + if (!o) return; + const std::set pts = selectedPoints(); + if (pts.empty()) return; + const glm::mat4 m = objectMatrix(*o); + const float r = std::max(softReach_, 1e-5f); + auto cell = [&](const glm::vec3& p) { return glm::ivec3(glm::floor(p / r)); }; + auto keyOf = [](glm::ivec3 c) { return (int64_t(c.x & 0x1FFFFF) << 42) | (int64_t(c.y & 0x1FFFFF) << 21) | int64_t(c.z & 0x1FFFFF); }; + std::unordered_map> grid; + for (int v : pts) { + const glm::vec3 w(m * glm::vec4(o->mesh.verts[size_t(v)], 1.0f)); + grid[keyOf(cell(w))].push_back(w); + } + for (size_t v = 0; v < o->mesh.verts.size(); ++v) { + if (pts.count(int(v))) continue; + const glm::vec3 w(m * glm::vec4(o->mesh.verts[v], 1.0f)); + const glm::ivec3 c = cell(w); + float best = r; + 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(keyOf(c + glm::ivec3(dx, dy, dz))); it != grid.end()) + for (const glm::vec3& q : it->second) best = std::min(best, glm::distance(w, q)); + if (best < r) drag_.soft.push_back({int(v), sculptFalloff(best, r)}); + } + } + void cancelDrag() { + if (!drag_.active) return; + scene_ = drag_.before; dropUndo(); - return say("Nothing to cut there: loop cuts go across four-sided faces"); + touchAll(); + drag_.active = false; } - changedShape(o); - // The new edges are selected, ready to move (along the faces, with the gizmo). - const std::set pts(made.begin(), made.end()); - elem_ = Elem::Edge; - for (auto [a, b] : edges(o.mesh)) - if (pts.count(a) && pts.count(b)) selEdges_.insert({a, b}); - say(std::format("Cut {} time{} round. W moves the new edges.", cuts_, cuts_ == 1 ? "" : "s")); -} -auto App::makeFaceNow() -> void { - Object* o = editing(); - if (!o) return; - const std::set pts = selectedPoints(); - pushUndo(); - const glm::vec3 towards = glm::inverse(glm::mat3(objectMatrix(*o))) * (eye_ - pivotOfSelection()); - if (!makeFace(o->mesh, std::vector(pts.begin(), pts.end()), towards)) { - dropUndo(); - return say("Pick 3 or more points (not in a line) to make a face"); + void updateDrag(const ImGuiIO& io) { + const Ray ray = mouseRay(io.MousePos); + const bool snapping = snap_ != io.KeyCtrl; // Ctrl flips snapping while held + scene_ = drag_.before; // every frame from where it started: no drift + const Axes& axes = drag_.axes; + const glm::vec3 p = drag_.pivot; + glm::mat4 T(1.0f); // what happens to the selection, in the world + if (drag_.tool == Tool::Extrude || drag_.tool == Tool::Inset) { + Object* o = editing(); + if (!o) return; + const glm::vec3 n = drag_.startHit; + float d = closestOnAxis(ray, p, n) - drag_.startT; + if (drag_.tool == Tool::Extrude) { + if (snapping) d = snapTo(d, snapMove_); + // (the pull in the object's own size, so it follows the mouse whatever its scale) + const float scale = std::cbrt(std::abs(glm::determinant(glm::mat3(objectMatrix(*o))))); + extrudeFaces(o->mesh, selFaces_, d / std::max(scale, 1e-6f)); + extrudeAmount_ = d; + message_ = std::format("Extrude {:.3f} m", d), messageAt_ = now(); + } else { + float t = std::clamp(-d / (pixelSize(p) * 120.0f), 0.0f, 0.99f); + if (snapping) t = snapTo(t, 0.05f); + insetFaces(o->mesh, selFaces_, t); + insetAmount_ = t; + message_ = std::format("Inset {:.0f}%", t * 100), messageAt_ = now(); + } + changedShape(*o, true); + return; + } + if (drag_.tool == Tool::Bevel) { + Object* o = editing(); + if (!o) return; + float w = std::max(0.0f, io.MousePos.x - drag_.startMouse.x) * pixelSize(p) * 0.5f; + if (snapping) w = snapTo(w, 0.005f); + if (w > 0) bevelEdges(o->mesh, drag_.bevelEdges, w, bevelSteps_); + bevelWidth_ = w; + handMade(*o); + touched(o->id); + message_ = std::format("Round edges {:.3f} m, {} step{}", w, bevelSteps_, bevelSteps_ == 1 ? "" : "s"), messageAt_ = now(); + return; + } + glm::vec3 objectScale(1.0f); + float even = 1.0f; + if (drag_.tool == Tool::Move) { + glm::vec3 delta(0.0f); + if (drag_.handle < 3) { + float t = closestOnAxis(ray, p, axes[size_t(drag_.handle)]) - drag_.startT; + if (snapping) t = snapTo(t, snapMove_); + delta = axes[size_t(drag_.handle)] * t; + } else if (auto hit = hitPlane(ray, p, -viewDir())) { + delta = *hit - drag_.startHit; + if (snapping) delta = glm::vec3(snapTo(delta.x, snapMove_), snapTo(delta.y, snapMove_), snapTo(delta.z, snapMove_)); + } + T = glm::translate(glm::mat4(1.0f), delta); + message_ = std::format("Move {:.3f}, {:.3f}, {:.3f} m", delta.x, delta.y, delta.z), messageAt_ = now(); + } else if (drag_.tool == Tool::Rotate) { + ImVec2 c; + if (!project(p, c)) return; + const float a0 = std::atan2(drag_.startMouse.y - c.y, drag_.startMouse.x - c.x), a1 = std::atan2(io.MousePos.y - c.y, io.MousePos.x - c.x); + float deg = glm::degrees(std::remainder(a1 - a0, 2 * kPi)); + const glm::vec3 axis = drag_.handle < 3 ? axes[size_t(drag_.handle)] : -viewDir(); + if (glm::dot(axis, viewDir()) < 0) deg = -deg; // (turning the way the mouse goes round, as seen) + if (snapping) deg = snapTo(deg, snapAngle_); + T = glm::translate(glm::mat4(1.0f), p) * glm::rotate(glm::mat4(1.0f), glm::radians(deg), axis) * glm::translate(glm::mat4(1.0f), -p); + message_ = std::format("Rotate {:.1f} degrees", deg), messageAt_ = now(); + } else if (drag_.tool == Tool::Scale) { + glm::vec3 s(1.0f); + // Shift (or Alt) held: from both sides, the middle staying put. + const bool both = (io.KeyShift || io.KeyAlt) && drag_.handle != 3; + if (drag_.handle != 3) { + const int k = drag_.handle < 3 ? drag_.handle : drag_.handle - 4; + const float from = both ? drag_.startMid : drag_.startDist; + float dist = from + closestOnAxis(ray, drag_.handlePoint, axes[size_t(k)]); + if (snapping) dist = both ? snapTo(dist * 2, snapMove_) / 2 : snapTo(dist, snapMove_); + const float f = std::abs(from) > 1e-6f ? dist / from : 1.0f; + s[k] = f; + message_ = std::format("Scale {:.2f} ({:.3f} m){}", f, std::abs(dist) * (both ? 2 : 1), both ? " from both sides" : " (Shift: from both sides)"), + messageAt_ = now(); + } else { + even = std::max(0.01f, 1.0f + ((io.MousePos.x - drag_.startMouse.x) - (io.MousePos.y - drag_.startMouse.y)) / 200.0f); + if (snapping) even = std::max(snapScale_, snapTo(even, snapScale_)); + s = glm::vec3(even); + message_ = std::format("Scale {:.2f} (drag right or up to grow)", even), messageAt_ = now(); + } + const glm::mat3 basis(axes[0], axes[1], axes[2]); + const glm::vec3 a = both ? drag_.pivot : drag_.anchor; + T = glm::translate(glm::mat4(1.0f), a) * glm::mat4(basis) * glm::scale(glm::mat4(1.0f), s) * glm::mat4(glm::transpose(basis)) * glm::translate(glm::mat4(1.0f), -a); + objectScale = s; + } + if (Object* o = editing()) { + const glm::mat4 m = objectMatrix(*o), local = glm::inverse(m) * T * m; + for (int v : selectedPoints()) o->mesh.verts[size_t(v)] = glm::vec3(local * glm::vec4(o->mesh.verts[size_t(v)], 1.0f)); + for (const auto& [v, w] : drag_.soft) { // (part of the way, by how near) + glm::vec3& p = o->mesh.verts[size_t(v)]; + p = glm::mix(p, glm::vec3(local * glm::vec4(p, 1.0f)), w); + } + handMade(*o); + touched(o->id); + return; + } + for (Object& o : scene_.objects) { + if (!selected_.count(o.id) || o.locked) continue; + o.position = glm::vec3(T * glm::vec4(o.position, 1.0f)); + if (drag_.tool == Tool::Rotate) o.rotation = glm::normalize(glm::quat_cast(glm::mat3(T)) * o.rotation); + if (drag_.tool == Tool::Scale) { + // On its own axes: the gizmo's where they're the same, else how much each of its + // axes lies along each of the gizmo's. + for (int j = 0; j < 3; ++j) { + const glm::vec3 own = o.rotation * glm::vec3(j == 0, j == 1, j == 2); + float f = 1.0f; + for (int k = 0; k < 3; ++k) f += (objectScale[k] - 1.0f) * glm::dot(own, axes[size_t(k)]) * glm::dot(own, axes[size_t(k)]); + o.scale[j] *= f; + } + } + touched(o.id); + } } - changedShape(*o); - selFaces_ = {int(o->mesh.faces.size()) - 1}; - elem_ = Elem::Face; -} -auto App::mergeNow() -> void { - Object* o = editing(); - if (!o) return; - const std::set pts = selectedPoints(); - if (pts.size() < 2) return say("Select 2 or more points to merge"); - pushUndo(); - mergeToCenter(o->mesh, pts); - changedShape(*o); -} + // ───────────────────────────── drawing a shape ───────────────────────────── -auto App::smoothPointsNow() -> void { - Object* o = editing(); - const std::set pts = selectedPoints(); - if (!o || pts.empty()) return say("Select points (or faces, edges) to smooth"); - pushUndo(); - smoothPoints(o->mesh, pts, 0.5f, 2); - changedShape(*o, true); - say("Smoothed (again for more)"); -} - -auto App::startPush() -> void { - Object* o = editing(); - if (!o || selectedPoints().empty()) return say("Select points (or faces) to push out or pull in"); - pushUndo(); - pushPts_ = selectedPoints(), pushFrom_ = o->mesh, pushObject_ = o->id, pushBy_ = 0.0f, showPush_ = true; -} - -auto App::pushWindow() -> void { - if (!showPush_) return; - Object* o = find(pushObject_); - if (!o || mode_ != Mode::Edit) return void(showPush_ = false); - ImGui::SetNextWindowPos(ImGui::GetMainViewport()->GetCenter(), ImGuiCond_Appearing, ImVec2(0.5f, 0.5f)); - bool open = true; - if (!ImGui::Begin("Push out, pull in", &open, ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoDocking)) return ImGui::End(); - const float w = 260; - float mm = pushBy_ * 1000; - ImGui::SetNextItemWidth(w); - if (ImGui::IsWindowAppearing()) ImGui::SetKeyboardFocusHere(); - if (ImGui::DragFloat("##by", &mm, 0.5f, -1e5f, 1e5f, "%.1f mm (- pulls in)")) { - pushBy_ = mm / 1000; - o->mesh = pushFrom_; - pushPoints(o->mesh, pushPts_, pushBy_); - changedShape(*o, true); + // The plane shapes are drawn on: the one the view looks straight at, or the ground. + void workPlane(glm::vec3& point, glm::vec3& normal) const { + 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. + void drawPlane(glm::vec3& p, glm::vec3& n) { + if (drawOnto_ && find(drawOnto_)) p = drawOnP_, n = drawOnN_; + else workPlane(p, n); + } + std::optional drawPoint(const ImGuiIO& io) { + 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); + } + return hit; + } + void finishShape() { + 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 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 : "")); } - ImGui::TextWrapped("Each point along the surface's normal there: fatter or thinner, the same shape."); - pushAccent(); - const bool done = ImGui::Button("Done", ImVec2(w, 0)) || ImGui::IsKeyPressed(ImGuiKey_Enter, false); - popAccent(); - ImGui::End(); - if (done) showPush_ = false; - if (!open) o->mesh = pushFrom_, changedShape(*o, true), showPush_ = false; // (closed: as it was) -} -auto App::bridgeNow() -> void { - Object* o = editing(); - if (!o || selFaces_.size() != 2) return say("Select two faces to bridge (Faces, 3; Join two objects first if they're apart)"); - const int a = *selFaces_.begin(), b = *selFaces_.rbegin(); - if (o->mesh.faces[size_t(a)].size() != o->mesh.faces[size_t(b)].size()) - return say(std::format("Those faces have {} and {} corners: bridging needs as many on each", o->mesh.faces[size_t(a)].size(), o->mesh.faces[size_t(b)].size())); - pushUndo(); - const std::vector made = bridgeFaces(o->mesh, a, b); - if (made.empty()) return takeBack(), say("Couldn't bridge those (they share a point)"); - changedShape(*o); - selFaces_.insert(made.begin(), made.end()); - say(std::format("Bridged: {} faces between them", made.size())); -} + // ───────────────────────────── the viewport's mouse ───────────────────────────── -auto App::flipNow() -> void { - Object* o = editing(); - if (!o || selFaces_.empty()) return say("Select faces to flip"); - pushUndo(); - flipFaces(o->mesh, selFaces_); - changedShape(*o, true); -} + void viewport(ImGuiIO& io) { + cutHover_ = {-1, -1}; + if (tab_ == Tab::Paint) return paintViewport(io); + if (tab_ == Tab::Layout) return layoutViewport(io); + if (tab_ == Tab::Print && printTool_ != PrintTool::None) return printViewport(io); + 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 (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 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) + } + } + void addDrawPoint(const ImGuiIO& io) { + 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. + void paintViewport(ImGuiIO& io) { + if (sculpting_) return sculptViewport(io); + 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); + } -// Rotation and scale baked into the points (so the object's turn is none and its size is 1). -auto App::applyTransform(Object& o) -> void { - pushUndo(); - transform(o.mesh, glm::mat4_cast(o.rotation) * glm::scale(glm::mat4(1.0f), o.scale)); - o.rotation = glm::quat(1, 0, 0, 0), o.scale = glm::vec3(1.0f); - handMade(o); - touched(o.id); -} + // The brush: whatever passes under it is selected (or, with Ctrl, let go). + void paint(const ImGuiIO& io) { + 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); + } -auto App::applyFinish(Object& o) -> void { - pushUndo(); - o.mesh = finishedMesh(o); - o.mirror = 0, o.round = 0, o.smoothLevels = 0; - handMade(o); - touched(o.id); -} + // ───────────────────────────── keys ───────────────────────────── -auto App::originToMiddle(Object& o) -> void { - pushUndo(); - const glm::vec3 c = (boundsMin(o.mesh) + boundsMax(o.mesh)) * 0.5f; - for (glm::vec3& v : o.mesh.verts) v -= c; - o.position += glm::vec3(objectMatrix(o) * glm::vec4(c, 0.0f)); - handMade(o); - touched(o.id); -} - -auto App::originToBottom(Object& o) -> void { - pushUndo(); - const glm::vec3 c((boundsMin(o.mesh).x + boundsMax(o.mesh).x) * 0.5f, (boundsMin(o.mesh).y + boundsMax(o.mesh).y) * 0.5f, boundsMin(o.mesh).z); - for (glm::vec3& v : o.mesh.verts) v -= c; - o.position += glm::vec3(objectMatrix(o) * glm::vec4(c, 0.0f)); - handMade(o); - touched(o.id); -} - -auto App::mirror(Object& o, int axis) -> void { - pushUndo(); - glm::vec3 s(1.0f); - s[axis] = -1.0f; - transform(o.mesh, glm::scale(glm::mat4(1.0f), s)); - handMade(o); - touched(o.id); -} - -// ───────────────────────────── tabs ───────────────────────────── - -auto App::setTab(Tab t) -> void { - if (t == tab_) return; - if (drag_.active) cancelDrag(); - lockLayer(); - endStroke(), endSculpt(); - knifePts_.clear(), drawPts_.clear(); - if (t != Tab::Model) leaveEdit(); - if ((t == Tab::Render || t == Tab::Print) && tool_ != Tool::Select && tool_ != Tool::Move && tool_ != Tool::Rotate && tool_ != Tool::Scale) tool_ = Tool::Move; - if ((t == Tab::Paint || t == Tab::Layout) && tab_ != Tab::Paint && tab_ != Tab::Layout) picZoom_ = 0; // (fit the picture again) - lay_.drag = 0, printTool_ = PrintTool::None, measure_.clear(); - tab_ = t; -} - -// ───────────────────────────── keys ───────────────────────────── - -auto App::shortcuts(ImGuiIO& io) -> void { - if (io.WantTextInput || slide_.active || drag_.active || looking_) return; // (a slide, a drag: their own keys; turning the camera: WASD, Q and E move it) - if (tool_ == Tool::Draw && !drawPts_.empty() && sketchKeys(io)) return; - const bool ctrl = io.KeyCtrl, shift = io.KeyShift, alt = io.KeyAlt; - auto key = [](ImGuiKey k) { return ImGui::IsKeyPressed(k, false); }; - if (key(ImGuiKey_F3) || (ctrl && key(ImGuiKey_Space))) return openSearch(); - if (!settingKey_.empty()) return; // (Keys and mouse is waiting for a key) - if (const auto chord = fry::pressedChord()) - for (const auto& [id, c] : myKeys_) - if (c == *chord) return void(runCommand(id)); - if (key(ImGuiKey_Space) && !ctrl && !alt && mouseInView(io) && (tab_ == Tab::Model || tab_ == Tab::Render || tab_ == Tab::Print)) - return void((pie_ = shift ? 2 : 1, pieAt_ = io.MousePos)); - if (key(ImGuiKey_F5)) return runScriptNow(); - if (ctrl && key(ImGuiKey_1)) setTab(Tab::Model); - if (ctrl && key(ImGuiKey_2)) setTab(Tab::Paint); - if (ctrl && key(ImGuiKey_3)) setTab(Tab::Layout); - if (tab_ == Tab::Paint && !ctrl && !alt && key(ImGuiKey_Tab)) return (void)(sculpting_ = !sculpting_, endStroke(), endSculpt()); - if (tab_ == Tab::Paint && sculpting_ && !ctrl && !alt) - for (int i = 0; i < int(std::size(kSculptTools)); ++i) - if (key(ImGuiKey(int(ImGuiKey_1) + i))) sculptBrush_ = kSculptTools[i].brush; - if (tab_ == Tab::Paint && sculpting_ && !ctrl && !alt && key(ImGuiKey_X)) sculptMirror_ = !sculptMirror_; - if (tab_ == Tab::Paint && !ctrl && !alt) { - if (key(ImGuiKey_B)) paintTool_ = PaintTool::Brush; - if (key(ImGuiKey_E)) paintTool_ = PaintTool::Eraser; - if (key(ImGuiKey_G)) paintTool_ = PaintTool::Fill; - if (key(ImGuiKey_I)) paintTool_ = PaintTool::Picker; - float& size = sculpting_ ? sculptPx_ : brushPx_; - if (ImGui::IsKeyPressed(ImGuiKey_LeftBracket)) size = std::max(1.0f, size / 1.2f); - if (ImGui::IsKeyPressed(ImGuiKey_RightBracket)) size = std::min(500.0f, size * 1.2f); - if (key(ImGuiKey_Enter) || key(ImGuiKey_KeypadEnter)) lockLayer(); - if (key(ImGuiKey_Delete) && editLayer()) deleteLayer(*find(ledit_.object), ledit_.index); + void shortcuts(ImGuiIO& io) { + if (io.WantTextInput) return; + const bool ctrl = io.KeyCtrl, shift = io.KeyShift, alt = io.KeyAlt; + auto key = [](ImGuiKey k) { return ImGui::IsKeyPressed(k, false); }; + if (key(ImGuiKey_F5)) return runScriptNow(); + if (ctrl && key(ImGuiKey_1)) setTab(Tab::Model); + if (ctrl && key(ImGuiKey_2)) setTab(Tab::Paint); + if (ctrl && key(ImGuiKey_3)) setTab(Tab::Layout); + if (tab_ == Tab::Paint && !ctrl && !alt && key(ImGuiKey_Tab)) return (void)(sculpting_ = !sculpting_, endStroke(), endSculpt()); + if (tab_ == Tab::Paint && sculpting_ && !ctrl && !alt) + for (int i = 0; i < int(std::size(kSculptTools)); ++i) + if (key(ImGuiKey(int(ImGuiKey_1) + i))) sculptBrush_ = kSculptTools[i].brush; + if (tab_ == Tab::Paint && sculpting_ && !ctrl && !alt && key(ImGuiKey_X)) sculptMirror_ = !sculptMirror_; + if (tab_ == Tab::Paint && !ctrl && !alt) { + if (key(ImGuiKey_B)) paintTool_ = PaintTool::Brush; + if (key(ImGuiKey_E)) paintTool_ = PaintTool::Eraser; + if (key(ImGuiKey_G)) paintTool_ = PaintTool::Fill; + if (key(ImGuiKey_I)) paintTool_ = PaintTool::Picker; + float& size = sculpting_ ? sculptPx_ : brushPx_; + if (ImGui::IsKeyPressed(ImGuiKey_LeftBracket)) size = std::max(1.0f, size / 1.2f); + if (ImGui::IsKeyPressed(ImGuiKey_RightBracket)) size = std::min(500.0f, size * 1.2f); + if (key(ImGuiKey_Enter) || key(ImGuiKey_KeypadEnter)) lockLayer(); + if (key(ImGuiKey_Delete) && editLayer()) deleteLayer(*find(ledit_.object), ledit_.index); + if (key(ImGuiKey_F1)) showHelp_ = !showHelp_; + if (key(ImGuiKey_F12)) dialog(5); + if (key(ImGuiKey_F)) frameSelected(); + if (key(ImGuiKey_Home)) frameAll(); + if (key(ImGuiKey_Z)) shading_ = shading_ == Shading::Solid ? Shading::Rendered : Shading::Solid; + return; + } + if (tab_ == Tab::Paint && ctrl && key(ImGuiKey_V)) return pasteFromClipboard(); + if (tab_ == Tab::Paint && ctrl && key(ImGuiKey_Z) && editLayer() && ledit_.fresh) return cancelFloating(); + if (ctrl && key(ImGuiKey_4)) setTab(Tab::Render); + if (ctrl && key(ImGuiKey_5)) setTab(Tab::Print); + if (tab_ == Tab::Layout && !ctrl && !alt) { + if (key(ImGuiKey_Q)) layTool_ = LayTool::Select; + if (key(ImGuiKey_W)) layTool_ = LayTool::Move; + if (key(ImGuiKey_E)) layTool_ = LayTool::Rotate; + if (key(ImGuiKey_R)) layTool_ = LayTool::Scale; + if (key(ImGuiKey_Tab)) lay_.corners = !lay_.corners, lay_.frames.clear(), lay_.points.clear(); + if (key(ImGuiKey_F1)) showHelp_ = !showHelp_; + if (key(ImGuiKey_F)) frameSelected(); + if (key(ImGuiKey_Home)) frameAll(); + return; + } + if (tab_ == Tab::Layout && ctrl && key(ImGuiKey_A)) return layoutSelectAll(); + if (tab_ == Tab::Print && !ctrl && !alt) { + if (key(ImGuiKey_M)) printTool_ = printTool_ == PrintTool::Measure ? PrintTool::None : PrintTool::Measure, measure_.clear(); + if (key(ImGuiKey_L)) printTool_ = printTool_ == PrintTool::LayFlat ? PrintTool::None : PrintTool::LayFlat; + if (key(ImGuiKey_B)) putOnBed(); + if (key(ImGuiKey_Q) || key(ImGuiKey_W) || key(ImGuiKey_E) || key(ImGuiKey_R)) printTool_ = PrintTool::None; + } + if ((tab_ == Tab::Render || tab_ == Tab::Print) && !ctrl && !alt) { + if (key(ImGuiKey_Q)) setTool(Tool::Select); + if (key(ImGuiKey_W)) setTool(Tool::Move); + if (key(ImGuiKey_E)) setTool(Tool::Rotate); + if (key(ImGuiKey_R)) setTool(Tool::Scale); + if (key(ImGuiKey_F1)) showHelp_ = !showHelp_; + if (key(ImGuiKey_F12)) dialog(5); + if (key(ImGuiKey_F)) frameSelected(); + if (key(ImGuiKey_Home)) frameAll(); + if (key(ImGuiKey_Delete)) deleteSelected(); + return; + } + if (ctrl) { + if (key(ImGuiKey_Z)) shift ? redo() : undo(); + if (key(ImGuiKey_Y)) redo(); + if (key(ImGuiKey_S)) shift || path_.empty() ? dialog(2) : void(saveFile(path_)); + if (key(ImGuiKey_O)) whenSaved(3); + if (key(ImGuiKey_N)) whenSaved(2); + if (key(ImGuiKey_E)) dialog(3); + if (key(ImGuiKey_Q)) whenSaved(1); + if (key(ImGuiKey_D)) duplicateSelected(); + if (key(ImGuiKey_A)) selectAll(); + if (key(ImGuiKey_I)) invertSelection(); + if (key(ImGuiKey_J)) joinSelected(); + if (key(ImGuiKey_G)) groupSelected(); + if (shift && (key(ImGuiKey_Equal) || key(ImGuiKey_KeypadAdd))) return combineSelected(Combine::Add); + if (shift && (key(ImGuiKey_Minus) || key(ImGuiKey_KeypadSubtract))) return combineSelected(Combine::Subtract); + if (key(ImGuiKey_Equal) || key(ImGuiKey_KeypadAdd)) growSelection(); + if (key(ImGuiKey_Minus) || key(ImGuiKey_KeypadSubtract)) shrinkSelection(); + return; + } + if (alt) { + if (key(ImGuiKey_S) && mode_ == Mode::Edit) startPush(); + if (key(ImGuiKey_A)) selectNone(); + if (key(ImGuiKey_Z)) xray_ = !xray_; + if (key(ImGuiKey_H)) + for (Object& o : scene_.objects) o.visible = true; + return; + } if (key(ImGuiKey_F1)) showHelp_ = !showHelp_; if (key(ImGuiKey_F12)) dialog(5); - if (key(ImGuiKey_F)) frameSelected(); - if (key(ImGuiKey_Home)) frameAll(); - if (key(ImGuiKey_Z)) shading_ = shading_ == Shading::Solid ? Shading::Rendered : Shading::Solid; - return; - } - if (tab_ == Tab::Paint && ctrl && key(ImGuiKey_V)) return pasteFromClipboard(); - if (tab_ == Tab::Paint && ctrl && key(ImGuiKey_Z) && editLayer() && ledit_.fresh) return cancelFloating(); - if (ctrl && key(ImGuiKey_4)) setTab(Tab::Render); - if (ctrl && key(ImGuiKey_5)) setTab(Tab::Print); - if (tab_ == Tab::Layout && !ctrl && !alt) { - if (key(ImGuiKey_Q)) layTool_ = LayTool::Select; - if (key(ImGuiKey_W)) layTool_ = LayTool::Move; - if (key(ImGuiKey_E)) layTool_ = LayTool::Rotate; - if (key(ImGuiKey_R)) layTool_ = LayTool::Scale; - if (key(ImGuiKey_Tab)) lay_.corners = !lay_.corners, lay_.frames.clear(), lay_.points.clear(); - if (key(ImGuiKey_F1)) showHelp_ = !showHelp_; - if (key(ImGuiKey_F)) frameSelected(); - if (key(ImGuiKey_Home)) frameAll(); - return; - } - if (tab_ == Tab::Layout && ctrl && key(ImGuiKey_A)) return layoutSelectAll(); - if (tab_ == Tab::Print && !ctrl && !alt) { - if (key(ImGuiKey_M)) printTool_ = printTool_ == PrintTool::Measure ? PrintTool::None : PrintTool::Measure, measure_.clear(); - if (key(ImGuiKey_L)) printTool_ = printTool_ == PrintTool::LayFlat ? PrintTool::None : PrintTool::LayFlat; - if (key(ImGuiKey_B)) putOnBed(); - if (key(ImGuiKey_Q) || key(ImGuiKey_W) || key(ImGuiKey_E) || key(ImGuiKey_R)) printTool_ = PrintTool::None; - } - if ((tab_ == Tab::Render || tab_ == Tab::Print) && !ctrl && !alt) { if (key(ImGuiKey_Q)) setTool(Tool::Select); if (key(ImGuiKey_W)) setTool(Tool::Move); if (key(ImGuiKey_E)) setTool(Tool::Rotate); if (key(ImGuiKey_R)) setTool(Tool::Scale); - if (key(ImGuiKey_F1)) showHelp_ = !showHelp_; - if (key(ImGuiKey_F12)) dialog(5); + if (key(ImGuiKey_D)) setTool(Tool::Draw); + if (key(ImGuiKey_X)) setTool(Tool::Extrude); + if (key(ImGuiKey_I)) setTool(Tool::Inset); + if (key(ImGuiKey_B)) setTool(Tool::Bevel); + if (key(ImGuiKey_C)) setTool(Tool::LoopCut); + if (key(ImGuiKey_K)) setTool(Tool::Knife); + if (key(ImGuiKey_O) && !shift) setTool(Tool::Hole); + if (key(ImGuiKey_Tab)) mode_ == Mode::Edit ? leaveEdit() : enterEdit(); + if (key(ImGuiKey_Delete) || key(ImGuiKey_Backspace)) deleteSelected(); if (key(ImGuiKey_F)) frameSelected(); if (key(ImGuiKey_Home)) frameAll(); - if (key(ImGuiKey_Delete)) deleteSelected(); - return; + if (key(ImGuiKey_Z)) shading_ = shift ? Shading::Wire : shading_ == Shading::Solid ? Shading::Rendered : Shading::Solid; + if (key(ImGuiKey_Enter) || key(ImGuiKey_KeypadEnter)) { + if (tool_ == Tool::Draw) finishShape(); + if (tool_ == Tool::Knife && knifePts_.size() >= 2) { + std::vector pts = knifePts_; + knifePts_.clear(); + knifeApply(active_, pts); + } + } + if (mode_ == Mode::Edit) { + if (key(ImGuiKey_M)) mergeNow(); + if (key(ImGuiKey_L)) selectLinked(); + if (key(ImGuiKey_P)) separateSelected(); + if (key(ImGuiKey_1)) elem_ = Elem::Vertex, clearElements(); + if (key(ImGuiKey_2)) elem_ = Elem::Edge, clearElements(); + if (key(ImGuiKey_3)) elem_ = Elem::Face, clearElements(); + } + if (key(ImGuiKey_H) && shift && !selected_.empty()) showOnly(selected_); // (the rest hidden) + else if (key(ImGuiKey_H)) + for (Object& o : scene_.objects) + if (selected_.count(o.id) && mode_ == Mode::Object) o.visible = false; + if ((key(ImGuiKey_Slash) || key(ImGuiKey_KeypadDivide)) && mode_ == Mode::Object && !selected_.empty()) showOnly(selected_); + if (key(ImGuiKey_O) && shift) softSel_ = !softSel_, say(softSel_ ? "Soft selection on: points near follow (the wheel while dragging: how far)" : "Soft selection off"); + if (key(ImGuiKey_Keypad1)) setView(shift ? View::Back : View::Front); + if (key(ImGuiKey_Keypad3)) setView(shift ? View::Right : View::Left); + if (key(ImGuiKey_Keypad7)) setView(shift ? View::Bottom : View::Top); + if (key(ImGuiKey_Keypad5)) view_ == View::Perspective ? setView(View::Front) : leaveFlatView(); } - if (ctrl) { - if (key(ImGuiKey_Z)) shift ? redo() : undo(); - if (key(ImGuiKey_Y)) redo(); - if (key(ImGuiKey_S)) shift || path_.empty() ? dialog(2) : void(saveFile(path_)); - if (key(ImGuiKey_O)) shift ? dialog(12) : whenSaved(3); - if (key(ImGuiKey_N)) whenSaved(2); - if (key(ImGuiKey_E)) dialog(3); - if (key(ImGuiKey_Q)) whenSaved(1); - if (key(ImGuiKey_D)) duplicateSelected(); - if (key(ImGuiKey_A)) selectAll(); - if (key(ImGuiKey_I)) invertSelection(); - if (key(ImGuiKey_J)) joinSelected(); - if (key(ImGuiKey_G)) groupSelected(); - if (shift && (key(ImGuiKey_Equal) || key(ImGuiKey_KeypadAdd))) return combineSelected(Combine::Add); - if (shift && (key(ImGuiKey_Minus) || key(ImGuiKey_KeypadSubtract))) return combineSelected(Combine::Subtract); - if (key(ImGuiKey_Equal) || key(ImGuiKey_KeypadAdd)) growSelection(); - if (key(ImGuiKey_Minus) || key(ImGuiKey_KeypadSubtract)) shrinkSelection(); - return; + + // ───────────────────────────── 3D ───────────────────────────── + +#if defined(__ANDROID__) + static constexpr int kRayLight = 0; // (light bounced by ray tracing: not on phones) +#else + static constexpr int kRayLight = 1; +#endif + // Solid: a plain grey room and soft studio light, no shadows or effects — for seeing shapes. + // Rendered: sky, sun, shadows and effects. + void applyShading() { + const Look& k = scene_.look; + const std::string want = + shading() == Shading::Rendered + ? std::format("r_ssao {}; r_bloom {}; r_vignette {}; r_exposure {}; r_saturation {}; r_sharpen {}; r_shadowsoft {}; r_gi {}; r_fog 0; r_skycolor \"{} {} {}\"", k.ao, + k.bloom, k.vignette, k.exposure, k.saturation, k.sharpen, k.shadowSoft, kRayLight, k.background.r, k.background.g, k.background.b) + : std::string("r_ssao 0; r_bloom 0; r_vignette 0; r_exposure 1; r_saturation 1; r_sharpen 0; r_gi 0; r_fog 0; r_skycolor \"0.2 0.2 0.215\""); + if (want == appliedLook_) return; + appliedLook_ = want; + keel::execute(want); } - if (alt) { - if (key(ImGuiKey_S) && mode_ == Mode::Edit) startPush(); - if (key(ImGuiKey_G) && mode_ == Mode::Edit) return startSlide(); - if (key(ImGuiKey_A)) selectNone(); - if (key(ImGuiKey_Z)) xray_ = !xray_; - if (key(ImGuiKey_H)) - for (Object& o : scene_.objects) o.visible = true; - return; + // Painted, and shown as painted (with rounding or smoothing added since, it isn't). + bool painted(const Object& o) const { return o.picture && o.uvsFit() && !o.hasFinish(); } + + keel::ModelId modelOf(const Object& o) { + const bool editingThis = mode_ == Mode::Edit && o.id == active_; + const uint64_t want = (versions_[o.id] * 2 + editingThis) * 2 + painted(o); + auto it = models_.find(o.id); + if (it != models_.end() && it->second.second == want && versions_[o.id]) return it->second.first; + const Mesh& mesh = shownMesh(o); + keel::KmdlModel km; + keel::KmdlMaterial mat{}; + mat.baseColor[0] = mat.baseColor[1] = mat.baseColor[2] = mat.baseColor[3] = 1.0f; + const bool withPicture = painted(o); + if (withPicture) std::snprintf(mat.texture, sizeof mat.texture, "fry:paint:%u", o.id); + km.materials.push_back(mat); + const auto normals = cornerNormals(mesh, o.smooth, o.smoothAngle); + for (size_t f = 0; f < mesh.faces.size(); ++f) { + const auto& face = mesh.faces[f]; + for (const auto& tri : triangulate(mesh, int(f))) + for (int k = 0; k < 3; ++k) { + const size_t corner = size_t(std::find(face.begin(), face.end(), tri[size_t(k)]) - face.begin()); + const glm::vec3 p = mesh.verts[size_t(tri[size_t(k)])], n = normals[f][corner]; + km.indices.push_back(uint32_t(km.vertices.size())); + const glm::vec2 uv = withPicture ? o.uvs[f][corner] : glm::vec2(0.0f); + km.vertices.push_back({{p.x, p.y, p.z}, {n.x, n.y, n.z}, {uv.x, uv.y}, {0, 0}}); + } + } + if (km.vertices.empty()) km.vertices.push_back({}), km.indices = {0, 0, 0}; + km.submeshes.push_back({0, uint32_t(km.indices.size()), 0}); + const glm::vec3 lo = boundsMin(mesh), hi = boundsMax(mesh); + std::copy_n(&lo.x, 3, km.header.boundsMin); + std::copy_n(&hi.x, 3, km.header.boundsMax); + const keel::ModelId id = r_->updateModel(std::format("fry:object{}", o.id), km); + models_[o.id] = {id, want}; + return id; } - if (key(ImGuiKey_F1)) showHelp_ = !showHelp_; - if (key(ImGuiKey_F12)) dialog(5); - if (key(ImGuiKey_Q)) setTool(Tool::Select); - if (key(ImGuiKey_W)) setTool(Tool::Move); - if (key(ImGuiKey_E) && shift && mode_ == Mode::Edit) return creaseSelected(); - if (key(ImGuiKey_G) && shift) return selectSimilar(); - if (key(ImGuiKey_E)) setTool(Tool::Rotate); - if (key(ImGuiKey_R)) setTool(Tool::Scale); - if (key(ImGuiKey_D)) setTool(Tool::Draw); - if (key(ImGuiKey_X)) setTool(Tool::Extrude); - if (key(ImGuiKey_I)) setTool(Tool::Inset); - if (key(ImGuiKey_B)) setTool(Tool::Bevel); - if (key(ImGuiKey_C) && shift) return runCommand("view.camera"); - if (key(ImGuiKey_C)) setTool(Tool::LoopCut); - if (key(ImGuiKey_K)) setTool(Tool::Knife); - if (key(ImGuiKey_O) && !shift) setTool(Tool::Hole); - if (key(ImGuiKey_Tab)) mode_ == Mode::Edit ? leaveEdit() : enterEdit(); - if (key(ImGuiKey_Delete) || key(ImGuiKey_Backspace)) deleteSelected(); - if (key(ImGuiKey_F)) frameSelected(); - if (key(ImGuiKey_Home)) frameAll(); - if (key(ImGuiKey_Z)) shading_ = shift ? Shading::Wire : shading_ == Shading::Solid ? Shading::Rendered : Shading::Solid; - if (key(ImGuiKey_Enter) || key(ImGuiKey_KeypadEnter)) { - if (tool_ == Tool::Draw) finishShape(); - if (tool_ == Tool::Knife && knifePts_.size() >= 2) { - std::vector pts = knifePts_; - knifePts_.clear(); - knifeApply(active_, pts); + + const Picture& picture(const Reference& ref) { + auto it = pictures_.find(ref.path); + if (it != pictures_.end()) return it->second; + Picture& p = pictures_[ref.path]; + int w = 0, h = 0, n = 0; + if (stbi_uc* px = stbi_load(ref.path.c_str(), &w, &h, &n, 4)) { + r_->createTexture("fry:picture:" + ref.path, uint32_t(w), uint32_t(h), px); + stbi_image_free(px); + p.ok = true, p.aspect = float(h) / float(std::max(w, 1)); + } else { + say("Couldn't load the picture " + ref.path); + } + return p; + } + + void loadLogo() { + if (logoTried_) return; + logoTried_ = true; + if (auto png = keel::fs::read("branding/logo.png")) { + int w = 0, h = 0, n = 0; + if (stbi_uc* px = stbi_load_from_memory(png->data(), int(png->size()), &w, &h, &n, 4)) { + logo_ = r_->createTexture("fry:logo", uint32_t(w), uint32_t(h), px); + stbi_image_free(px); + haveLogo_ = true; + } } } - if (mode_ == Mode::Edit) { - if (key(ImGuiKey_M)) mergeNow(); - if (key(ImGuiKey_L)) selectLinked(); - if (key(ImGuiKey_P)) separateSelected(); - if (key(ImGuiKey_1)) elem_ = Elem::Vertex, clearElements(); - if (key(ImGuiKey_2)) elem_ = Elem::Edge, clearElements(); - if (key(ImGuiKey_3)) elem_ = Elem::Face, clearElements(); + void logoImage(float size) { + if (!haveLogo_) return; + const ImVec2 at = ImGui::GetCursorScreenPos(); + ImGui::Dummy(ImVec2(size, size)); + uiImage(ImGui::GetWindowDrawList(), logo_, at, ImVec2(at.x + size, at.y + size)); + } + // A picture of ours in the UI, read the way ImGui's colours are (not turned linear: too dark). + void uiImage(ImDrawList* dl, keel::TextureId t, ImVec2 a, ImVec2 b) { + const keel::UiRawRead raw = r_->uiRawRead(t); + if (raw.begin) dl->AddCallback(raw.begin, raw.data); + dl->AddImage(ImTextureID(r_->uiTexture(t)), a, b); + if (raw.end) dl->AddCallback(raw.end, raw.data); } - if (key(ImGuiKey_H) && shift && !selected_.empty()) showOnly(selected_); // (the rest hidden) - else if (key(ImGuiKey_H)) - for (Object& o : scene_.objects) - if (selected_.count(o.id) && mode_ == Mode::Object) o.visible = false; - if ((key(ImGuiKey_Slash) || key(ImGuiKey_KeypadDivide)) && mode_ == Mode::Object && !selected_.empty()) showOnly(selected_); - if (key(ImGuiKey_O) && shift) softSel_ = !softSel_, say(softSel_ ? "Soft selection on: points near follow (the wheel while dragging: how far)" : "Soft selection off"); - if (key(ImGuiKey_Keypad1)) setView(shift ? View::Back : View::Front); - if (key(ImGuiKey_Keypad3)) setView(shift ? View::Right : View::Left); - if (key(ImGuiKey_Keypad7)) setView(shift ? View::Bottom : View::Top); - if (key(ImGuiKey_Keypad5)) view_ == View::Perspective ? setView(View::Front) : leaveFlatView(); -} -} // namespace fryapp + float gridStep() const { return std::pow(10.0f, std::floor(std::log10(std::max(viewHeight() / 4.0f, 1e-4f)))); } + + void draw3D(keel::Renderer& r) { + keel::Camera cam; + cam.origin = eye_ * kUnits, cam.yaw = yaw_, cam.pitch = pitch_, cam.fovY = fov_, cam.shift = shift_; + r.setCamera(cam); + const Look& look = scene_.look; + const bool rendered = shading() == Shading::Rendered; + const float turn = glm::radians(look.sunTurn), up = glm::radians(look.sunHeight); + r.setSun(rendered && look.sunStrength > 0, glm::vec3(std::cos(up) * std::cos(turn), std::cos(up) * std::sin(turn), std::sin(up)), look.sunColor * look.sunStrength, true); + keel::Renderer::Atmosphere atm; + atm.sky = rendered && look.sky ? "skies/day.ksky" : ""; + atm.fogDensity = 0.0f; + r.setAtmosphere(atm); + // Rendered: light from the sky all round. Solid: a studio's — bright from above and the + // front-left, darker below, so every side of a shape reads differently. + const keel::ModelLighting light = + rendered ? keel::ModelLighting{{{0.22f, 0.22f, 0.24f}, {0.13f, 0.13f, 0.15f}, {0.2f, 0.2f, 0.22f}, {0.16f, 0.16f, 0.18f}, {0.3f, 0.31f, 0.35f}, {0.06f, 0.06f, 0.06f}}, 1.0f} + : keel::ModelLighting{{{0.5f, 0.49f, 0.47f}, {0.2f, 0.21f, 0.24f}, {0.72f, 0.71f, 0.69f}, {0.3f, 0.31f, 0.34f}, {0.95f, 0.95f, 0.95f}, {0.08f, 0.08f, 0.09f}}, 0.0f}; + const glm::mat4 toUnits = glm::scale(glm::mat4(1.0f), glm::vec3(kUnits)); + // Painted pictures: laid out again if the shape's faces changed, and sent to the card. + for (Object& o : scene_.objects) + if (o.picture && !o.uvsFit() && !o.hasFinish()) o.uvs = autoUnwrap(o.mesh), touched(o.id); + for (const Object& o : scene_.objects) + if (o.picture) uploadPicture(o, !(paint_.active && paint_.object == o.id)); + if (shading() != Shading::Wire) + for (const Object& o : scene_.objects) { + if (!o.visible) continue; + const glm::vec4 tint = painted(o) ? glm::vec4(1.0f) : glm::vec4(glm::pow(o.color, glm::vec3(2.2f)), 1.0f); // (colours are picked as seen: sRGB) + const keel::Surface surface{o.metal, o.rough}; + r.drawModel(modelOf(o), toUnits * objectMatrix(o), tint, nullptr, &light, rendered, nullptr, keel::LiquidStyle::None, &surface); + } + // Rendered: a ground to stand on and catch the shadows (not part of what you make). + if (rendered && look.ground) { + if (!haveGround_) { + haveGround_ = true; + 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); + const float h = 500.0f; + for (glm::vec2 c : {glm::vec2(-h, -h), glm::vec2(h, -h), glm::vec2(h, h), glm::vec2(-h, h)}) km.vertices.push_back({{c.x, c.y, -0.0005f}, {0, 0, 1}, {0, 0}, {0, 0}}); + km.indices = {0, 1, 2, 0, 2, 3}; + km.submeshes.push_back({0, 6, 0}); + ground_ = r.updateModel("fry:ground", km); + } + r.drawModel(ground_, toUnits, glm::vec4(glm::pow(look.groundColor, glm::vec3(2.2f)), 1.0f), nullptr, &light, false); + } + // Reference pictures: flat, see-through, a little behind the plane through the middle. + for (const Reference& ref : scene_.references) { + if (!ref.visible) continue; + const Picture& pic = picture(ref); + if (!pic.ok) continue; + const auto c = referenceCorners(ref, pic.aspect); + const glm::vec3 n = glm::normalize(glm::cross(c[1] - c[0], c[3] - c[0])); + keel::KmdlModel km; + keel::KmdlMaterial mat{}; + std::snprintf(mat.texture, sizeof mat.texture, "fry:picture:%s", ref.path.c_str()); + mat.baseColor[0] = mat.baseColor[1] = mat.baseColor[2] = 1.0f, mat.baseColor[3] = 1.0f; + km.materials.push_back(mat); + const glm::vec2 uv[4] = {{0, 1}, {1, 1}, {1, 0}, {0, 0}}; + for (int side = 0; side < 2; ++side) // (both sides: seen from behind too) + for (int k : {0, 1, 2, 0, 2, 3}) { + const int i = side ? (3 - k + 1) % 4 : k; + const glm::vec3 p = c[size_t(i)] - n * 0.001f, nn = side ? -n : n; + km.indices.push_back(uint32_t(km.vertices.size())); + km.vertices.push_back({{p.x, p.y, p.z}, {nn.x, nn.y, nn.z}, {uv[i].x, uv[i].y}, {0, 0}}); + } + km.submeshes.push_back({0, uint32_t(km.indices.size()), 0}); + const keel::ModelId id = r.updateModel(std::format("fry:ref{}", ref.id), km); + 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}; + r.drawModel(id, toUnits, glm::vec4(1.0f, 1.0f, 1.0f, ref.opacity), nullptr, &flat, false); + } + // The grid on the work plane (the ground, or what a flat view looks at), and the axes. + if (tab_ == Tab::Render ? renderGrid_ : showGrid_) { + glm::vec3 p, n; + workPlane(p, n); + const glm::vec3 u = n.z > 0.5f ? glm::vec3(1, 0, 0) : n.x > 0.5f ? glm::vec3(0, 1, 0) : glm::vec3(1, 0, 0); + const glm::vec3 v = glm::cross(n, u); + const float step = gridStep(); + const int count = 20; + const glm::vec3 centre = target_ - n * glm::dot(target_, n); + const float cu = snapTo(glm::dot(centre, u), step * 10), cv = snapTo(glm::dot(centre, v), step * 10); + const float shade = rendered ? 0.0f : 1.0f; // (dark lines on the sunny ground, light ones in the grey room) + for (int i = -count; i <= count; ++i) + for (int dir = 0; dir < 2; ++dir) { + const glm::vec3 along = dir ? u : v, across = dir ? v : u; + const float at = (dir ? cv : cu) + float(i) * step, mid = dir ? cu : cv; + const glm::vec3 a = across * at + along * (mid - count * step), b = across * at + along * (mid + count * step); + const bool major = std::abs(snapTo(at, step * 10) - at) < step * 0.01f, axis = std::abs(at) < step * 0.01f; + glm::vec4 col = glm::vec4(glm::vec3(shade), major ? (rendered ? 0.35f : 0.13f) : (rendered ? 0.16f : 0.06f)); + if (axis) col = dir ? (n.x > 0.5f ? glm::vec4(0.3f, 0.75f, 0.25f, 0.9f) : glm::vec4(0.9f, 0.25f, 0.25f, 0.9f)) + : (n.z > 0.5f ? glm::vec4(0.3f, 0.75f, 0.25f, 0.9f) : glm::vec4(0.25f, 0.45f, 0.95f, 0.9f)); + const float w = pixelSize(target_) * (major || axis ? 1.6f : 1.0f) * kUnits; + r.drawStreak(a * kUnits, b * kUnits, w, col, keel::kWhiteTexture); + } + } + if (tab_ == Tab::Print) drawPrintBed(r, light); + } + + // (the Print tab's: the printer's plate and overhangs) + // On top of the scene: outlines and wires, points, the gizmo, the selection shapes. + void overlays() { + ImDrawList* dl = ImGui::GetBackgroundDrawList(); + dl->PushClipRect(viewMin_, viewMax_); + const ImGuiIO& io = ImGui::GetIO(); + // Every edge of a mesh (X-ray, wireframe), or just the edges the eye can see. + auto lines = [&](const Object& o, const Mesh& mesh, ImU32 col, float width, bool seenOnly) { + const glm::mat4 m = objectMatrix(o); + const std::vector* vis = seenOnly && !xray_ ? &visiblePoints(o) : nullptr; + std::vector s(mesh.verts.size()); + std::vector ok(mesh.verts.size()); + for (size_t i = 0; i < s.size(); ++i) ok[i] = project(glm::vec3(m * glm::vec4(mesh.verts[i], 1.0f)), s[i]); + for (auto [a, b] : edges(mesh)) { + if (vis && !((*vis)[size_t(a)] && (*vis)[size_t(b)])) continue; + if (ok[size_t(a)] && ok[size_t(b)]) dl->AddLine(s[size_t(a)], s[size_t(b)], col, width); + } + }; + // The outline round a selected object (where it turns away from you, and open edges). + auto outline = [&](const Object& o, ImU32 col, float width) { + const Mesh& mesh = shownMesh(o); + const glm::mat4 m = objectMatrix(o); + std::vector w(mesh.verts.size()); + for (size_t i = 0; i < w.size(); ++i) w[i] = glm::vec3(m * glm::vec4(mesh.verts[i], 1.0f)); + std::map, int> side; // bit 1: a face towards you, bit 2: away; + 4 each face + for (size_t f = 0; f < mesh.faces.size(); ++f) { + const auto& fv = mesh.faces[f]; + glm::vec3 n(0.0f); + for (size_t i = 0; i < fv.size(); ++i) n += glm::cross(w[size_t(fv[i])], w[size_t(fv[(i + 1) % fv.size()])]); + const int facing = glm::dot(n, eye_ - w[size_t(fv[0])]) > 0 ? 1 : 2; + for (size_t i = 0; i < fv.size(); ++i) { + int& s = side[{std::min(fv[i], fv[(i + 1) % fv.size()]), std::max(fv[i], fv[(i + 1) % fv.size()])}]; + s = (s | facing) + 4; + } + } + const std::vector* vis = xray_ ? nullptr : &visiblePoints(o, mesh); // (not the edges of a pocket behind its rim) + for (auto [e, s] : side) { + const bool silhouette = (s & 3) == 3, open = (s >> 2) == 1 && (s & 1); + if (vis && !((*vis)[size_t(e.first)] && (*vis)[size_t(e.second)])) continue; + ImVec2 a, b; + if ((silhouette || open) && project(w[size_t(e.first)], a) && project(w[size_t(e.second)], b)) dl->AddLine(a, b, col, width); + } + }; + const bool clean = capturing(); // (a picture being saved: no outlines or gizmos) + Object* ed = editing(); + for (const Object& o : scene_.objects) { + if (!o.visible) continue; + const bool sel = selected_.count(o.id) > 0 && !clean && tab_ != Tab::Paint; + if (ed && o.id == ed->id) continue; + if (shading() == Shading::Wire || xray_) lines(o, shownMesh(o), sel ? kAccent : IM_COL32(200, 200, 205, 150), sel ? 1.6f : 1.0f, false); + else if (sel) outline(o, kAccent, 2.5f); + } + if (tab_ == Tab::Paint) { + if (Object* so = find(ledit_.object); so && editLayer() && editLayer()->sticker) { // the sticker's frame and handles + std::array hw; + ImVec2 q[6]; + bool ok = stickerHandles(*so, *editLayer(), hw); + for (int i = 0; ok && i < 6; ++i) ok = project(hw[size_t(i)], q[i]); + if (ok) { + const int hot = ledit_.drag >= 0 ? ledit_.drag : stickerHandle(*so, *editLayer(), io.MousePos); + dl->AddPolyline(q, 4, hot == 4 ? kHot : kAccent, ImDrawFlags_Closed, 2.0f); + const ImVec2 top((q[0].x + q[1].x) * 0.5f, (q[0].y + q[1].y) * 0.5f); + dl->AddLine(top, q[4], kAccent, 1.5f); + dl->AddCircleFilled(q[4], 7.0f, hot == 5 ? kHot : IM_COL32(255, 255, 255, 240)); + dl->AddCircle(q[4], 7.0f, IM_COL32(20, 20, 25, 255)); + for (int i = 0; i < 4; ++i) { + dl->AddRectFilled(ImVec2(q[i].x - 6, q[i].y - 6), ImVec2(q[i].x + 6, q[i].y + 6), hot == i ? kHot : IM_COL32(255, 255, 255, 240)); + dl->AddRect(ImVec2(q[i].x - 6, q[i].y - 6), ImVec2(q[i].x + 6, q[i].y + 6), IM_COL32(20, 20, 25, 255)); + } + } + } + if (sculpting_) { + if (mouseInView(io)) dl->AddCircle(io.MousePos, sculptPx_, io.KeyCtrl ? IM_COL32(120, 180, 255, 220) : IM_COL32(255, 255, 255, 200), 40, 1.5f); + } else if (mouseInView(io) && !editLayer() && paintTool_ != PaintTool::Picture) + dl->AddCircle(io.MousePos, brushPx_, paintTool_ == PaintTool::Fill || paintTool_ == PaintTool::Picker ? IM_COL32(255, 255, 255, 90) : IM_COL32(255, 255, 255, 200), 40, 1.5f); + dl->PopClipRect(); + return; + } + if (tab_ == Tab::Layout) { + layoutOverlay(dl); + dl->PopClipRect(); + return; + } + if (tab_ == Tab::Print && !clean) printOverlay(dl, io); + if (clean) { + dl->PopClipRect(); + return; + } + if (ed) { + if (ed->hasFinish() && ed->mirror && shading() != Shading::Wire) // (the mirrored half: faint) + lines(*ed, shownMesh(*ed), IM_COL32(20, 20, 25, 70), 1.0f, false); + lines(*ed, ed->mesh, shading() == Shading::Wire ? IM_COL32(230, 230, 235, 230) : IM_COL32(12, 12, 15, 235), 1.3f, true); + } + if (Object* o = ed) { + const glm::mat4 m = objectMatrix(*o); + if (elem_ == Elem::Vertex) { + for (size_t v = 0; v < o->mesh.verts.size(); ++v) { + ImVec2 s; + const bool sel = selVerts_.count(int(v)); + if (!project(glm::vec3(m * glm::vec4(o->mesh.verts[v], 1.0f)), s) || (!sel && !pointSeen(*o, int(v)))) continue; + dl->AddRectFilled(ImVec2(s.x - 3, s.y - 3), ImVec2(s.x + 3, s.y + 3), sel ? kAccent : IM_COL32(15, 15, 18, 255)); + } + } else if (elem_ == Elem::Edge) { + for (auto [a, b] : selEdges_) { + ImVec2 sa, sb; + if (a < int(o->mesh.verts.size()) && b < int(o->mesh.verts.size()) && project(glm::vec3(m * glm::vec4(o->mesh.verts[size_t(a)], 1.0f)), sa) && + project(glm::vec3(m * glm::vec4(o->mesh.verts[size_t(b)], 1.0f)), sb)) + dl->AddLine(sa, sb, kAccent, 3.0f); + } + } else { // the selected faces the eye can see: an orange skin, a dot in the middle + const glm::mat3 nm = glm::transpose(glm::inverse(glm::mat3(m))); + for (int f : selFaces_) { + if (f >= int(o->mesh.faces.size())) continue; + const glm::vec3 c(m * glm::vec4(faceCenter(o->mesh, f), 1.0f)); + const bool front = glm::dot(nm * faceNormal(o->mesh, f), eye_ - c) > 0; + if (!xray_ && !front) continue; + const auto& fv = o->mesh.faces[size_t(f)]; + if (!xray_ && !std::all_of(fv.begin(), fv.end(), [&](int v) { return pointSeen(*o, v); }) && !visible(*o, c)) continue; + std::vector poly(fv.size()); + bool ok = true; + for (size_t k = 0; k < fv.size(); ++k) ok &= project(glm::vec3(m * glm::vec4(o->mesh.verts[size_t(fv[k])], 1.0f)), poly[k]); + if (ok) dl->AddConcavePolyFilled(poly.data(), int(poly.size()), IM_COL32(255, 120, 40, 70)); + ImVec2 s; + if (project(c, s)) dl->AddCircleFilled(s, 3.5f, kAccent); + } + } + // The loop cut to be: across each face of the ring. + if (tool_ == Tool::LoopCut && cutHover_.first >= 0) + for (const auto& q : ringQuads(o->mesh, cutHover_)) + for (int c = 1; c <= cuts_; ++c) { + const float t = float(c) / float(cuts_ + 1); + const glm::vec3 a = glm::mix(o->mesh.verts[size_t(q[0])], o->mesh.verts[size_t(q[1])], t), b = glm::mix(o->mesh.verts[size_t(q[3])], o->mesh.verts[size_t(q[2])], t); + ImVec2 sa, sb; + if (project(glm::vec3(m * glm::vec4(a, 1.0f)), sa) && project(glm::vec3(m * glm::vec4(b, 1.0f)), sb)) dl->AddLine(sa, sb, kHot, 2.5f); + } + } + // The knife's line: its points, on to the mouse; cutting through, on past the ends. + if (tool_ == Tool::Knife && (!knifePts_.empty() || knifeDown_)) { + std::vector pts = knifePts_; + if (knifePts_.empty()) pts.push_back(knifePress_); + if (mouseInView(io) || knifeDown_) pts.push_back(knifeEnd(io)); + for (size_t i = 0; i + 1 < pts.size(); ++i) dl->AddLine(pts[i], pts[i + 1], kHot, 2.0f); + if (knifeThrough_ && pts.size() >= 2) + for (int end = 0; end < 2; ++end) { + const ImVec2 a = end ? pts.back() : pts.front(), b = end ? pts[pts.size() - 2] : pts[1]; + const float dx = a.x - b.x, dy = a.y - b.y, l = std::max(std::hypot(dx, dy), 1e-3f); + for (int k = 0; k < 60; k += 2) + dl->AddLine(ImVec2(a.x + dx / l * float(k) * 12, a.y + dy / l * float(k) * 12), ImVec2(a.x + dx / l * float(k + 1) * 12, a.y + dy / l * float(k + 1) * 12), + (kHot & 0x00FFFFFF) | 0x90000000, 1.5f); + } + for (const ImVec2& p : knifePts_) dl->AddCircleFilled(p, 4.0f, kHot); + } + // The hole to be. + if (tool_ == Tool::Hole && (mouseInView(io) || hole_.active)) { + const auto ring = holePreview(io); + std::vector pts; + for (const glm::vec3& w : ring) { + ImVec2 sp; + if (project(w, sp)) pts.push_back(sp); + } + if (pts.size() >= 3) dl->AddPolyline(pts.data(), int(pts.size()), hole_.active ? kHot : IM_COL32(255, 255, 255, 200), ImDrawFlags_Closed, 2.0f); + } + if (boxing_ && !touchOrbit_ && std::hypot(io.MousePos.x - boxStart_.x, io.MousePos.y - boxStart_.y) > 4) { + dl->AddRectFilled(boxStart_, io.MousePos, IM_COL32(255, 140, 60, 30)); + dl->AddRect(boxStart_, io.MousePos, kAccent); + } + if (lassoing_ && lasso_.size() > 1) dl->AddPolyline(lasso_.data(), int(lasso_.size()), kAccent, ImDrawFlags_Closed, 1.5f); + if (selShape_ == SelShape::Brush && tool_ == Tool::Select && mode_ == Mode::Edit && mouseInView(io)) + dl->AddCircle(io.MousePos, brushRadius_, painting_ ? kAccent : IM_COL32(255, 255, 255, 160), 32, 1.5f); + if (tool_ == Tool::Draw) { + ImVec2 prev, s; + for (size_t i = 0; i < drawPts_.size(); ++i) { + if (!project(drawPts_[i], s)) continue; + if (i) dl->AddLine(prev, s, kAccent, 2.0f); + dl->AddCircleFilled(s, i == 0 ? 6.0f : 4.0f, i == 0 ? IM_COL32(255, 255, 255, 255) : kAccent); + prev = s; + } + if (mouseInView(io) && !touch()) // (a finger isn't hovering anywhere) + if (auto p = drawPoint(io); p && project(*p, s)) { + if (!drawPts_.empty()) dl->AddLine(prev, s, IM_COL32(255, 160, 90, 180), 1.5f); + dl->AddCircle(s, 5.0f, IM_COL32(255, 255, 255, 220), 0, 1.5f); + } + } + gizmo(dl, io); + dl->PopClipRect(); + } + + void gizmo(ImDrawList* dl, const ImGuiIO& io) { + const int hot = mouseInView(io) || drag_.active ? hoverHandle(io.MousePos) : -1; + if (softSel_ && mode_ == Mode::Edit && (tool_ == Tool::Move || tool_ == Tool::Rotate || tool_ == Tool::Scale) && !selectedPoints().empty()) { + const glm::vec3 at = drag_.active ? drag_.pivot : pivotOfSelection(); + ImVec2 c; + if (project(at, c)) dl->AddCircle(c, softReach_ / std::max(pixelSize(at), 1e-9f), keel::ui::accent(0.8f), 64, 1.5f); + } + if (tool_ == Tool::Extrude || tool_ == Tool::Inset) { + glm::vec3 at, n; + if (!faceHandle(at, n)) return; + ImVec2 a, b; + if (!project(at, a) || !project(at + n * pixelSize(at) * 90.0f, b)) return; + const ImU32 col = hot == 0 ? kHot : IM_COL32(255, 255, 255, 255); + dl->AddLine(a, b, col, 3.0f); + if (tool_ == Tool::Extrude) dl->AddCircleFilled(b, 7.0f, col); + else dl->AddRectFilled(ImVec2(b.x - 6, b.y - 6), ImVec2(b.x + 6, b.y + 6), col); + return; + } + if (tool_ == Tool::Bevel) { + ImVec2 c, h; + if (!bevelHandle(c, h)) return; + const ImU32 col = hot == 0 ? kHot : IM_COL32(255, 255, 255, 255); + dl->AddLine(c, h, (col & 0x00FFFFFF) | 0x90000000, 2.0f); + dl->AddCircleFilled(h, 9.0f, col); + drawIcon(dl, Icon::Bevel, ImVec2(h.x - 7, h.y - 7), 14.0f, IM_COL32(30, 30, 35, 255)); + return; + } + if (tool_ != Tool::Move && tool_ != Tool::Rotate && tool_ != Tool::Scale) return; + if (!hasSelection()) return; + const Axes axes = drag_.active ? drag_.axes : gizmoAxes(); + if (tool_ == Tool::Scale) { // the box round the selection, a handle on each side + const auto [lo, hi] = selectionBox(axes); + for (int e = 0; e < 12; ++e) { + const int k = e / 4, u = (k + 1) % 3, v = (k + 2) % 3; + glm::vec3 a, b; + a[k] = lo[k], b[k] = hi[k]; + a[u] = b[u] = e & 1 ? hi[u] : lo[u]; + a[v] = b[v] = e & 2 ? hi[v] : lo[v]; + ImVec2 sa, sb; + if (project(fromFrame(axes, a), sa) && project(fromFrame(axes, b), sb)) dl->AddLine(sa, sb, IM_COL32(255, 255, 255, 110), 1.0f); + } + for (int h : {0, 1, 2, 4, 5, 6, 3}) { + ImVec2 s; + if (!project(scaleHandle(axes, lo, hi, h), s)) continue; + const ImU32 col = hot == h ? kHot : h == 3 ? IM_COL32(255, 255, 255, 235) : kAxisCol[h < 3 ? h : h - 4]; + dl->AddRectFilled(ImVec2(s.x - 6, s.y - 6), ImVec2(s.x + 6, s.y + 6), col); + dl->AddRect(ImVec2(s.x - 6, s.y - 6), ImVec2(s.x + 6, s.y + 6), IM_COL32(20, 20, 25, 200)); + } + return; + } + const glm::vec3 p = drag_.active ? (tool_ == Tool::Rotate ? drag_.pivot : selectionMiddle(axes)) : (tool_ == Tool::Rotate ? rotatePivot(axes) : selectionMiddle(axes)); + ImVec2 c; + if (!project(p, c)) return; + const float len = pixelSize(p) * 90.0f; + if (tool_ == Tool::Rotate) { + for (int k = 0; k < 3; ++k) { + const glm::vec3 u = glm::normalize(glm::cross(axes[size_t(k)], std::abs(axes[size_t(k)].z) < 0.9f ? glm::vec3(0, 0, 1) : glm::vec3(1, 0, 0))); + const glm::vec3 v = glm::cross(axes[size_t(k)], u); + ImVec2 prev; + bool havePrev = false; + for (int i = 0; i <= 64; ++i) { + const float a = 2 * kPi * float(i) / 64.0f; + const glm::vec3 w = p + (u * std::cos(a) + v * std::sin(a)) * len; + ImVec2 s; + if (!project(w, s)) { + havePrev = false; + continue; + } + const bool front = glm::dot(w - p, eye_ - p) > -0.2f * len * glm::length(eye_ - p); + if (havePrev) dl->AddLine(prev, s, hot == k ? kHot : (front ? kAxisCol[k] : (kAxisCol[k] & 0x00FFFFFF) | 0x50000000), hot == k ? 3.5f : 2.5f); + prev = s, havePrev = true; + } + } + dl->AddCircle(c, 102.0f, hot == 3 ? kHot : IM_COL32(255, 255, 255, 160), 64, 2.0f); + return; + } + for (int k = 0; k < 3; ++k) { + ImVec2 e; + if (!project(p + axes[size_t(k)] * len, e)) continue; + const ImU32 col = hot == k ? kHot : kAxisCol[k]; + dl->AddLine(c, e, col, 3.0f); + const float dx = e.x - c.x, dy = e.y - c.y, l = std::max(std::hypot(dx, dy), 1.0f); + const ImVec2 dir(dx / l, dy / l), side(-dir.y, dir.x); + dl->AddTriangleFilled(ImVec2(e.x + dir.x * 14, e.y + dir.y * 14), ImVec2(e.x + side.x * 6, e.y + side.y * 6), ImVec2(e.x - side.x * 6, e.y - side.y * 6), col); + } + dl->AddRect(ImVec2(c.x - 8, c.y - 8), ImVec2(c.x + 8, c.y + 8), hot == 3 ? kHot : IM_COL32(255, 255, 255, 230), 0, 0, 2.0f); + } + + // ───────────────────────────── panels ───────────────────────────── + + void setupUi() { + keel::ui::setPixelScale(touchScale()); + if (iniPath_.empty()) { + touchMode_ = fry_touch.b(); + iniPath_ = keel::prefPath() + (touchMode_ ? "layout_phone.ini" : "layout_v3.ini"); + ImGui::GetIO().IniFilename = iniPath_.c_str(); + if (stdfs::exists(iniPath_)) ImGui::LoadIniSettingsFromDisk(iniPath_.c_str()); + } + if (fry_theme.i() != appliedTheme_) appliedTheme_ = fry_theme.i(), keel::ui::setAppearance(keel::ui::Appearance(std::clamp(appliedTheme_, 0, 2))); + if (styled_) return; + styled_ = true; + ImGui::GetStyle().FontScaleDpi = 0.92f; // (the look itself: keel/ui, Niveus) + } + + // The least a panel can be and still read right (its words, its rows), by the font's size. + static ImVec2 panelMin(std::string_view name) { + const float f = ImGui::GetFontSize(); + if (name == "Toolbox") return {15.5f * f, 9 * f}; + if (name == "Tool options") return {15.5f * f, 8 * f}; + if (name == "Properties") return {23 * f, 12 * f}; + if (name == "Layers") return {20 * f, 8 * f}; + if (name == "Picture") return {18 * f, 14 * f}; + if (name == "Script") return {30 * f, 16 * f}; + return {0, 0}; + } + // Docked panels kept at their least size: where a split leaves one side smaller, the split + // moves (when the other side has room to give). ImGui's own splitters know only one least + // size for everything. Gives what the node needs. + ImVec2 keepMinSizes(ImGuiDockNode* n) { + if (!n) return {0, 0}; + if (n->IsLeafNode()) { + const float f = ImGui::GetFontSize(); + ImVec2 need = n->IsCentralNode() ? ImVec2(16 * f, 12 * f) : ImVec2(0, 0); + for (ImGuiWindow* w : n->Windows) need = ImMax(need, panelMin(w->Name)); + if (need.y > 0 && !n->IsCentralNode()) need.y += ImGui::GetFrameHeight(); // (its tab) + return need; + } + ImGuiDockNode *a = n->ChildNodes[0], *b = n->ChildNodes[1]; + const ImVec2 na = keepMinSizes(a), nb = keepMinSizes(b); + if (!a->IsVisible) return nb; + if (!b->IsVisible) return na; + const int axis = n->SplitAxis; + const float gap = ImGui::GetStyle().DockingSeparatorSize, room = n->Size[axis] - gap; + float sa = a->Size[axis]; + if (sa < na[axis] && room - na[axis] >= nb[axis]) sa = na[axis]; + else if (room - sa < nb[axis] && room - nb[axis] >= na[axis]) sa = room - nb[axis]; + if (sa != a->Size[axis]) a->SizeRef[axis] = sa, b->SizeRef[axis] = room - sa; + ImVec2 need = ImMax(na, nb); + need[axis] = na[axis] + nb[axis] + gap; + return need; + } + + void defaultLayout(ImGuiID dock) { + layoutDone_ = true; + ImGuiDockNode* node = ImGui::DockBuilderGetNode(dock); + if (!touch() && node && (node->IsSplitNode() || node->Windows.Size)) return; // (the user's own, from the .ini) + ImGui::DockBuilderRemoveNodeChildNodes(dock); + ImGui::DockBuilderSetNodeSize(dock, ImGui::GetMainViewport()->WorkSize); // (splits are shares of this) + ImGuiID center = dock; + if (touch() && portrait_) { // a phone held upright: the view on top, the panels as tabs under it + const ImGuiID bottom = ImGui::DockBuilderSplitNode(center, ImGuiDir_Down, 0.36f, nullptr, ¢er); + const ImVec2 size = ImGui::GetMainViewport()->WorkSize; + ImGui::DockBuilderSetNodeSize(bottom, ImVec2(size.x, size.y * 0.36f)); + for (const char* w : {"Toolbox", "Tool options", "Properties", "Layers", "Picture"}) ImGui::DockBuilderDockWindow(w, bottom); + ImGui::DockBuilderFinish(dock); + showToolbox_ = true; // (its tab first) + return; + } + if (touch()) { // a phone: tools down the left, the panels on the right (shown when asked for) + ImGuiID left = ImGui::DockBuilderSplitNode(center, ImGuiDir_Left, 0.2f, nullptr, ¢er); + const ImGuiID leftBottom = ImGui::DockBuilderSplitNode(left, ImGuiDir_Down, 0.45f, nullptr, &left); + const ImGuiID right = ImGui::DockBuilderSplitNode(center, ImGuiDir_Right, 0.36f, nullptr, ¢er); + ImGui::DockBuilderDockWindow("Toolbox", left); + ImGui::DockBuilderDockWindow("Tool options", leftBottom); + ImGui::DockBuilderDockWindow("Properties", right); + ImGui::DockBuilderDockWindow("Layers", right); + ImGui::DockBuilderDockWindow("Picture", right); + return ImGui::DockBuilderFinish(dock); + } + // (shares of the window, but never less than the panels' least sizes) + const float wide = ImGui::GetMainViewport()->WorkSize.x, leftShare = std::min(0.3f, std::max(0.14f, (panelMin("Toolbox").x + 8) / wide)); + const float rightShare = std::min(0.45f, std::max(0.22f, (panelMin("Properties").x + 8) / (wide * (1 - leftShare)))); + ImGuiID left = ImGui::DockBuilderSplitNode(center, ImGuiDir_Left, leftShare, nullptr, ¢er); + const ImGuiID leftBottom = ImGui::DockBuilderSplitNode(left, ImGuiDir_Down, 0.5f, nullptr, &left); + ImGuiID right = ImGui::DockBuilderSplitNode(center, ImGuiDir_Right, rightShare, nullptr, ¢er); + const ImGuiID rightBottom = ImGui::DockBuilderSplitNode(right, ImGuiDir_Down, 0.66f, nullptr, &right); + const ImGuiID bottom = ImGui::DockBuilderSplitNode(center, ImGuiDir_Down, 0.04f, nullptr, ¢er); + const ImGuiID picture = ImGui::DockBuilderSplitNode(center, ImGuiDir_Right, 0.45f, nullptr, ¢er); // (the Paint tab's) + ImGui::DockBuilderDockWindow("Picture", picture); + ImGui::DockBuilderDockWindow("Script", picture); + ImGui::DockBuilderDockWindow("Toolbox", left); + ImGui::DockBuilderDockWindow("Tool options", leftBottom); + ImGui::DockBuilderDockWindow("Layers", right); + ImGui::DockBuilderDockWindow("Properties", rightBottom); + ImGui::DockBuilderDockWindow("Status", bottom); + if (ImGuiDockNode* b = ImGui::DockBuilderGetNode(bottom)) b->SetLocalFlags(ImGuiDockNodeFlags_NoTabBar); + ImGui::DockBuilderFinish(dock); + } + + void menus() { + if (ImGui::BeginMenu("File")) { + if (ImGui::MenuItem("New", "Ctrl+N")) whenSaved(2); + if (ImGui::MenuItem("Open...", "Ctrl+O")) whenSaved(3); + if (ImGui::MenuItem("Save", "Ctrl+S")) path_.empty() ? dialog(2) : void(saveFile(path_)); + if (ImGui::MenuItem("Save as...", "Ctrl+Shift+S")) dialog(2); + ImGui::Separator(); + if (ImGui::MenuItem("Export (GLB, glTF, OBJ, STL, PLY)...", "Ctrl+E")) dialog(3); + if (ImGui::BeginMenu("Autosave")) { + static const int every[] = {1, 2, 5, 10, 0}; + for (int m : every) + if (ImGui::MenuItem(m ? std::format("Every {} minute{}", m, m > 1 ? "s" : "").c_str() : "Off", nullptr, fry_autosave.i() == m)) + keel::execute(std::format("fry_autosave {}", m)); + ImGui::Separator(); + if (ImGui::MenuItem("Recover autosaved work...")) findAutosaves(), showRecover_ = !recover_.empty(); + ImGui::EndMenu(); + } + if (ImGui::MenuItem("Save a picture of the view...", "F12")) dialog(5); + ImGui::Separator(); + if (ImGui::MenuItem("Quit", "Ctrl+Q")) whenSaved(1); + ImGui::EndMenu(); + } + if (ImGui::BeginMenu("Edit")) { + if (ImGui::MenuItem("Undo", "Ctrl+Z", false, !undo_.empty())) undo(); + if (ImGui::MenuItem("Redo", "Ctrl+Y", false, !redo_.empty())) redo(); + ImGui::Separator(); + if (ImGui::MenuItem("Duplicate", "Ctrl+D", false, mode_ == Mode::Object)) duplicateSelected(); + if (ImGui::MenuItem("Join into one", "Ctrl+J", false, mode_ == Mode::Object && selected_.size() > 1)) joinSelected(); + if (ImGui::MenuItem("Delete", "Del")) deleteSelected(); + ImGui::Separator(); + if (ImGui::MenuItem(mode_ == Mode::Edit ? "Stop editing the shape" : "Edit the shape", "Tab")) mode_ == Mode::Edit ? leaveEdit() : enterEdit(); + ImGui::EndMenu(); + } + if (ImGui::BeginMenu("Select")) { + if (ImGui::MenuItem("All", "Ctrl+A")) selectAll(); + if (ImGui::MenuItem("None", "Alt+A")) selectNone(); + if (ImGui::MenuItem("Invert", "Ctrl+I")) invertSelection(); + ImGui::Separator(); + ImGui::BeginDisabled(mode_ != Mode::Edit); + if (ImGui::MenuItem("More (grow)", "Ctrl +")) growSelection(); + if (ImGui::MenuItem("Less (shrink)", "Ctrl -")) shrinkSelection(); + if (ImGui::MenuItem("Everything joined to it", "L")) selectLinked(); + ImGui::MenuItem("A loop: Alt+click or double-click", nullptr, false, false); + ImGui::EndDisabled(); + ImGui::Separator(); + if (ImGui::MenuItem("Select with a box", nullptr, selShape_ == SelShape::Box)) selShape_ = SelShape::Box; + if (ImGui::MenuItem("Select with a brush", nullptr, selShape_ == SelShape::Brush)) selShape_ = SelShape::Brush; + if (ImGui::MenuItem("Select with a lasso", nullptr, selShape_ == SelShape::Lasso)) selShape_ = SelShape::Lasso; + ImGui::EndMenu(); + } + if (ImGui::BeginMenu("Add")) { + addMenuItems(); + pluginMenuItems("add"); + ImGui::EndMenu(); + } + if (ImGui::BeginMenu("Object")) { + const bool many = selected_.size() >= 2, any = !selected_.empty(); + if (ImGui::MenuItem("Go to it", "F", false, any)) goTo(selected_); + if (ImGui::MenuItem("Duplicate", "Ctrl+D", false, any)) duplicateSelected(); + if (ImGui::MenuItem("Make copies (in a row, round)...", nullptr, false, any)) showCopies_ = true; + if (ImGui::MenuItem("Group into a folder", "Ctrl+G", false, any)) groupSelected(); + ImGui::Separator(); + ImGui::TextDisabled("Two or more: the last picked is kept"); + if (ImGui::MenuItem("Add them together", "Ctrl+Shift++", false, many)) combineSelected(Combine::Add); + if (ImGui::MenuItem("Take the others out of it", "Ctrl+Shift+-", false, many)) combineSelected(Combine::Subtract); + if (ImGui::MenuItem("Keep only where they overlap", nullptr, false, many)) combineSelected(Combine::Overlap); + if (ImGui::MenuItem("Join (just as they are)", "Ctrl+J", false, many)) joinSelected(); + ImGui::Separator(); + if (ImGui::MenuItem("Make it thick (or hollow)...", nullptr, false, any)) showThicken_ = true; + if (ImGui::MenuItem("Join flat faces side by side", nullptr, false, any)) cleanFlatFaces(); + pluginMenuItems("object"); + ImGui::EndMenu(); + } + if (ImGui::BeginMenu("Shape")) { + const bool ed = mode_ == Mode::Edit; + if (!ed) ImGui::TextDisabled("Edit an object's shape first (Tab)"); + ImGui::BeginDisabled(!ed); + if (ImGui::MenuItem("Extrude", "X")) setTool(Tool::Extrude); + if (ImGui::MenuItem("Inset", "I")) setTool(Tool::Inset); + if (ImGui::MenuItem("Round edges", "B")) setTool(Tool::Bevel); + if (ImGui::MenuItem("Loop cut", "C")) setTool(Tool::LoopCut); + ImGui::Separator(); + if (ImGui::MenuItem("Cut every face in four")) subdivideNow(false); + if (ImGui::MenuItem("Smooth it all (cut and round)")) subdivideNow(true); + if (ImGui::MenuItem("Merge points", "M")) mergeNow(); + if (ImGui::MenuItem("Make a face")) makeFaceNow(); + if (ImGui::MenuItem("Flip faces")) flipNow(); + ImGui::Separator(); + if (ImGui::MenuItem("Smooth points")) smoothPointsNow(); + if (ImGui::MenuItem("Push out / pull in...", "Alt+S")) startPush(); + if (ImGui::MenuItem("Bridge two faces")) bridgeNow(); + ImGui::MenuItem("Soft selection", "Shift+O", &softSel_); + if (ImGui::MenuItem("Make into its own object", "P")) separateSelected(); + if (ImGui::MenuItem("Delete", "Del")) deleteElements(); + ImGui::EndDisabled(); + ImGui::EndMenu(); + } + if (ImGui::BeginMenu("View")) { + if (ImGui::MenuItem("Solid (for modelling)", "Z", shading_ == Shading::Solid)) shading_ = Shading::Solid; + if (ImGui::MenuItem("Rendered (sky, sun, shadows)", "Z", shading_ == Shading::Rendered)) shading_ = Shading::Rendered; + if (ImGui::MenuItem("Wireframe", "Shift+Z", shading_ == Shading::Wire)) shading_ = Shading::Wire; + ImGui::MenuItem("X-ray (see and pick through)", "Alt+Z", &xray_); + ImGui::MenuItem("Grid", nullptr, &showGrid_); + ImGui::MenuItem("Ground (in Rendered)", nullptr, &scene_.look.ground); + if (ImGui::BeginMenu("Appearance")) { + const char* looks[] = {"Like the desktop", "Dark", "Light"}; + for (int i = 0; i < 3; ++i) + if (ImGui::MenuItem(looks[i], nullptr, fry_theme.i() == i)) keel::execute(std::format("fry_theme {}", i)); + ImGui::EndMenu(); + } + ImGui::Separator(); + if (ImGui::MenuItem("Perspective", "Numpad 5", view_ == View::Perspective)) leaveFlatView(); + if (ImGui::MenuItem("Front", "Numpad 1", view_ == View::Front)) setView(View::Front); + if (ImGui::MenuItem("Back", "Shift+Numpad 1", view_ == View::Back)) setView(View::Back); + if (ImGui::MenuItem("Left side", "Numpad 3", view_ == View::Left)) setView(View::Left); + if (ImGui::MenuItem("Right side", "Shift+Numpad 3", view_ == View::Right)) setView(View::Right); + if (ImGui::MenuItem("Top", "Numpad 7", view_ == View::Top)) setView(View::Top); + if (ImGui::MenuItem("Bottom", "Shift+Numpad 7", view_ == View::Bottom)) setView(View::Bottom); + ImGui::Separator(); + if (ImGui::MenuItem("Frame selected", "F")) frameSelected(); + if (ImGui::MenuItem("Frame everything", "Home")) frameAll(); + if (ImGui::MenuItem("Show only the selected (again: all)", "/", false, !selected_.empty())) showOnly(selected_); + ImGui::EndMenu(); + } + if (ImGui::BeginMenu("Script")) { + ImGui::MenuItem("The Script panel", nullptr, &showScript_); + if (ImGui::MenuItem("Run it", "F5", false, currentScript())) runScriptNow(); + if (!scene_.scripts.empty() && ImGui::BeginMenu("This file's scripts")) { + for (size_t i = 0; i < scene_.scripts.size(); ++i) + if (ImGui::MenuItem(scene_.scripts[i].name.c_str(), nullptr, i == script_)) script_ = i, showScript_ = true; + ImGui::EndMenu(); + } + ImGui::Separator(); + if (ImGui::MenuItem("New script")) addScript("Script", kFirstScript); + if (ImGui::MenuItem("Open a script...")) showScript_ = true, dialog(10); + if (ImGui::MenuItem("Save the script as...", nullptr, false, currentScript())) dialog(11); + exampleScripts(); + ImGui::Separator(); + if (ImGui::BeginMenu("Plug-ins")) { + bool any = false; + for (size_t i = 0; i < plugins_.size(); ++i) + if (plugins_[i].p) + for (size_t c = 0; c < plugins_[i].p->commands.size(); ++c) + if (plugins_[i].p->commands[c].menu == "script") pluginMenuItem(i, c), any = true; + if (any) ImGui::Separator(); + if (ImGui::MenuItem("Plug-ins...")) showPlugins_ = true; + if (ImGui::MenuItem("Open the plug-ins folder")) openPluginsFolder(); + if (ImGui::MenuItem("Load them again")) loadPlugins(); + ImGui::EndMenu(); + } + if (ImGui::MenuItem("What scripts can do")) showScript_ = showScriptHelp_ = true; + ImGui::EndMenu(); + } + if (ImGui::BeginMenu("Help")) { + if (ImGui::MenuItem("Keys and mouse", "F1")) showHelp_ = true; + if (ImGui::MenuItem("About Fry3DX")) showAbout_ = true; + ImGui::EndMenu(); + } + } + void menuBar() { + if (!ImGui::BeginMainMenuBar()) return; + if (haveLogo_) { + const float h = ImGui::GetFrameHeight() - 4; + ImGui::SetCursorPosY(ImGui::GetCursorPosY() + 1); + logoImage(h); + } + ImGui::PushFont(keel::ui::font(keel::ui::Font::Bold), 0.0f), ImGui::TextColored(accentText(), "Fry3DX"), ImGui::PopFont(); + if (!touch()) menus(); + else if (ImGui::BeginMenu("Menu")) { // (a phone: one menu, the others inside it) + menus(); + ImGui::EndMenu(); + } + const std::string file = (path_.empty() ? "Untitled" : fileName(path_)) + (dirty_ ? " *" : ""); + // The tabs: what you're doing (as wide as there's room for between the menus and the file's + // name; the pictures alone when it's tight). + { + const char* names[] = {"Model", "Paint", "Layout", "Render", "Print"}; + const Icon icons[] = {Icon::Objects, Icon::PaintBrush, Icon::Layout, Icon::Render, Icon::Print}; + const char* tips[] = {"Make the shape (Ctrl+1)", "Paint pictures onto it (Ctrl+2)", "Change how its picture is laid out: move, turn and size its frames (Ctrl+3)", + "Light it and save pictures of it (Ctrl+4)", "Get it ready for a 3D printer: size, checks, the printer's plate (Ctrl+5)"}; + const float h = ImGui::GetFrameHeight(), from = ImGui::GetCursorPosX() + 20.0f; + const float room = ImGui::GetWindowWidth() - from - (touch() ? 8.0f : ImGui::CalcTextSize(file.c_str()).x + 32.0f); + float tw = std::min(touch() ? 72.0f : 92.0f, room / 5 - 4); + const bool words = tw >= h + 2 + ImGui::CalcTextSize("Layout").x + 6; + if (!words) tw = h + 4; + ImGui::SetCursorPosX(std::max(from, std::min((ImGui::GetWindowWidth() - (tw + 4) * 5) * 0.5f, from + room - (tw + 4) * 5))); + for (int i = 0; i < 5; ++i) { + if (i) ImGui::SameLine(0, 4); + ImGui::PushID(i); + if (wideButton("##tab", icons[i], words ? names[i] : "", int(tab_) == i, tw, words ? tips[i] : std::format("{}: {}", names[i], tips[i]).c_str(), h)) setTab(Tab(i)); + ImGui::PopID(); + } + } + if (touch()) return ImGui::EndMainMenuBar(); // (no room on a phone) + ImGui::SameLine(ImGui::GetWindowWidth() - ImGui::CalcTextSize(file.c_str()).x - 16); + ImGui::TextDisabled("%s", file.c_str()); + ImGui::EndMainMenuBar(); + } + + void addMenuItems() { + ImGui::SeparatorText("Shapes"); + for (const ShapeRecipe& r : shapeRecipes()) + if (!r.starter && ImGui::MenuItem(r.name)) addShape(r.id); + ImGui::SeparatorText("Starter models"); + for (const ShapeRecipe& r : shapeRecipes()) + if (r.starter && ImGui::MenuItem(r.name)) addShape(r.id); + ImGui::Separator(); + if (ImGui::MenuItem("Draw a shape", "D")) { + if (mode_ == Mode::Edit) leaveEdit(); + setTool(Tool::Draw); + } + if (ImGui::MenuItem("Reference picture...")) dialog(4); + } + + // How the last item's key looks: filled when it's the one in use, sunk while held. + static keel::ui::KeyState keyState(bool on) { + using keel::ui::KeyState; + return on ? KeyState::On : ImGui::IsItemActive() ? KeyState::Pressed : ImGui::IsItemHovered() ? KeyState::Hovered : KeyState::Normal; + } + // A square button with a picture on it (the accent when it's the one in use). + bool iconButton(const char* id, Icon icon, bool on, const char* tip, float size = 38.0f) { + const ImVec2 p = ImGui::GetCursorScreenPos(); + const bool hit = ImGui::InvisibleButton(id, ImVec2(size, size)); + const keel::ui::KeyState k = keyState(on); + ImDrawList* dl = ImGui::GetWindowDrawList(); + keel::ui::drawKey(dl, p, ImVec2(p.x + size, p.y + size), 7.0f, k); + const float sink = k == keel::ui::KeyState::Pressed ? 1.0f : 0.0f; + drawIcon(dl, icon, ImVec2(p.x + size * 0.14f, p.y + size * 0.14f + sink), size * 0.72f, keel::ui::keyText(k)); + const bool hov = ImGui::IsItemHovered(); + if (hov && tip) ImGui::SetTooltip("%s", tip); + return hit; + } + // A wide one, with words beside the picture. + bool wideButton(const char* id, Icon icon, const char* label, bool on, float width, const char* tip = nullptr, float h = 30.0f) { + const ImVec2 p = ImGui::GetCursorScreenPos(); + const bool hit = ImGui::InvisibleButton(id, ImVec2(width, h)); + const keel::ui::KeyState k = keyState(on); + ImDrawList* dl = ImGui::GetWindowDrawList(); + keel::ui::drawKey(dl, p, ImVec2(p.x + width, p.y + h), 7.0f, k); + const float sink = k == keel::ui::KeyState::Pressed ? 1.0f : 0.0f; + drawIcon(dl, icon, ImVec2(p.x + h * 0.15f, p.y + h * 0.13f + sink), h * 0.74f, keel::ui::keyText(k)); + dl->AddText(ImVec2(p.x + h + 2, p.y + (h - ImGui::GetFontSize()) * 0.5f + sink), keel::ui::keyText(k), label); + const bool hov = ImGui::IsItemHovered(); + if (hov && tip) ImGui::SetTooltip("%s", tip); + return hit; + } + // Buttons in rows, as many as fit. + template + void grid(int count, float size, F each) { + const float gap = 5.0f; + const int perRow = std::max(1, int((ImGui::GetContentRegionAvail().x + gap) / (size + gap))); + for (int i = 0; i < count; ++i) { + if (i % perRow) ImGui::SameLine(0, gap); + each(i); + } + } + + void toolbox() { + if (std::exchange(showToolbox_, false)) ImGui::SetNextWindowFocus(); + ImGui::SetNextWindowSizeConstraints(panelMin("Toolbox"), ImVec2(FLT_MAX, FLT_MAX)); + ImGui::Begin("Toolbox", nullptr, ImGuiWindowFlags_NoCollapse); + const float w = ImGui::GetContentRegionAvail().x; + if (tab_ == Tab::Paint) { + paintToolbox(w); + return ImGui::End(); + } + if (tab_ == Tab::Layout) { + layoutToolbox(w); + return ImGui::End(); + } + if (tab_ == Tab::Render || tab_ == Tab::Print) { + const Tool tools[] = {Tool::Select, Tool::Move, Tool::Rotate, Tool::Scale}; + grid(4, 38.0f, [&](int i) { + const ToolInfo& t = kTools[int(tools[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, tool_ == t.tool, tip.c_str())) setTool(t.tool); + }); + ImGui::Spacing(); + if (tab_ == Tab::Print) printToolbox(w); + else if (wideButton("##save", Icon::Picture, "Save a picture", false, w, "Saves what the view shows, without the panels or gizmos (F12)")) dialog(5); + return ImGui::End(); + } + // The mode: whole objects, or the shape of one. + if (wideButton("##objects", Icon::Objects, "Objects", mode_ == Mode::Object, (w - 5) / 2, "Work with whole objects")) leaveEdit(); + ImGui::SameLine(0, 5); + if (wideButton("##shape", Icon::Shape, "Shape", mode_ == Mode::Edit, (w - 5) / 2, "Edit the selected object's shape: its points, edges and faces (Tab, or double-click it)")) enterEdit(); + if (mode_ == Mode::Edit) { + const Icon icons[] = {Icon::Points, Icon::Edges, Icon::Faces}; + const char* tips[] = {"Points (1)", "Edges (2)", "Faces (3)"}; + const char* names[] = {"Points", "Edges", "Faces"}; + const float third = (w - 10) / 3; + for (int i = 0; i < 3; ++i) { + if (i) ImGui::SameLine(0, 5); + ImGui::PushID(i); + if (wideButton("##elem", icons[i], names[i], int(elem_) == i, third, tips[i])) elem_ = Elem(i), clearElements(); + ImGui::PopID(); + } + } + ImGui::Spacing(); + // The tools, as pictures (hover for the name and key). + std::vector tools; + for (const ToolInfo& t : kTools) + if ((mode_ == Mode::Object && t.objectMode) || (mode_ == Mode::Edit && t.editMode)) tools.push_back(&t); + grid(int(tools.size()) + 1, 38.0f, [&](int i) { + if (i == int(tools.size())) { + if (iconButton("##add", Icon::Add, false, "Add a shape or a starter model")) ImGui::OpenPopup("add"); + return; + } + const ToolInfo& t = *tools[size_t(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, tool_ == t.tool, tip.c_str())) setTool(t.tool); + }); + if (ImGui::BeginPopup("add")) { + addMenuItems(); + ImGui::EndPopup(); + } + ImGui::Spacing(); + for (const ToolInfo& t : kTools) + if (t.tool == tool_) ImGui::TextColored(accentText(), "%s", t.name); + ImGui::End(); + } + + void paintToolbox(float w) { + const float half = (w - ImGui::GetStyle().ItemSpacing.x) * 0.5f; + if (wideButton("##colour-side", Icon::PaintBrush, "Colour", !sculpting_, half, "Paint colours on it (Tab)")) sculpting_ = false, endSculpt(); + ImGui::SameLine(); + if (wideButton("##shape-side", Icon::Sculpt, "Sculpt", sculpting_, half, "Push its surface about like clay (Tab)")) sculpting_ = true, endStroke(); + ImGui::Spacing(); + if (sculpting_) { + grid(int(std::size(kSculptTools)), 38.0f, [&](int i) { + const SculptToolInfo& t = kSculptTools[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, sculptBrush_ == t.brush, tip.c_str())) sculptBrush_ = t.brush; + }); + for (const SculptToolInfo& t : kSculptTools) + if (t.brush == sculptBrush_) ImGui::TextColored(accentText(), "%s", t.name); + return; + } + grid(int(std::size(kPaintTools)), 38.0f, [&](int i) { + const PaintToolInfo& t = kPaintTools[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, paintTool_ == t.tool, tip.c_str())) paintTool_ = t.tool; + }); + ImGui::Spacing(); + // The colour, big, and a few to pick from. + ImGui::ColorButton("##colour", ImVec4(paintColor_.r, paintColor_.g, paintColor_.b, 1.0f), ImGuiColorEditFlags_NoTooltip, ImVec2(w, 34)); + static const glm::vec3 palette[] = {{0.08f, 0.08f, 0.09f}, {0.95f, 0.95f, 0.95f}, {0.55f, 0.55f, 0.57f}, {0.85f, 0.2f, 0.12f}, {1.0f, 0.55f, 0.1f}, {0.98f, 0.85f, 0.2f}, + {0.3f, 0.7f, 0.25f}, {0.15f, 0.45f, 0.85f}, {0.45f, 0.25f, 0.7f}, {0.45f, 0.3f, 0.18f}, {0.75f, 0.6f, 0.45f}, {0.95f, 0.6f, 0.7f}}; + grid(int(std::size(palette)), 20.0f, [&](int i) { + ImGui::PushID(i); + if (ImGui::ColorButton("##p", ImVec4(palette[i].r, palette[i].g, palette[i].b, 1.0f), 0, ImVec2(20, 20))) paintColor_ = glm::vec4(palette[i], paintColor_.a); + ImGui::PopID(); + }); + for (const PaintToolInfo& t : kPaintTools) + if (t.tool == paintTool_) ImGui::TextColored(accentText(), "%s", t.name); + } + + void sculptOptions(float w) { + for (const SculptToolInfo& t : kSculptTools) + if (t.brush == sculptBrush_) ImGui::SeparatorText(t.name), ImGui::TextWrapped("%s", t.hint); + ImGui::Spacing(); + ImGui::SetNextItemWidth(w); + ImGui::SliderFloat("##size", &sculptPx_, 4.0f, 400.0f, "size %.0f px", ImGuiSliderFlags_Logarithmic); + if (sculptBrush_ != SculptBrush::Grab) { + ImGui::SetNextItemWidth(w); + ImGui::SliderFloat("##strength", &sculptStrength_, 0.02f, 1.0f, "strength %.2f"); + } + ImGui::Checkbox("Same on both sides (X)", &sculptMirror_); + if (ImGui::IsItemHovered()) ImGui::SetTooltip("What you do on its left is done on its right too"); + if (Object* o = selected_.size() == 1 ? find(active_) : nullptr) { + ImGui::SeparatorText(o->name.c_str()); + ImGui::TextDisabled("%zu points", o->mesh.verts.size()); + size_t after = 0; + for (const auto& f : o->mesh.faces) after += f.size(); + ImGui::BeginDisabled(after > 400000 || o->locked); + if (ImGui::Button("Finer, for small details", ImVec2(w, 0))) { + pushUndo(); + if (o->hasFinish()) o->mesh = finishedMesh(*o), o->mirror = 0, o->round = 0, o->smoothLevels = 0; + handMade(*o); + subdivide(o->mesh, false); + touched(o->id); + } + ImGui::EndDisabled(); + if (ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled)) ImGui::SetTooltip("Each face split in four: the same shape, more points to push about"); + if (ImGui::Button("Smooth all of it", ImVec2(w, 0)) && !o->locked) { + pushUndo(); + handMade(*o); + const SculptMesh sm(o->mesh); + const glm::vec3 mid = (boundsMin(o->mesh) + boundsMax(o->mesh)) * 0.5f; + sculptDab(o->mesh, sm, SculptBrush::Smooth, {mid, glm::distance(boundsMin(o->mesh), boundsMax(o->mesh)) * 2.0f, 0.5f}); + touched(o->id); + } + } + ImGui::Spacing(); + ImGui::TextDisabled("Ctrl: the other way Shift: smooth\n[ and ]: brush size 1-6: brushes"); + } + + void paintOptions(float w) { + if (sculpting_) return sculptOptions(w); + for (const PaintToolInfo& t : kPaintTools) + if (t.tool == paintTool_) ImGui::SeparatorText(t.name); + if (paintTool_ == PaintTool::Brush || paintTool_ == PaintTool::Eraser) { + ImGui::SetNextItemWidth(w); + ImGui::SliderFloat("##size", &brushPx_, 1.0f, 300.0f, "size %.0f px", ImGuiSliderFlags_Logarithmic); + ImGui::SetNextItemWidth(w); + ImGui::SliderFloat("##hard", &hardness_, 0.0f, 1.0f, "hard edge %.2f"); + ImGui::SetNextItemWidth(w); + ImGui::SliderFloat("##strength", &paintColor_.a, 0.02f, 1.0f, "strength %.2f"); + } + if (paintTool_ == PaintTool::Fill) { + ImGui::SetNextItemWidth(w); + ImGui::SliderFloat("##tol", &fillTolerance_, 0.0f, 1.0f, "how alike %.2f"); + if (ImGui::IsItemHovered()) ImGui::SetTooltip("How different a colour can be and still be filled"); + } + if (paintTool_ == PaintTool::Brush || paintTool_ == PaintTool::Fill || paintTool_ == PaintTool::Picker) { + ImGui::SetNextItemWidth(w); + ImGui::ColorPicker3("##picker", &paintColor_.x, ImGuiColorEditFlags_NoSidePreview | ImGuiColorEditFlags_NoSmallPreview | ImGuiColorEditFlags_PickerHueBar); + } + if (paintTool_ == PaintTool::Eraser) ImGui::TextWrapped("Paints the object's plain colour back (its colour in the Model tab)."); + if (paintTool_ == PaintTool::Picture) { + if (ImGui::Button("Paste (Ctrl+V)", ImVec2(w, 0))) pasteFromClipboard(); + if (ImGui::Button("Add a picture from a file...", ImVec2(w, 0))) dialog(6); + if (PictureLayer* l = editLayer()) { + Object* o = find(ledit_.object); + ImGui::SeparatorText(l->name.c_str()); + if (ImGui::Button("Lock it in place (Enter)", ImVec2(w, 0))) lockLayer(); + if (ImGui::Button(ledit_.fresh ? "Take it away (Esc)" : "Delete it (Del)", ImVec2(w, 0))) ledit_.fresh ? cancelFloating() : deleteLayer(*o, ledit_.index); + ImGui::SetNextItemWidth(w); + undoable([&] { return ImGui::SliderFloat("##op", &l->opacity, 0.0f, 1.0f, "see-through %.2f"); }); + if (l->sticker) { + float size[2] = {l->width, l->height}; + ImGui::SetNextItemWidth(w); + if (undoable([&] { return ImGui::DragFloat2("##wh", size, 0.002f, 0.002f, 100.0f, "%.3f m"); })) l->width = size[0], l->height = size[1]; + } else if (o && o->picture) { + if (ImGui::Button("Cover the whole picture", ImVec2(w, 0))) { + pushUndo(); + const float W = float(o->picture->w), H = float(o->picture->h); + l->corners = {glm::vec2(0, 0), glm::vec2(W, 0), glm::vec2(W, H), glm::vec2(0, H)}; + } + if (ImGui::Button("Square it up again", ImVec2(w, 0))) { + pushUndo(); + glm::vec2 c(0.0f); + for (const glm::vec2& p : l->corners) c += p * 0.25f; + const float across = (glm::distance(l->corners[0], l->corners[1]) + glm::distance(l->corners[3], l->corners[2])) * 0.5f; + const glm::vec2 half(across * 0.5f, across * 0.5f * float(l->image->h) / float(std::max(l->image->w, 1))); + 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)}; + } + } + ImGui::Separator(); + } + ImGui::TextDisabled("New pictures go"); + int where = pasteOnSurface_ ? 0 : 1; + ImGui::SetNextItemWidth(w); + if (ImGui::Combo("##where", &where, "On the model (a sticker)\0On the flat picture\0")) pasteOnSurface_ = where == 0; + ImGui::TextWrapped(pasteOnSurface_ ? "Copy a picture in any app (GIMP, a browser...) and paste it. Drag it round the model: over an edge it bends like a sticker. " + "Corners size it (Shift: stretch), the round handle turns it (Ctrl: steps). Enter locks it as a layer; click it in the Picture panel to move it again." + : "Copy a picture in any app and paste it. In the Picture panel drag its corners where you want them: it bends to fit " + "(Shift keeps its shape; drag inside to move it). Enter locks it as a layer; click it there to move it again."); + } + } + + void softChoice(float w) { + if (mode_ != Mode::Edit) return; + ImGui::Checkbox("Soft selection (Shift+O)", &softSel_); + if (ImGui::IsItemHovered()) ImGui::SetTooltip("Points near the selection follow it, less the further they are"); + if (!softSel_) return; + ImGui::SetNextItemWidth(w); + ImGui::DragFloat("##reach", &softReach_, 0.002f, 0.001f, 1000.0f, "reaches %.3f m"); + ImGui::TextDisabled("(the wheel while dragging too)"); + } + void toolOptions() { + ImGui::SetNextWindowSizeConstraints(panelMin("Tool options"), ImVec2(FLT_MAX, FLT_MAX)); + ImGui::Begin("Tool options", nullptr, ImGuiWindowFlags_NoCollapse); + const float w = ImGui::GetContentRegionAvail().x; + if (tab_ == Tab::Paint) { + paintOptions(w); + return ImGui::End(); + } + if (tab_ == Tab::Layout) { + layoutOptions(w); + return ImGui::End(); + } + if (tab_ == Tab::Print && printTool_ != PrintTool::None) { + printOptions(w); + return ImGui::End(); + } + const char* name = ""; + for (const ToolInfo& t : kTools) + if (t.tool == tool_) name = t.name; + ImGui::SeparatorText(name); + auto orientChoice = [&] { + ImGui::TextDisabled("Arrows point"); + int o = int(orient()); + const char* items = mode_ == Mode::Edit ? "The object's way\0The world's way\0Out of the faces\0" : "The object's way\0The world's way\0"; + ImGui::SetNextItemWidth(w); + if (ImGui::Combo("##orient", &o, items)) orient() = Orient(o); + }; + auto snapChoice = [&](bool angle) { + ImGui::Checkbox("Snap", &snap_); + if (ImGui::IsItemHovered()) ImGui::SetTooltip("In steps (holding Ctrl does the opposite)"); + if (!snap_) return; + ImGui::SetNextItemWidth(w); + if (angle) ImGui::DragFloat("##snapangle", &snapAngle_, 0.5f, 1.0f, 90.0f, "every %.0f deg"); + else ImGui::DragFloat("##snapmove", &snapMove_, 0.001f, 0.001f, 10.0f, "every %.3f m"); + }; + switch (tool_) { + case Tool::Select: { + ImGui::TextDisabled("Drag to select with"); + const Icon icons[] = {Icon::Box, Icon::Brush, Icon::Lasso}; + const char* tips[] = {"A box", "A brush: paint over what you want", "A lasso: draw round it"}; + for (int i = 0; i < 3; ++i) { + if (i) ImGui::SameLine(0, 5); + ImGui::PushID(i); + if (iconButton("##shape", icons[i], int(selShape_) == i, tips[i], 34.0f)) selShape_ = SelShape(i); + ImGui::PopID(); + } + if (selShape_ == SelShape::Brush) { + ImGui::SetNextItemWidth(w); + ImGui::SliderFloat("##brush", &brushRadius_, 4.0f, 200.0f, "brush %.0f px"); + } + ImGui::Checkbox("See through (X-ray)", &xray_); + if (ImGui::IsItemHovered()) ImGui::SetTooltip("Select things behind others too (Alt+Z)"); + break; + } + case Tool::Move: + orientChoice(); + snapChoice(false); + softChoice(w); + break; + case Tool::Rotate: { + orientChoice(); + ImGui::TextDisabled("Turn around"); + int p = rotatePivot_ == Pivot::Origin ? 1 : 0; + ImGui::SetNextItemWidth(w); + if (ImGui::Combo("##rpivot", &p, "The middle\0The origin\0")) rotatePivot_ = p ? Pivot::Origin : Pivot::Middle; + snapChoice(true); + softChoice(w); + break; + } + case Tool::Scale: { + if (mode_ == Mode::Edit) orientChoice(); + ImGui::TextDisabled("What stays put"); + int p = scalePivot_ == Pivot::Opposite ? 0 : scalePivot_ == Pivot::Middle ? 1 : scalePivot_ == Pivot::Bottom ? 2 : 3; + ImGui::SetNextItemWidth(w); + if (ImGui::Combo("##spivot", &p, "The other side\0The middle\0The bottom\0The origin\0")) + scalePivot_ = p == 0 ? Pivot::Opposite : p == 1 ? Pivot::Middle : p == 2 ? Pivot::Bottom : Pivot::Origin; + if (ImGui::IsItemHovered()) + ImGui::SetTooltip("The other side: drag a side, the opposite side stays (like stretching in a paint program).\nThe middle: both sides move.\nThe bottom: it stays standing on the ground."); + snapChoice(false); + softChoice(w); + break; + } + case Tool::Draw: + ImGui::SetNextItemWidth(w); + ImGui::DragFloat("##thick", &drawThickness_, 0.001f, 0.0f, 10.0f, "thickness %.3f m"); + ImGui::TextWrapped("Drawn on the face you start on, or on %s.", + view_ == View::Perspective || view_ == View::Top || view_ == View::Bottom ? "the ground (a side view draws upright)" : "the plane you look at"); + ImGui::TextDisabled("Drawn on an object:"); + ImGui::SetNextItemWidth(w); + ImGui::Combo("##drawon", &drawOnMode_, "A new object on it\0Added to it (raised)\0Cut into it\0"); + snapChoice(false); + if (!drawPts_.empty() && ImGui::Button("Finish (Enter)", ImVec2(w, 0))) finishShape(); + break; + case Tool::Extrude: + ImGui::SetNextItemWidth(w); + ImGui::DragFloat("##ext", &extrudeAmount_, 0.001f, -10, 10, "%.3f m"); + if (ImGui::Button("Extrude by that", ImVec2(w, 0))) extrudeNow(extrudeAmount_); + snapChoice(false); + break; + case Tool::Inset: + ImGui::SetNextItemWidth(w); + ImGui::SliderFloat("##ins", &insetAmount_, 0.01f, 0.99f, "%.2f of the way in"); + if (ImGui::Button("Inset by that", ImVec2(w, 0))) insetNow(insetAmount_); + break; + case Tool::Bevel: + ImGui::SetNextItemWidth(w); + ImGui::DragFloat("##bw", &bevelWidth_, 0.001f, 0.0f, 10.0f, "width %.3f m"); + ImGui::SetNextItemWidth(w); + ImGui::SliderInt("##bs", &bevelSteps_, 1, 10, bevelSteps_ == 1 ? "1 step (a flat cut)" : "%d steps round"); + if (ImGui::Button("Round them", ImVec2(w, 0))) bevelNow(); + break; + case Tool::LoopCut: + ImGui::SetNextItemWidth(w); + ImGui::SliderInt("##cuts", &cuts_, 1, 16, cuts_ == 1 ? "1 cut" : "%d cuts"); + ImGui::TextWrapped("Cuts go across four-sided faces, all the way round."); + break; + case Tool::Knife: { + ImGui::Checkbox("Cut right through", &knifeThrough_); + if (ImGui::IsItemHovered()) ImGui::SetTooltip("Through everything in the line's way, the back too (else only what you can see).\nThe line goes on past its ends."); + if (knifeThrough_) { + ImGui::TextDisabled("Then"); + ImGui::SetNextItemWidth(w); + ImGui::Combo("##after", &knifeAfter_, "Just add the edges\0Pieces of the one object\0Each piece its own object\0Delete the smaller piece\0"); + if (knifeAfter_ > 0) { + ImGui::TextDisabled("Inside, where it's cut"); + int fill = knifeFill_ ? 0 : 1; + ImGui::SetNextItemWidth(w); + if (ImGui::Combo("##fill", &fill, "Filled (solid)\0Left open (hollow)\0")) knifeFill_ = fill == 0; + } + } + ImGui::TextWrapped("Click points along the cut, or drag a straight line. Ctrl: at 45 degree steps. Enter or double-click cuts, Esc lets go. " + "Each face it crosses is cut from edge to edge."); + if (knifePts_.size() >= 2 && ImGui::Button("Cut (Enter)", ImVec2(w, 0))) { + std::vector pts = knifePts_; + knifePts_.clear(); + knifeApply(active_, pts); + } + break; + } + case Tool::Hole: { + ImGui::SetNextItemWidth(w); + ImGui::Combo("##shape", &holeShape_, "Round\0Rectangle\0Triangle\0Hexagon (for nuts)\0"); + const HoleShape shape = HoleShape(holeShape_); + // Ready-made sizes for 3D prints: screws go through round holes, nuts sit in hexagons + // (a little room added, as prints come out a touch tight). + struct Fit { + const char* name; + float across, deep; // mm + }; + static const Fit screws[] = {{"M2 screw (2.4 mm)", 2.4f, 0}, {"M2.5 screw (2.9 mm)", 2.9f, 0}, {"M3 screw (3.4 mm)", 3.4f, 0}, {"M4 screw (4.5 mm)", 4.5f, 0}, + {"M5 screw (5.5 mm)", 5.5f, 0}, {"M6 screw (6.6 mm)", 6.6f, 0}, {"M8 screw (9 mm)", 9.0f, 0}}; + static const Fit nuts[] = {{"M2 nut (4 mm, 1.6 thick)", 4.0f, 1.6f}, {"M2.5 nut (5 mm, 2 thick)", 5.0f, 2.0f}, {"M3 nut (5.5 mm, 2.4 thick)", 5.5f, 2.4f}, + {"M4 nut (7 mm, 3.2 thick)", 7.0f, 3.2f}, {"M5 nut (8 mm, 4 thick)", 8.0f, 4.0f}, {"M6 nut (10 mm, 5 thick)", 10.0f, 5.0f}, + {"M8 nut (13 mm, 6.5 thick)", 13.0f, 6.5f}}; + if (shape == HoleShape::Round || shape == HoleShape::Hexagon) { + const bool nut = shape == HoleShape::Hexagon; + ImGui::SetNextItemWidth(w); + if (ImGui::BeginCombo("##fit", nut ? "Size for a nut..." : "Size for a screw...")) { + for (const Fit& f : nut ? std::span(nuts) : std::span(screws)) + if (ImGui::Selectable(f.name)) { + holeSize_ = (f.across + (nut ? holeRoom_ * 1000 : 0)) / 1000; + if (nut) holeMode_ = 1, holeDepth_ = (f.deep + 0.2f) / 1000; + else holeMode_ = 0; + } + ImGui::EndCombo(); + } + if (nut) { + float room = holeRoom_ * 1000; + ImGui::SetNextItemWidth(w); + if (ImGui::SliderFloat("##room", &room, 0.0f, 0.8f, "+ %.2f mm room")) holeRoom_ = room / 1000; + if (ImGui::IsItemHovered()) ImGui::SetTooltip("Added to a nut's size so it pushes in (0.2 to 0.4 mm suits most printers)"); + } + } + if (shape == HoleShape::Round) { + ImGui::SetNextItemWidth(w); + ImGui::SliderInt("##sides", &holeSides_, 6, 96, "%d sides round"); + } + const char* acrossName = shape == HoleShape::Round ? "%.2f mm across" : shape == HoleShape::Rectangle ? "%.2f mm wide" : shape == HoleShape::Triangle ? "%.2f mm sides" : "%.2f mm across flats"; + float across = holeSize_ * 1000, tall = holeTall_ * 1000; + ImGui::SetNextItemWidth(w); + if (ImGui::DragFloat("##size", &across, 0.1f, 0.1f, 100000.0f, acrossName)) holeSize_ = across / 1000; + if (shape == HoleShape::Rectangle) { + ImGui::SetNextItemWidth(w); + if (ImGui::DragFloat("##tall", &tall, 0.1f, 0.1f, 100000.0f, "%.2f mm high")) holeTall_ = tall / 1000; + } + if (shape != HoleShape::Round) { + ImGui::SetNextItemWidth(w); + ImGui::SliderFloat("##turn", &holeTurn_, -180.0f, 180.0f, "turned %.0f degrees"); + } + ImGui::TextDisabled("Cut"); + ImGui::SetNextItemWidth(w); + ImGui::Combo("##mode", &holeMode_, "All the way through\0A pocket\0Only the outline (a new face)\0"); + if (holeMode_ == 1) { + float deep = holeDepth_ * 1000; + ImGui::SetNextItemWidth(w); + if (ImGui::DragFloat("##depth", &deep, 0.1f, 0.1f, 100000.0f, "%.2f mm deep")) holeDepth_ = deep / 1000; + } + ImGui::TextWrapped(holeMode_ == 2 ? "Press on a face and drag out the size, or just click. Nothing's taken away: the outline is cut into the face, " + "and the new face inside it is picked, ready to Extrude, Bevel or paint." + : "Press on a face and drag out the size, or just click (the size above). It has to fit inside the face%s. " + "Round edges in Properties rounds its rim too.", + holeMode_ == 0 ? ", and the face behind" : ""); + break; + } + } + ImGui::End(); + } + + void layersPanel() { + ImGui::SetNextWindowSizeConstraints(panelMin("Layers"), ImVec2(FLT_MAX, FLT_MAX)); + ImGui::Begin("Layers", nullptr, ImGuiWindowFlags_NoCollapse); + // Along the top: a new folder, the picked ones into one. + if (ImGui::SmallButton("+ Folder")) { + pushUndo(); + Folder f; + f.id = scene_.nextId++, f.name = "Folder"; + scene_.folders.push_back(f); + renamingFolder_ = f.id, std::snprintf(renameBuf_, sizeof renameBuf_, "%s", f.name.c_str()); + } + if (ImGui::IsItemHovered()) ImGui::SetTooltip("A new folder: drag things onto it"); + ImGui::SameLine(); + ImGui::BeginDisabled(selected_.empty()); + if (ImGui::SmallButton("Group (Ctrl+G)")) groupSelected(); + ImGui::EndDisabled(); + if (ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled)) ImGui::SetTooltip("The picked objects into a new folder: picked and moved together"); + if (ImGui::BeginTable("objects", 4, ImGuiTableFlags_RowBg | ImGuiTableFlags_ScrollY)) { + ImGui::TableSetupColumn("eye", ImGuiTableColumnFlags_WidthFixed, 22); + ImGui::TableSetupColumn("lock", ImGuiTableColumnFlags_WidthFixed, 22); + ImGui::TableSetupColumn("name"); + ImGui::TableSetupColumn("go", ImGuiTableColumnFlags_WidthFixed, 22); + layerRows(0, 0); + for (Reference& ref : scene_.references) { + ImGui::PushID(int(ref.id)); + ImGui::TableNextRow(); + ImGui::TableNextColumn(); + if (rowToggle("##eye", ref.visible ? Icon::Eye : Icon::EyeShut, ref.visible, ref.visible ? "Shown (click to hide)" : "Hidden (click to show)")) ref.visible = !ref.visible; + ImGui::TableNextColumn(); + ImGui::TableNextColumn(); + const std::string label = "Picture: " + stdfs::path(ref.path).filename().string(); + if (ImGui::Selectable(label.c_str(), activeRef_ == ref.id)) activeRef_ = ref.id, selected_.clear(), leaveEdit(); + ImGui::TableNextColumn(); + ImGui::PopID(); + } + ImGui::EndTable(); + } + // (dropped below the list: out of its folder, to the top) + ImGui::InvisibleButton("##top", ImVec2(std::max(ImGui::GetContentRegionAvail().x, 1.0f), std::max(ImGui::GetContentRegionAvail().y, 8.0f))); + if (ImGui::BeginDragDropTarget()) { + if (const ImGuiPayload* p = ImGui::AcceptDragDropPayload("FRY_OBJECT")) { + const uint32_t oid = *static_cast(p->Data); + moveToFolder(selected_.count(oid) ? selected_ : std::set{oid}, 0); + } + if (const ImGuiPayload* p = ImGui::AcceptDragDropPayload("FRY_FOLDER")) + if (Folder* g = findFolder(*static_cast(p->Data))) pushUndo(), g->parent = 0; + ImGui::EndDragDropTarget(); + } + ImGui::End(); + } + + // A value that's undoable: the undo point is taken when you start dragging or typing. + template + bool undoable(F widget) { + const bool changed = widget(); + if (ImGui::IsItemActivated()) pushUndo(); + return changed; + } + + void paintProperties(float w) { + Object* o = selected_.empty() ? nullptr : find(active_); + if (sculpting_) { // (its look, while you shape it: what it's made of shows how the light runs over it) + if (!o) return ImGui::TextWrapped("Brush over an object to sculpt it.\n\nA shape with few points is made finer first, keeping its shape. Tab goes back to painting colours."); + ImGui::TextColored(accentText(), "%s", o->name.c_str()); + ImGui::TextDisabled("%zu points, %zu faces", o->mesh.verts.size(), o->mesh.faces.size()); + ImGui::SeparatorText("Look"); + materialPicker(*o); + undoable([&] { return ImGui::ColorEdit3("Colour", &o->color.x); }); + if (ImGui::Checkbox("Smooth shading", &o->smooth)) pushUndo(), touched(o->id); + return; + } + if (!o) { + ImGui::TextWrapped("Click an object to paint on it.\n\nIts surface gets laid out flat on a picture (the Picture panel), which you can paint on too, " + "or put pictures onto: copy one in any app and press Ctrl+V."); + return; + } + ImGui::TextColored(accentText(), "%s", o->name.c_str()); + if (!o->picture) { + int size = pictureSize_ <= 512 ? 0 : pictureSize_ <= 1024 ? 1 : pictureSize_ <= 2048 ? 2 : 3; + ImGui::SetNextItemWidth(w); + if (ImGui::Combo("##psize", &size, "512 x 512\0001024 x 1024\0002048 x 2048\0004096 x 4096\0")) pictureSize_ = 512 << size; + if (ImGui::Button("Start painting it", ImVec2(w, 0))) readyToPaint(o->id); + ImGui::TextDisabled("(or just paint on it)"); + return; + } + ImGui::TextDisabled("Picture %d x %d", o->picture->w, o->picture->h); + if (o->hasFinish()) ImGui::TextWrapped("It's had rounding or smoothing added since: the picture shows again when you paint (they become part of the shape)."); + ImGui::Checkbox("Show the layout lines", &showUvLines_); + // The pictures put on it, top first: click one to move it (again), the eye hides it. + ImGui::SeparatorText("Pictures on it (layers)"); + if (o->layers.empty()) ImGui::TextDisabled("None yet: paste one (Ctrl+V)"); + int move = -1, by = 0, drop = -1, merge = -1; + for (int i = int(o->layers.size()) - 1; i >= 0; --i) { + PictureLayer& l = o->layers[size_t(i)]; + const bool on = ledit_.object == o->id && ledit_.index == i; + ImGui::PushID(i); + if (rowToggle("##eye", l.visible ? Icon::Eye : Icon::EyeShut, l.visible, l.visible ? "Hide it" : "Show it")) pushUndo(), l.visible = !l.visible; + ImGui::SameLine(); + if (ImGui::Selectable(std::format("{}{}", l.name, on ? " (moving)" : l.sticker ? "" : " (flat)").c_str(), on, 0, ImVec2(w - 92, 0))) + on ? lockLayer() : unlockLayer(o->id, i); + ImGui::SameLine(w - 62); + ImGui::BeginDisabled(i == int(o->layers.size()) - 1); + if (ImGui::ArrowButton("up", ImGuiDir_Up)) move = i, by = 1; + ImGui::EndDisabled(); + ImGui::SameLine(0, 2); + ImGui::BeginDisabled(i == 0); + if (ImGui::ArrowButton("down", ImGuiDir_Down)) move = i, by = -1; + ImGui::EndDisabled(); + ImGui::SameLine(0, 2); + if (ImGui::SmallButton("x")) drop = i; + if (ImGui::IsItemHovered()) ImGui::SetTooltip("Delete it"); + if (ImGui::BeginPopupContextItem("menu")) { + if (ImGui::MenuItem("Paint it in for good")) merge = i; + if (ImGui::MenuItem("Delete")) drop = i; + ImGui::EndPopup(); + } + ImGui::PopID(); + } + if (move >= 0) pushUndo(), std::swap(o->layers[size_t(move)], o->layers[size_t(move + by)]), ledit_ = {}; + if (drop >= 0) deleteLayer(*o, drop); + if (merge >= 0) mergeLayer(*o, merge); + ImGui::Spacing(); + if (ImGui::Button("Fill it all with the colour", ImVec2(w, 0))) { + Image& img = changePicture(*o); + img = makeImage(img.w, img.h, glm::vec4(glm::vec3(paintColor_), 1.0f)); + paint_.image.reset(); + } + if (ImGui::Button("Save the picture as PNG...", ImVec2(w, 0))) dialog(7); + if (ImGui::Button("Lay it out again", ImVec2(w, 0))) { + pushUndo(); + o->uvs = autoUnwrap(o->mesh); + touched(o->id); + } + if (ImGui::IsItemHovered()) ImGui::SetTooltip("Lays its surface out flat on the picture again (what's painted won't fit it any more)"); + if (ImGui::Button("Remove the picture", ImVec2(w, 0))) { + pushUndo(); + o->picture.reset(), o->uvs.clear(); + touched(o->id); + } + ImGui::Spacing(); + ImGui::TextWrapped("Tip: paint last. Changing the shape's faces (extrude, knife...) lays it out again, and what's painted moves."); + } + + void renderProperties(float w) { + Look& k = scene_.look; + auto slider = [&](const char* label, float* v, float lo, float hi, const char* fmt) { + if (undoable([&] { return ImGui::SliderFloat(label, v, lo, hi, fmt); })) dirty_ = true; + }; + ImGui::SeparatorText("Sun"); + slider("Round", &k.sunTurn, 0, 360, "%.0f deg"); + slider("Up", &k.sunHeight, 2, 90, "%.0f deg"); + slider("Strength", &k.sunStrength, 0, 2, "%.2f"); + undoable([&] { return ImGui::ColorEdit3("Sun colour", &k.sunColor.x); }); + ImGui::SeparatorText("Round it"); + if (ImGui::Checkbox("Sky", &k.sky)) dirty_ = true; + if (!k.sky) undoable([&] { return ImGui::ColorEdit3("Background", &k.background.x); }); + if (ImGui::Checkbox("Ground", &k.ground)) dirty_ = true; + if (k.ground) undoable([&] { return ImGui::ColorEdit3("Ground colour", &k.groundColor.x); }); + ImGui::Checkbox("Grid", &renderGrid_); + ImGui::SeparatorText("Look"); + slider("Brightness", &k.exposure, 0.2f, 3.0f, "%.2f"); + slider("Shade in corners", &k.ao, 0, 1, "%.2f"); + slider("Soft shadows", &k.shadowSoft, 0, 4, "%.2f"); + slider("Glow", &k.bloom, 0, 0.3f, "%.3f"); + slider("Colour", &k.saturation, 0, 2, "%.2f"); + slider("Dark edges", &k.vignette, 0, 1, "%.2f"); + slider("Sharpen", &k.sharpen, 0, 1, "%.2f"); + if (ImGui::Button("Back to how it was", ImVec2(w, 0))) pushUndo(), k = Look{}; + if (ImGui::Button("Save a picture (F12)", ImVec2(w, 0))) dialog(5); + if (Object* o = selected_.size() == 1 ? find(active_) : nullptr) { + ImGui::SeparatorText(o->name.c_str()); + materialPicker(*o); + undoable([&] { return ImGui::ColorEdit3("Colour", &o->color.x); }); + undoable([&] { return ImGui::SliderFloat("Metal", &o->metal, 0, 1); }); + undoable([&] { return ImGui::SliderFloat("Rough", &o->rough, 0, 1); }); + ImGui::TextDisabled("(metal and rough go with it into games)"); + } + } + + // The Paint tab's picture: the selected object's, flat, to paint on and place pictures on. + void picturePanel() { + ImGui::SetNextWindowSizeConstraints(panelMin("Picture"), ImVec2(FLT_MAX, FLT_MAX)); + ImGui::Begin("Picture", nullptr, ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse); + Object* o = selected_.empty() ? nullptr : find(active_); + if (!o || !o->picture) { + if (tab_ == Tab::Layout) { + ImGui::TextWrapped(o ? "It has no picture yet, so nothing's laid out." : "Click an object: how its picture is laid out shows here."); + if (o && ImGui::Button("Lay it out (and start its picture)")) readyToPaint(o->id); + return ImGui::End(); + } + ImGui::TextWrapped(sculpting_ ? "Sculpting changes its shape, not its picture. Tab goes back to painting colours." + : o ? "Paint on it (or press Start painting it in Properties) and its picture shows here." : "Click an object to paint it: its picture shows here."); + return ImGui::End(); + } + uploadPicture(*o, !paint_.active); + const int W = o->picture->w, H = o->picture->h; + const ImVec2 origin = ImGui::GetCursorScreenPos(), avail = ImGui::GetContentRegionAvail(); + if (avail.x < 16 || avail.y < 16) return ImGui::End(); + if (picZoom_ <= 0 || picFor_ != o->id) { + picZoom_ = std::min(avail.x / float(W), avail.y / float(H)) * 0.96f; + picPan_ = ImVec2((avail.x - float(W) * picZoom_) * 0.5f, (avail.y - float(H) * picZoom_) * 0.5f), picFor_ = o->id; + } + ImGui::InvisibleButton("canvas", avail, ImGuiButtonFlags_MouseButtonLeft | ImGuiButtonFlags_MouseButtonMiddle | ImGuiButtonFlags_MouseButtonRight); + const bool hovered = ImGui::IsItemHovered(); + ImGuiIO& io = ImGui::GetIO(); + auto toScreen = [&](glm::vec2 p) { return ImVec2(origin.x + picPan_.x + p.x * picZoom_, origin.y + picPan_.y + p.y * picZoom_); }; + const glm::vec2 mouse((io.MousePos.x - origin.x - picPan_.x) / picZoom_, (io.MousePos.y - origin.y - picPan_.y) / picZoom_); + if (hovered && io.MouseWheel != 0) { // zoom about the mouse + const float z = std::clamp(picZoom_ * std::pow(1.2f, io.MouseWheel), 0.05f, 64.0f); + picPan_ = ImVec2(io.MousePos.x - origin.x - mouse.x * z, io.MousePos.y - origin.y - mouse.y * z), picZoom_ = z; + } + if (ImGui::IsItemActive() && (ImGui::IsMouseDown(ImGuiMouseButton_Middle) || ImGui::IsMouseDown(ImGuiMouseButton_Right))) + picPan_.x += io.MouseDelta.x, picPan_.y += io.MouseDelta.y; + ImDrawList* dl = ImGui::GetWindowDrawList(); + dl->PushClipRect(origin, ImVec2(origin.x + avail.x, origin.y + avail.y), true); + dl->AddRectFilled(origin, ImVec2(origin.x + avail.x, origin.y + avail.y), IM_COL32(24, 24, 28, 255)); + uiImage(dl, paintTex_[o->id], toScreen({0, 0}), toScreen({float(W), float(H)})); + dl->AddRect(toScreen({0, 0}), toScreen({float(W), float(H)}), IM_COL32(0, 0, 0, 255)); + if (tab_ == Tab::Layout) { + if (o->uvsFit()) layoutCanvas(*o, dl, toScreen, mouse, hovered, W, H); + dl->PopClipRect(); + return ImGui::End(); + } + if (showUvLines_ && o->uvsFit()) + for (const auto& face : o->uvs) { + std::vector pts; + for (const glm::vec2& q : face) pts.push_back(toScreen(q * glm::vec2(W, H))); + dl->AddPolyline(pts.data(), int(pts.size()), IM_COL32(255, 255, 255, 60), ImDrawFlags_Closed, 1.0f); + } + PictureLayer* el = ledit_.object == o->id ? editLayer() : nullptr; + if (el && !el->sticker) { + ImVec2 q[4]; + for (int i = 0; i < 4; ++i) q[i] = toScreen(el->corners[size_t(i)]); + if (hovered && ImGui::IsMouseClicked(ImGuiMouseButton_Left)) { + ledit_.drag = -1; + for (int i = 0; i < 4; ++i) + if (std::abs(io.MousePos.x - q[i].x) < 10 && std::abs(io.MousePos.y - q[i].y) < 10) ledit_.drag = i; + if (ledit_.drag < 0 && insidePolygon(io.MousePos, {q[0], q[1], q[2], q[3]})) ledit_.drag = 4; + if (ledit_.drag >= 0) pushUndo(); + else lockLayer(); + ledit_.grab = mouse, ledit_.startCorners = el->corners; + } + if (ledit_.drag >= 0 && ImGui::IsMouseDown(ImGuiMouseButton_Left)) { + const glm::vec2 d = mouse - ledit_.grab; + const int i = ledit_.drag; + if (i == 4) + for (int k = 0; k < 4; ++k) el->corners[size_t(k)] = ledit_.startCorners[size_t(k)] + d; + else if (io.KeyShift) { // bigger or smaller, the opposite corner staying + const glm::vec2 opp = ledit_.startCorners[size_t((i + 2) % 4)]; + const float k = glm::length(mouse - opp) / std::max(glm::length(ledit_.startCorners[size_t(i)] - opp), 1e-3f); + for (int j = 0; j < 4; ++j) el->corners[size_t(j)] = opp + (ledit_.startCorners[size_t(j)] - opp) * k; + } else + el->corners[size_t(i)] = ledit_.startCorners[size_t(i)] + d; + } + if (!ImGui::IsMouseDown(ImGuiMouseButton_Left)) ledit_.drag = -1; + for (int i = 0; i < 4; ++i) q[i] = toScreen(el->corners[size_t(i)]); + dl->AddPolyline(q, 4, kAccent, ImDrawFlags_Closed, 2.0f); + for (int i = 0; i < 4; ++i) { + const bool hot = ledit_.drag == i || (std::abs(io.MousePos.x - q[i].x) < 10 && std::abs(io.MousePos.y - q[i].y) < 10); + dl->AddRectFilled(ImVec2(q[i].x - 6, q[i].y - 6), ImVec2(q[i].x + 6, q[i].y + 6), hot ? kHot : IM_COL32(255, 255, 255, 240)); + dl->AddRect(ImVec2(q[i].x - 6, q[i].y - 6), ImVec2(q[i].x + 6, q[i].y + 6), IM_COL32(20, 20, 25, 255)); + } + } else { + const bool onPicture = mouse.x >= 0 && mouse.y >= 0 && mouse.x < float(W) && mouse.y < float(H); + const glm::vec2 uv = mouse / glm::vec2(W, H); + // Which layer is on top under the mouse (0: the painting). + int under = 0; + if (const auto it = owner_.find(o->id); onPicture && it != owner_.end() && it->second.size() == size_t(W) * size_t(H)) + under = it->second[size_t(int(mouse.y)) * size_t(W) + size_t(int(mouse.x))]; + if (hovered && under && !paint_.active) ImGui::SetTooltip("%s: click to move it", o->layers[size_t(under - 1)].name.c_str()); + if (hovered && onPicture && ImGui::IsMouseClicked(ImGuiMouseButton_Left) && (under || el)) { + if (under) unlockLayer(o->id, under - 1); // (clicking a placed picture here unlocks it) + else lockLayer(); + } else if (hovered && onPicture && ImGui::IsMouseClicked(ImGuiMouseButton_Left)) { + if (paintTool_ == PaintTool::Picker || io.KeyAlt) pickColor(*o, uv); + else if (paintTool_ == PaintTool::Fill) fillAt(*o, uv); + else if (paintTool_ == PaintTool::Brush || paintTool_ == PaintTool::Eraser) { + beginStroke(*o, true); + paint_.stroke.dab(*paint_.image, mouse, brushPx_ / picZoom_, hardness_); + paint_.last2D = mouse, paint_.haveLast = true; + } + } + if (paint_.active && paint_.in2D) { + if (!ImGui::IsMouseDown(ImGuiMouseButton_Left)) endStroke(); + else { + const float r = brushPx_ / picZoom_, gap = glm::distance(mouse, paint_.last2D); + const int steps = std::max(1, int(std::ceil(gap / std::max(r * 0.3f, 0.5f)))); + for (int i = 1; i <= steps; ++i) paint_.stroke.dab(*paint_.image, glm::mix(paint_.last2D, mouse, float(i) / float(steps)), r, hardness_); + paint_.last2D = mouse; + uploaded_[o->id].dirty = true; + } + } + if (hovered && (paintTool_ == PaintTool::Brush || paintTool_ == PaintTool::Eraser)) dl->AddCircle(io.MousePos, brushPx_, IM_COL32(255, 255, 255, 200), 40, 1.5f); + } + dl->PopClipRect(); + ImGui::End(); + } + + void propertiesPanel() { + ImGui::SetNextWindowSizeConstraints(panelMin("Properties"), ImVec2(FLT_MAX, FLT_MAX)); + ImGui::Begin("Properties", nullptr, ImGuiWindowFlags_NoCollapse); + const float w = ImGui::GetContentRegionAvail().x; + if (tab_ == Tab::Paint) { + paintProperties(w); + return ImGui::End(); + } + if (tab_ == Tab::Render) { + renderProperties(w); + return ImGui::End(); + } + if (tab_ == Tab::Layout) { + layoutProperties(w); + return ImGui::End(); + } + if (tab_ == Tab::Print) { + printProperties(w); + return ImGui::End(); + } + if (Reference* ref = activeRef_ && selected_.empty() ? findRef(activeRef_) : nullptr) { + ImGui::TextWrapped("%s", ref->path.c_str()); + int axis = int(ref->axis); + if (ImGui::Combo("Facing", &axis, "Front view\0Side view\0Top view\0")) pushUndo(), ref->axis = Axis(axis); + undoable([&] { return ImGui::DragFloat2("Middle (m)", &ref->offset.x, 0.005f); }); + undoable([&] { return ImGui::DragFloat("Width (m)", &ref->width, 0.005f, -100.0f, 100.0f, "%.3f"); }); + ImGui::TextDisabled("A minus width flips it."); + undoable([&] { return ImGui::SliderFloat("See-through", &ref->opacity, 0.05f, 1.0f); }); + if (ImGui::Button("Remove picture", ImVec2(w, 0))) deleteSelected(); + ImGui::End(); + return; + } + if (Object* o = editing()) { + editProperties(*o, w); + ImGui::End(); + return; + } + if (selected_.empty()) { + ImGui::TextWrapped("Nothing selected.\n\nClick an object to select it, or add one with + in the toolbox (or the Add menu): " + "shapes and starter models, with sizes you can change. Draw shape (D) turns an outline you click into a solid."); + ImGui::End(); + return; + } + if (selected_.size() > 1) { + ImGui::Text("%zu objects selected", selected_.size()); + glm::vec3 col = find(active_) ? find(active_)->color : glm::vec3(0.8f); + if (undoable([&] { return ImGui::ColorEdit3("Colour (all)", &col.x); })) + for (uint32_t id : selected_) + if (Object* o = find(id)) o->color = col, dirty_ = true; + if (ImGui::Button("Join into one (Ctrl+J)", ImVec2(w, 0))) joinSelected(); + ImGui::End(); + return; + } + Object* o = find(*selected_.begin()); + if (!o) { + ImGui::End(); + return; + } + ImGui::BeginDisabled(o->locked); + char name[128]; + std::snprintf(name, sizeof name, "%s", o->name.c_str()); + if (undoable([&] { return ImGui::InputText("Name", name, sizeof name); }) && name[0]) o->name = name; + // A library shape: its own sizes, until its shape's edited by hand. + if (const ShapeRecipe* r = findRecipe(o->recipe)) { + ImGui::SeparatorText(std::format("{} settings", r->name).c_str()); + bool changed = false; + o->settings.resize(r->settings.size()); + for (size_t i = 0; i < r->settings.size(); ++i) { + const ShapeSetting& s = r->settings[i]; + const float speed = s.whole ? 0.1f : std::max(0.001f, (s.max - s.min) * 0.0005f); + changed |= undoable([&] { return ImGui::DragFloat(s.name, &o->settings[i], speed, s.min, s.max, s.whole ? "%.0f" : "%.3f"); }); + } + if (changed) o->mesh = makeFromRecipe(*r, o->settings), touched(o->id); + ImGui::TextDisabled("(These go once you edit the shape by hand.)"); + } + ImGui::SeparatorText("Place"); + undoable([&] { return ImGui::DragFloat3("Position (m)", &o->position.x, 0.005f, 0, 0, "%.3f"); }); + glm::vec3 euler = toEuler(o->rotation); + if (undoable([&] { return ImGui::DragFloat3("Turn (deg)", &euler.x, 0.5f, 0, 0, "%.1f"); })) o->rotation = fromEuler(euler); + if (undoable([&] { return ImGui::DragFloat3("Size", &o->scale.x, 0.005f, 0, 0, "%.3f"); })) touched(o->id); + const glm::vec3 lo = boundsMin(shownMesh(*o)) * glm::abs(o->scale), hi = boundsMax(shownMesh(*o)) * glm::abs(o->scale); + ImGui::TextDisabled("%.3f x %.3f x %.3f m", hi.x - lo.x, hi.y - lo.y, hi.z - lo.z); + ImGui::SeparatorText("Look"); + materialPicker(*o); + undoable([&] { return ImGui::ColorEdit3("Colour", &o->color.x); }); + undoable([&] { return ImGui::SliderFloat("Metal", &o->metal, 0, 1); }); + undoable([&] { return ImGui::SliderFloat("Rough", &o->rough, 0, 1); }); + if (ImGui::Checkbox("Smooth shading", &o->smooth)) pushUndo(), touched(o->id); + if (ImGui::IsItemHovered()) ImGui::SetTooltip("Round surfaces look round; edges sharper than the angle stay sharp"); + if (o->smooth) { + ImGui::SameLine(); + ImGui::SetNextItemWidth(-1); + if (undoable([&] { return ImGui::SliderFloat("##angle", &o->smoothAngle, 1, 180, "creases > %.0f deg"); })) touched(o->id); + } + // Finishing touches that stay changeable: rounded edges, smoothing, a mirrored half. + ImGui::SeparatorText("Round, smooth, mirror"); + if (undoable([&] { return ImGui::DragFloat("Round edges", &o->round, 0.001f, 0.0f, 10.0f, o->round > 0 ? "%.3f m" : "off"); })) { + if (o->round > 0 && o->roundSteps > 1) o->smooth = true; + touched(o->id); + } + if (ImGui::IsItemHovered()) ImGui::SetTooltip("Rounds off every sharp edge and corner (drag right)"); + if (o->round > 0) { + if (undoable([&] { return ImGui::SliderInt("Steps round", &o->roundSteps, 1, 8); })) touched(o->id); + if (undoable([&] { return ImGui::SliderFloat("Edges sharper than", &o->roundAngle, 1, 179, "%.0f deg"); })) touched(o->id); + } + if (undoable([&] { return ImGui::SliderInt("Smooth out", &o->smoothLevels, 0, 3, o->smoothLevels ? "%d times" : "off"); })) { + if (o->smoothLevels) o->smooth = true; + touched(o->id); + } + if (ImGui::IsItemHovered()) ImGui::SetTooltip("Cuts every face into smaller ones and rounds the whole shape (like clay)"); + if (o->smoothLevels && ImGui::Checkbox("Keep sharp edges sharp", &o->smoothKeepSharp)) pushUndo(), touched(o->id); + ImGui::TextDisabled("Mirror across"); + for (int a = 0; a < 3; ++a) { + ImGui::SameLine(); + bool on = o->mirror & (1 << a); + if (ImGui::Checkbox(a == 0 ? "X" : a == 1 ? "Y" : "Z", &on)) pushUndo(), o->mirror ^= 1 << a, touched(o->id); + } + if (o->hasFinish() && ImGui::Button("Make it part of the shape", ImVec2(w, 0))) applyFinish(*o); + if (o->hasFinish() && ImGui::IsItemHovered()) ImGui::SetTooltip("Bake the rounding, smoothing and mirror into the points (so you can edit them)"); + ImGui::SeparatorText("Do"); + if (ImGui::Button("Edit shape (Tab)", ImVec2(w, 0))) enterEdit(); + if (ImGui::Button("Apply turn and size", ImVec2(w, 0))) applyTransform(*o); + if (ImGui::IsItemHovered()) ImGui::SetTooltip("Bake them into the shape: turn becomes 0, size 1"); + if (ImGui::Button("Origin to middle", ImVec2((w - 6) / 2, 0))) originToMiddle(*o); + ImGui::SameLine(0, 6); + if (ImGui::Button("Origin to bottom", ImVec2(-1, 0))) originToBottom(*o); + if (ImGui::Button("Flip X", ImVec2((w - 12) / 3, 0))) mirror(*o, 0); + ImGui::SameLine(0, 6); + if (ImGui::Button("Flip Y", ImVec2((w - 12) / 3, 0))) mirror(*o, 1); + ImGui::SameLine(0, 6); + if (ImGui::Button("Flip Z", ImVec2(-1, 0))) mirror(*o, 2); + ImGui::EndDisabled(); + if (ImGui::Button(o->locked ? "Unlock" : "Lock", ImVec2((w - 6) / 2, 0))) o->locked = !o->locked; + ImGui::SameLine(0, 6); + if (ImGui::Button("Delete", ImVec2(-1, 0))) deleteSelected(); + ImGui::End(); + } + + // A material to start from: sets colour, metal and rough in one go (each a little ball of it). + // Yours (saved here, or from plug-ins and scripts) first; "Save its look as a material" adds one. + void materialPicker(Object& o) { + auto matches = [&](const MaterialPreset& m) { + return std::abs(m.metal - o.metal) < 0.01f && std::abs(m.rough - o.rough) < 0.01f && + (m.keepColor || glm::all(glm::lessThan(glm::abs(m.color - o.color), glm::vec3(0.01f)))); + }; + const MaterialPreset* now = nullptr; + for (const MaterialPreset& m : materialPresets()) + if (matches(m)) now = &m; + for (const MaterialPreset& m : ownMaterials()) + if (matches(m)) now = &m; + ImGui::SetNextItemWidth(ImGui::CalcItemWidth()); + if (ImGui::BeginCombo("Material", now ? now->name.c_str() : "(its own)", ImGuiComboFlags_HeightLarge)) { + std::optional remove; + auto item = [&](const MaterialPreset& m, bool own) { + const ImVec2 p = ImGui::GetCursorScreenPos(); + const float h = ImGui::GetTextLineHeight(); + ImGui::PushID(m.id.c_str()); + ImGui::PushID(own); + if (ImGui::Selectable("##m", now == &m, 0, ImVec2(0, h))) pushUndo(), applyMaterial(o, m), touched(o.id); + if (own && m.from.empty() && ImGui::BeginPopupContextItem()) { + if (ImGui::MenuItem("Remove it")) remove = m.id; + ImGui::EndPopup(); + } + if (own && ImGui::IsItemHovered()) + ImGui::SetTooltip("%s", m.from.empty() ? "Yours (right-click to remove)" : ("From " + m.from).c_str()); + ImGui::PopID(); + ImGui::PopID(); + // A ball: its colour, darker round the edge, a highlight as sharp as it's smooth. + const glm::vec3 c = m.keepColor ? o.color : m.color; + const ImVec2 mid(p.x + h * 0.5f, p.y + h * 0.5f); + ImDrawList* dl = ImGui::GetWindowDrawList(); + dl->AddCircleFilled(mid, h * 0.45f, ImGui::ColorConvertFloat4ToU32(ImVec4(c.r * 0.55f, c.g * 0.55f, c.b * 0.55f, 1))); + dl->AddCircleFilled(ImVec2(mid.x - h * 0.05f, mid.y - h * 0.05f), h * 0.36f, ImGui::ColorConvertFloat4ToU32(ImVec4(c.r, c.g, c.b, 1))); + dl->AddCircleFilled(ImVec2(mid.x - h * 0.14f, mid.y - h * 0.14f), h * (0.06f + 0.1f * m.rough), IM_COL32(255, 255, 255, int(230 * (1.0f - m.rough * 0.8f)))); + dl->AddText(ImVec2(p.x + h * 1.4f, p.y), ImGui::GetColorU32(ImGuiCol_Text), m.name.c_str()); + }; + if (!ownMaterials().empty()) { + ImGui::SeparatorText("Yours"); + for (const MaterialPreset& m : ownMaterials()) item(m, true); + ImGui::SeparatorText("Fry3DX's"); + } + for (const MaterialPreset& m : materialPresets()) item(m, false); + ImGui::Separator(); + if (ImGui::Selectable("Save its look as a material...")) nameMaterial_ = true, materialName_.clear(), materialKeepColor_ = false; + ImGui::EndCombo(); + if (remove && removeOwnMaterial(*remove)) saveOwnMaterials(); + } + if (std::exchange(nameMaterial_, false)) ImGui::OpenPopup("Save as a material"); + ImGui::SetNextWindowSize(ImVec2(340, 0)); + if (ImGui::BeginPopupModal("Save as a material", nullptr, ImGuiWindowFlags_NoResize)) { + ImGui::TextWrapped("Its colour, metal and rough as one of your materials, in the picker for every file."); + if (ImGui::IsWindowAppearing()) ImGui::SetKeyboardFocusHere(); + ImGui::SetNextItemWidth(-FLT_MIN); + inputString("##name", materialName_, "Its name (\"Teal plastic\")"); + ImGui::Checkbox("Only how metal and rough (keep each object's colour)", &materialKeepColor_); + const bool taken = std::any_of(materialPresets().begin(), materialPresets().end(), [&](const MaterialPreset& m) { return m.id == materialId(materialName_); }); + if (taken) ImGui::TextColored(roleColor(keel::ui::Role::DangerText), "That's one of Fry3DX's own: another name"); + ImGui::BeginDisabled(materialId(materialName_).empty() || taken); + pushAccent(); + const bool save = ImGui::Button("Save", ImVec2(120, 0)) || ImGui::IsKeyPressed(ImGuiKey_Enter, false); + popAccent(); + ImGui::EndDisabled(); + ImGui::SameLine(); + if (save && !materialId(materialName_).empty() && !taken) { + addOwnMaterial({materialId(materialName_), materialName_, o.color, o.metal, o.rough, materialKeepColor_, ""}); + saveOwnMaterials(); + say("Saved \"" + materialName_ + "\": it's in the Material picker now"); + ImGui::CloseCurrentPopup(); + } + if (ImGui::Button("Cancel", ImVec2(120, 0))) ImGui::CloseCurrentPopup(); + ImGui::EndPopup(); + } + } + + void editProperties(Object& o, float w) { + ImGui::Text("Editing the shape of %s", o.name.c_str()); + ImGui::TextDisabled("%zu points, %zu faces", o.mesh.verts.size(), o.mesh.faces.size()); + if (o.hasFinish()) ImGui::TextDisabled("(its rounding and smoothing show again when you're done)"); + const std::set pts = selectedPoints(); + if (!pts.empty()) { + // Where the selection's middle is (in the world); typing moves it there. + const glm::vec3 c = pivotOfSelection(); + glm::vec3 at = c; + if (undoable([&] { return ImGui::DragFloat3("Middle (m)", &at.x, 0.002f, 0, 0, "%.3f"); })) { + const glm::mat4 m = objectMatrix(o), local = glm::inverse(m) * glm::translate(glm::mat4(1.0f), at - c) * m; + for (int v : pts) o.mesh.verts[size_t(v)] = glm::vec3(local * glm::vec4(o.mesh.verts[size_t(v)], 1.0f)); + handMade(o); + touched(o.id); + } + } + ImGui::SeparatorText("Faces"); + ImGui::SetNextItemWidth(w * 0.45f); + ImGui::DragFloat("##ext", &extrudeAmount_, 0.001f, -10, 10, "%.3f m"); + ImGui::SameLine(); + if (ImGui::Button("Extrude", ImVec2(-1, 0))) extrudeNow(extrudeAmount_); + ImGui::SetNextItemWidth(w * 0.45f); + ImGui::SliderFloat("##ins", &insetAmount_, 0.01f, 0.99f, "%.2f"); + ImGui::SameLine(); + if (ImGui::Button("Inset", ImVec2(-1, 0))) insetNow(insetAmount_); + if (ImGui::Button("Flip", ImVec2((w - 6) / 2, 0))) flipNow(); + ImGui::SameLine(0, 6); + if (ImGui::Button("Make face", ImVec2(-1, 0))) makeFaceNow(); + if (ImGui::Button("Bridge two faces", ImVec2(w, 0))) bridgeNow(); + if (ImGui::IsItemHovered()) ImGui::SetTooltip("Two faces with as many corners: taken out and joined by a tunnel"); + if (ImGui::Button("Make into its own object (P)", ImVec2(w, 0))) separateSelected(); + ImGui::SeparatorText("Edges"); + ImGui::SetNextItemWidth(w * 0.45f); + ImGui::DragFloat("##bw", &bevelWidth_, 0.001f, 0.0f, 10.0f, "%.3f m"); + ImGui::SameLine(); + ImGui::SetNextItemWidth(w * 0.2f); + ImGui::DragInt("##bs", &bevelSteps_, 0.1f, 1, 10); + ImGui::SameLine(); + if (ImGui::Button("Round", ImVec2(-1, 0))) bevelNow(); + if (ImGui::IsItemHovered()) ImGui::SetTooltip("Round the selected edges (or the edges round the selected faces): width, steps"); + ImGui::SeparatorText("Points"); + if (ImGui::Button("Merge to middle (M)", ImVec2(w, 0))) mergeNow(); + if (ImGui::Button("Smooth", ImVec2((w - 6) / 2, 0))) smoothPointsNow(); + if (ImGui::IsItemHovered()) ImGui::SetTooltip("Even out bumps: each point towards its neighbours"); + ImGui::SameLine(0, 6); + if (ImGui::Button("Push / pull (Alt+S)", ImVec2(-1, 0))) startPush(); + ImGui::SetNextItemWidth(w * 0.45f); + ImGui::DragFloat("##md", &mergeDistance_, 0.0001f, 0.0f, 1.0f, "%.4f m"); + ImGui::SameLine(); + if (ImGui::Button("Merge close", ImVec2(-1, 0))) { + pushUndo(); + const int gone = mergeByDistance(o.mesh, mergeDistance_); + changedShape(o); + say(std::format("{} points merged", gone)); + } + ImGui::SeparatorText("Whole shape"); + if (ImGui::Button("Cut in four", ImVec2((w - 6) / 2, 0))) subdivideNow(false); + ImGui::SameLine(0, 6); + if (ImGui::Button("Smooth it", ImVec2(-1, 0))) subdivideNow(true); + ImGui::SeparatorText("Select"); + if (ImGui::Button("All", ImVec2((w - 12) / 3, 0))) selectAllElements(); + ImGui::SameLine(0, 6); + if (ImGui::Button("None", ImVec2((w - 12) / 3, 0))) clearElements(); + ImGui::SameLine(0, 6); + if (ImGui::Button("Invert", ImVec2(-1, 0))) invertSelection(); + if (ImGui::Button("More", ImVec2((w - 12) / 3, 0))) growSelection(); + ImGui::SameLine(0, 6); + if (ImGui::Button("Less", ImVec2((w - 12) / 3, 0))) shrinkSelection(); + ImGui::SameLine(0, 6); + if (ImGui::Button("Joined", ImVec2(-1, 0))) selectLinked(); + ImGui::Separator(); + if (ImGui::Button("Delete (Del)", ImVec2(w, 0))) deleteElements(); + if (ImGui::Button("Done (Tab)", ImVec2(w, 0))) leaveEdit(); + } + + void statusBar() { + ImGui::Begin("Status", nullptr, ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoScrollbar); + if (now() - messageAt_ < 4.0) ImGui::TextColored(accentText(), "%s", message_.c_str()); + else if (tab_ == Tab::Layout) { + for (const LayToolInfo& t : kLayTools) + if (t.tool == layTool_) + ImGui::TextDisabled("%s: %s · Tab: frames or corners Right drag: orbit (pan in the Picture panel) F1: all keys", t.name, t.hint); + } else if (tab_ == Tab::Print && printTool_ != PrintTool::None) { + ImGui::TextDisabled(printTool_ == PrintTool::Measure ? "Measure: click two points on the model (corners catch the mouse); a third click starts again · M: done" + : "Lay flat: click the face it should stand on: it turns so that face is down, on the bed · L: done"); + } else if (tab_ == Tab::Paint && sculpting_) { + for (const SculptToolInfo& t : kSculptTools) + if (t.brush == sculptBrush_) ImGui::TextDisabled("%s: %s · Shift: smooth X: both sides Tab: colour Right drag: orbit F1: all keys", t.name, t.hint); + } else if (tab_ == Tab::Paint) { + for (const PaintToolInfo& t : kPaintTools) + if (t.tool == paintTool_) ImGui::TextDisabled("%s: %s · Right drag: orbit Wheel: zoom (in the Picture panel too) F1: all keys", t.name, t.hint); + } else + for (const ToolInfo& t : kTools) + if (t.tool == tool_) ImGui::TextDisabled("%s: %s · Right drag: orbit Middle drag: pan Wheel: zoom F1: all keys", t.name, t.hint); + ImGui::End(); + } + + // The view's corner: which way you're looking, buttons to look straight at a side, and + // handles to drag to orbit, pan and zoom (for a touchpad or a pen). + void viewButtons() { + const ImVec2 at(viewMax_.x - 10, viewMin_.y + 10); + ImGui::SetNextWindowPos(at, ImGuiCond_Always, ImVec2(1, 0)); + ImGui::SetNextWindowBgAlpha(0.55f); + ImGui::Begin("##views", nullptr, ImGuiWindowFlags_NoDecoration | ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoSavedSettings | ImGuiWindowFlags_NoFocusOnAppearing | + ImGuiWindowFlags_NoNav | ImGuiWindowFlags_NoDocking); + static const char* names[] = {"Perspective", "Front", "Back", "Left", "Right", "Top", "Bottom"}; + static const char* shadings[] = {"Solid", "Rendered", "Wireframe"}; + ImGui::TextColored(accentText(), "%s", names[int(view_)]); + ImGui::SameLine(); + ImGui::TextDisabled("%s%s", shadings[int(shading_)], xray_ ? ", X-ray" : ""); + for (int i = 1; i < 7; ++i) { + if (i != 1 && i != 4) ImGui::SameLine(0, 3); + if (ImGui::Button(names[i], ImVec2(56, 0))) setView(View(i)); + } + if (ImGui::Button("3D", ImVec2(56, 0))) leaveFlatView(); + ImGui::SameLine(0, 3); + if (ImGui::Button("Frame", ImVec2(56, 0))) frameSelected(); + ImGui::SameLine(0, 3); + if (ImGui::Button(xray_ ? "Solid" : "X-ray", ImVec2(56, 0))) xray_ = !xray_; + const ImVec2 d = ImGui::GetIO().MouseDelta; + ImGui::Button("Orbit", ImVec2(56, 0)); + if (ImGui::IsItemActive()) orbitBy(d); + if (ImGui::IsItemHovered()) ImGui::SetTooltip("Drag to orbit"); + ImGui::SameLine(0, 3); + ImGui::Button("Pan", ImVec2(56, 0)); + if (ImGui::IsItemActive()) panBy(d); + if (ImGui::IsItemHovered()) ImGui::SetTooltip("Drag to pan"); + ImGui::SameLine(0, 3); + ImGui::Button("Zoom", ImVec2(56, 0)); + if (ImGui::IsItemActive()) dist_ = std::clamp(dist_ * std::pow(1.01f, d.y), 0.01f, 5000.0f); + if (ImGui::IsItemHovered()) ImGui::SetTooltip("Drag up and down to zoom"); + if (tab_ != Tab::Render && ImGui::Button(shading_ == Shading::Rendered ? "Show: Solid" : "Show: Rendered", ImVec2(56 * 3 + 6, 0))) + shading_ = shading_ == Shading::Rendered ? Shading::Solid : Shading::Rendered; + ImGui::End(); + } + + // ───────────────────────────── scripts ───────────────────────────── + + void scriptLine(std::string text, bool error = false) { + 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; + } + fry::SceneScript* currentScript() { return script_ < scene_.scripts.size() ? &scene_.scripts[script_] : nullptr; } + // "little_town.lua" -> "little town" (a script's name in the list). + static std::string scriptName(const std::string& path) { + 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). + void addScript(std::string name, std::string code) { + 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. + void runScriptNow() { + 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. + void finishRun(const fry::ScriptResult& r, const std::string& name, bool frame) { + 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)); + } + void openScript(const std::string& path, const std::string& shownName) { + std::ifstream in(path, std::ios::binary); + if (!in) return say("Couldn't open " + shownName); + addScript(scriptName(shownName), std::string(std::istreambuf_iterator(in), {})); + scriptPath_ = isUri(path) ? std::string() : path; + say("Opened " + shownName + ": it's kept in this file now. F5 runs it"); + } + void saveScript(std::string file) { + 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. + void exampleScripts() { + if (!ImGui::BeginMenu("Examples")) return; + exampleItems(false); + ImGui::EndMenu(); + } + void exampleItems(bool selectable) { + 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); + } + } + + // ── Own materials (materials.txt in the settings folder) ── + static std::string ownMaterialsPath() { return keel::prefPath() + "materials.txt"; } + void loadOwnMaterials() { + std::ifstream in(ownMaterialsPath(), std::ios::binary); + if (in) parseOwnMaterials(std::string(std::istreambuf_iterator(in), {})); + } + void saveOwnMaterials() { + const std::string text = ownMaterialsText(); + std::string error; + if (!writeFileSafely(ownMaterialsPath(), text.data(), text.size(), &error)) say("Couldn't keep your materials: " + error); + } + + // ── Plug-ins: content/Fry3DX/plugins (they come with it) and the settings folder's plugins ── + static std::string pluginsFolder() { return keel::prefPath() + "plugins/"; } + std::set pluginsOff() const { + std::set off; + std::istringstream in(fry_pluginsoff.str()); + for (std::string f; std::getline(in, f, '|');) + if (!f.empty()) off.insert(f); + return off; + } + void setPluginOn(const std::string& file, bool on) { + std::set 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(); + } + void loadPlugins() { + for (const PluginEntry& e : plugins_) fry::clearOwnMaterials(e.file); + plugins_.clear(); + std::error_code ec; + stdfs::create_directories(pluginsFolder(), ec); + std::vector> 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 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(in), {})}); + } + const std::set off = pluginsOff(); + fry::Scene scratch; // (loading a plug-in can't touch the open file: only its commands, when run, do) + std::set none; + uint32_t noActive = 0; + for (auto& [e, code] : found) { + e.on = !off.count(e.file); + if (e.on) { + e.p = std::make_unique(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); + } + void openPluginsFolder() { + std::error_code ec; + stdfs::create_directories(pluginsFolder(), ec); + SDL_OpenURL(("file://" + pluginsFolder()).c_str()); + } + bool pluginUsable(size_t i) const { return i < plugins_.size() && plugins_[i].p && plugins_[i].error.empty(); } + void pluginMenuItems(const char* menu) { + 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); + } + } + void pluginMenuItem(size_t i, size_t c) { + 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()); + } + void runPluginCommand(size_t i, size_t c, const std::vector& values) { + 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. + void commandForm() { + 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(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(); + } + void pluginsWindow() { + 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."); + std::optional> toggle; + for (size_t i = 0; i < plugins_.size(); ++i) { + const PluginEntry& e = plugins_[i]; + 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"); + 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"); + ImGui::EndChild(); + ImGui::PopID(); + } + ImGui::End(); + if (toggle) setPluginOn(toggle->first, toggle->second); + } + static int growString(ImGuiInputTextCallbackData* d) { + if (d->EventFlag == ImGuiInputTextFlags_CallbackResize) { + auto* str = static_cast(d->UserData); + str->resize(size_t(d->BufTextLen)); + d->Buf = str->data(); + } + return 0; + } + // A one-line field for a std::string (it grows as it's typed into). + static bool inputString(const char* label, std::string& text, const char* hint = nullptr) { + return ImGui::InputTextWithHint(label, hint ? hint : "", text.data(), text.capacity() + 1, ImGuiInputTextFlags_CallbackResize, growString, &text); + } + void scriptPanel() { + 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 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(); + } + + void helpWindow() { + 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> 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("Looking around", {{"Right drag", "Orbit round what you look at"}, {"Middle drag / Shift+Right drag", "Pan"}, {"Wheel", "Zoom (towards the mouse)"}, + {"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(); + } + + void aboutWindow() { + 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("Fry3DX", roleColor(Role::Text)); + ImGui::PopFont(); + centred("Frying 3D Extreme", accentText()); + centred("Version " FRY_VERSION, roleColor(Role::TextSecondary)); + ImGui::Dummy(ImVec2(0, 8)); + ImGui::PushStyleColor(ImGuiCol_Text, roleColor(Role::TextSecondary)); + ImGui::TextWrapped("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."); + 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> rows = { + {"Version", FRY_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("https://nexoniarz.github.io/Fry3DX/"); + popAccent(); + ImGui::SameLine(); + if (ImGui::Button("Source on GitHub", ImVec2(bw, 0))) SDL_OpenURL("https://github.com/Nexoniarz/Fry3DX"); + ImGui::SameLine(); + if (ImGui::Button("Copy details", ImVec2(bw, 0))) { + std::string text = "Fry3DX"; + 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"); + } + ImGui::Dummy(ImVec2(0, 6)); + ImGui::PushStyleColor(ImGuiCol_Text, roleColor(Role::TextTertiary)); + ImGui::TextWrapped("Made by Nexoniarz, open source (Apache License 2.0). Built on SDL 3, Dear ImGui, Lua 5.4, GLM, zstd, stb, volk and VMA; " + "the look is Niveus."); + ImGui::PopStyleColor(); + ImGui::End(); + } +}; + +} // namespace int main(int argc, char** argv) { std::string path; @@ -1809,9 +7282,6 @@ int main(int argc, char** argv) { if (std::string(argv[i]).ends_with(".fry") && argv[i - 1][0] != '+') path = argv[i]; // (not a console command's own) else rest.push_back(argv[i]); } -#if defined(__ANDROID__) // (a phone: sideways only; SDL would otherwise let a resizable window turn upright) - SDL_SetHint(SDL_HINT_ORIENTATIONS, "LandscapeLeft LandscapeRight"); -#endif - fryapp::App app(path); + App app(path); return keel::run(app, int(rest.size()), rest.data()); } diff --git a/src/app.h b/src/app.h deleted file mode 100644 index 70b8718..0000000 --- a/src/app.h +++ /dev/null @@ -1,1549 +0,0 @@ -// Fry3DX — Frying 3D Extreme (the app: its state and what it does, declared; defined in app*.cpp). A 3D modeller that works the way you'd expect: pick a tool from the -// toolbox, click and drag. The view in the middle is drawn by the engine's renderer: plain -// and clear while you model (Solid), or properly lit (Rendered). The panels are Dear ImGui. -// -// fry3dx [file.fry] -#pragma once -#include "bvh.h" -#include "commands.h" -#include "icons.h" -#include "mesh.h" -#include "scene.h" -#include "script.h" -#include "sculpt.h" -#include "shapes.h" -#include "import.h" -#include "text.h" - -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#include -#include -#include -#include -#include -#include -#include -#include - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -using namespace fry; -namespace stdfs = std::filesystem; - -namespace fryapp { - -inline double now() { return double(SDL_GetTicks()) / 1000.0; } // (seconds; works before the UI exists) - -constexpr float kUnits = 39.37f; // the engine's units (inches) in a metre: the view renders in them -constexpr float kPi = 3.14159265358979f; - -enum class Mode { Object, Edit }; -enum class Tool { Select, Move, Rotate, Scale, Draw, Extrude, Inset, Bevel, LoopCut, Knife, Hole }; -enum class Tab { Model, Paint, Layout, Render, Print }; -enum class LayTool { Select, Move, Rotate, Scale }; -enum class PrintTool { None, Measure, LayFlat }; -enum class PaintTool { Brush, Eraser, Fill, Picker, Picture }; -enum class Elem { Vertex, Edge, Face }; -enum class View { Perspective, Front, Back, Left, Right, Top, Bottom }; -enum class Orient { Local, World, Normal }; -// which way the gizmo's arrows point -enum class Pivot { Middle, Origin, Opposite, Bottom }; // what stays put when turning / sizing -enum class Shading { Solid, Rendered, Wire }; -enum class SelShape { Box, Brush, Lasso }; - -struct ToolInfo { - Tool tool; - Icon icon; - const char* name; - const char* key; - const char* hint; - bool objectMode, editMode; -}; -const ToolInfo kTools[] = { - {Tool::Select, Icon::Select, "Select", "Q", "Click to select; drag round things. Objects: Shift adds, Ctrl takes away. Shape: Ctrl adds/takes one, Shift+click a whole loop, Shift+Ctrl+click an edge ring.", true, true}, - {Tool::Move, Icon::Move, "Move", "W", "Drag an arrow to move along it, the square to move freely. Hold Ctrl to snap.", true, true}, - {Tool::Rotate, Icon::Rotate, "Rotate", "E", "Drag a ring to turn about it; the outer ring turns it as you see it. Ctrl: 15 degree steps.", true, true}, - {Tool::Scale, Icon::Scale, "Scale", "R", "Drag a side's handle to stretch it (the other side stays, or see Tool options); hold Shift to stretch both sides from the middle. Middle: all ways.", true, true}, - {Tool::Draw, Icon::Draw, "Draw shape", "D", "Click points round a shape (on the ground, or on any face of an object); click the first point (or press Enter) to finish. Esc cancels.", true, false}, - {Tool::Extrude, Icon::Extrude, "Extrude", "X", "Select faces, then drag the arrow to pull them out (along the way they face).", false, true}, - {Tool::Inset, Icon::Inset, "Inset", "I", "Select faces, then drag the handle to make a smaller face inside each.", false, true}, - {Tool::Bevel, Icon::Bevel, "Round edges", "B", "Select edges (or faces), then drag the round handle to round them off.", false, true}, - {Tool::LoopCut, Icon::LoopCut, "Loop cut", "C", "Point at an edge: the cut shows across. Click to cut there.", false, true}, - {Tool::Knife, Icon::Knife, "Knife", "K", "Click points along where to cut, or drag a straight line. Enter or double-click cuts. Cutting right through: Tool options.", false, true}, - {Tool::Hole, Icon::Hole, "Hole", "O", "Press on a face and drag out the size: round, rectangle, triangle or hexagon (nuts); all the way through, a pocket, or only its outline (a new face).", true, true}, -}; - -struct PaintToolInfo { - PaintTool tool; - Icon icon; - const char* name; - const char* key; - const char* hint; -}; -struct LayToolInfo { - LayTool tool; - Icon icon; - const char* name; - const char* key; - const char* hint; -}; -const LayToolInfo kLayTools[] = { - {LayTool::Select, Icon::Select, "Select", "Q", "Click frames (or corners) to pick them, Shift adds; drag round them to pick many. Clicking the model picks its frame too."}, - {LayTool::Move, Icon::Move, "Move", "W", "Drag frames (or corners) about the picture."}, - {LayTool::Rotate, Icon::Rotate, "Turn", "E", "Drag round to turn what's picked (Ctrl: 15 degree steps)."}, - {LayTool::Scale, Icon::Scale, "Size", "R", "Drag out or in to make what's picked bigger or smaller (Shift: only across or only up)."}, -}; -// Printers' build plates (mm, across x deep x high) and what's printed with. -struct PrinterInfo { - const char* name; - float x, y, z; -}; -const PrinterInfo kPrinters[] = { - {"Prusa MK4 / MK4S", 250, 210, 220}, {"Prusa Core One", 250, 220, 270}, {"Prusa MINI+", 180, 180, 180}, {"Bambu Lab X1 / P1 / P2", 256, 256, 256}, - {"Bambu Lab A1", 256, 256, 256}, {"Bambu Lab A1 mini", 180, 180, 180}, {"Creality Ender 3 (V2, V3)", 220, 220, 250}, {"Creality K1", 220, 220, 250}, - {"Elegoo Neptune 4", 225, 225, 265}, {"Anycubic Kobra 3", 250, 250, 260}, {"Your own size", 0, 0, 0}, -}; -struct MaterialInfo { - const char* name; - float density; // g/cm3 -}; -// What the Script panel starts with. -inline const char* kFirstScript = R"(-- A script builds or changes the scene. F5 or Ctrl+Enter runs it; --- Ctrl+Z takes back everything it did. The ? button lists what there is. - -local legs = 6 -for i = 1, legs do - local a = math.rad(i * 360 / legs) - local leg = fry.add("cylinder", {radius = 0.03, height = 0.7, color = "#c47a3c"}) - leg.position = {math.cos(a) * 0.45, math.sin(a) * 0.45, 0} -end -local top = fry.add("cylinder", {radius = 0.6, height = 0.05, sides = 48, color = "#d9a066"}) -top.position = {0, 0, 0.7} -fry.frame() -)"; -const MaterialInfo kMaterials[] = {{"PLA", 1.24f}, {"PETG", 1.27f}, {"ABS", 1.04f}, {"ASA", 1.07f}, {"TPU", 1.21f}, {"Nylon", 1.14f}, {"Resin", 1.12f}}; - -struct SculptToolInfo { - SculptBrush brush; - Icon icon; - const char* name; - const char* key; - const char* hint; -}; -const SculptToolInfo kSculptTools[] = { - {SculptBrush::Draw, Icon::Sculpt, "Raise", "1", "Builds the surface up where you brush. Ctrl digs in instead."}, - {SculptBrush::Inflate, Icon::Inflate, "Puff out", "2", "Swells it out all round, like blowing it up. Ctrl shrinks it."}, - {SculptBrush::Smooth, Icon::Smooth, "Smooth", "3", "Evens out bumps. Hold Shift with any brush to smooth."}, - {SculptBrush::Flatten, Icon::Flatten, "Flatten", "4", "Presses it flat, like a palette knife."}, - {SculptBrush::Pinch, Icon::Pinch, "Pinch", "5", "Pulls it in towards the middle of the brush: sharp creases and ridges. Ctrl pushes apart."}, - {SculptBrush::Grab, Icon::Grab, "Grab", "6", "Take hold of a patch and drag it where it should be."}, -}; - -const PaintToolInfo kPaintTools[] = { - {PaintTool::Brush, Icon::PaintBrush, "Brush", "B", "Paint on the model or on the flat picture. Alt+click picks a colour; [ and ] change the size."}, - {PaintTool::Eraser, Icon::Eraser, "Eraser", "E", "Paints its plain colour back."}, - {PaintTool::Fill, Icon::Fill, "Fill", "G", "Fills the patch of about the same colour where you click."}, - {PaintTool::Picker, Icon::Picker, "Pick colour", "I", "Click to take a colour from the model or the picture."}, - {PaintTool::Picture, Icon::Picture, "Place a picture", "Ctrl+V", "Paste a picture (copied in any app) or add one from a file, then drag its corners to fit. Enter places it."}, -}; - -inline glm::vec3 forwardOf(float yaw, float pitch) { - const float y = glm::radians(yaw), p = glm::radians(pitch); - return {std::cos(p) * std::cos(y), std::cos(p) * std::sin(y), -std::sin(p)}; -} - -// The engine's view-projection (render/renderer.cpp), in metres. -inline glm::mat4 viewProjection(const glm::vec3& eye, float yaw, float pitch, float fov, float aspect, glm::vec2 shift, glm::vec2 nearFar) { - const glm::vec3 f = forwardOf(yaw, pitch); - glm::mat4 view = glm::lookAt(eye * kUnits, eye * kUnits + f, glm::vec3(0, 0, 1)); - glm::mat4 proj = glm::perspective(glm::radians(fov), aspect, nearFar.x, nearFar.y); - proj[1][1] *= -1.0f; - proj[2][0] -= shift.x, proj[2][1] -= shift.y; - return proj * view * glm::scale(glm::mat4(1.0f), glm::vec3(kUnits)); -} - -struct Ray { - glm::vec3 origin, dir; -}; - -// Möller–Trumbore; t along the ray, or nothing. -inline std::optional hitPlane(const Ray& r, const glm::vec3& point, const glm::vec3& normal) { - const float d = glm::dot(r.dir, normal); - if (std::abs(d) < 1e-8f) return std::nullopt; - const float t = glm::dot(point - r.origin, normal) / d; - return t > 0 ? std::optional(r.origin + r.dir * t) : std::nullopt; -} - -// Where along the line (from `p`, along unit `axis`) the ray passes closest. -inline float closestOnAxis(const Ray& r, const glm::vec3& p, const glm::vec3& axis) { - const glm::vec3 w = p - r.origin; - const float b = glm::dot(axis, r.dir), d = glm::dot(axis, w), e = glm::dot(r.dir, w); - const float den = 1.0f - b * b; - return den < 1e-6f ? 0.0f : (b * e - d) / den; -} - -inline float distToSegment(ImVec2 p, ImVec2 a, ImVec2 b) { - const float vx = b.x - a.x, vy = b.y - a.y, len = vx * vx + vy * vy; - const float t = len > 0 ? std::clamp(((p.x - a.x) * vx + (p.y - a.y) * vy) / len, 0.0f, 1.0f) : 0.0f; - const float dx = a.x + vx * t - p.x, dy = a.y + vy * t - p.y; - return std::sqrt(dx * dx + dy * dy); -} - -inline std::string lower(std::string s) { - std::transform(s.begin(), s.end(), s.begin(), [](unsigned char c) { return char(std::tolower(c)); }); - return s; -} -inline bool insidePolygon(ImVec2 p, const std::vector& poly) { - bool in = false; - for (size_t i = 0, j = poly.size() - 1; i < poly.size(); j = i++) - if ((poly[i].y > p.y) != (poly[j].y > p.y) && p.x < (poly[j].x - poly[i].x) * (p.y - poly[i].y) / (poly[j].y - poly[i].y) + poly[i].x) in = !in; - return in; -} - -inline float snapTo(float v, float step) { return step > 0 ? std::round(v / step) * step : v; } - -using Axes = std::array; -inline glm::vec3 inFrame(const Axes& a, const glm::vec3& p) { return {glm::dot(p, a[0]), glm::dot(p, a[1]), glm::dot(p, a[2])}; } -inline glm::vec3 fromFrame(const Axes& a, const glm::vec3& c) { return a[0] * c.x + a[1] * c.y + a[2] * c.z; } - -const ImU32 kAxisCol[3] = {IM_COL32(235, 70, 70, 255), IM_COL32(110, 210, 80, 255), IM_COL32(70, 130, 245, 255)}; -const ImU32 kHot = IM_COL32(255, 225, 90, 255); -constexpr ImU32 kAccent = IM_COL32(255, 120, 40, 255); -// The UI's colours: Niveus's roles, in the appearance that's showing (keel/ui). -inline ImVec4 roleColor(keel::ui::Role r) { return ImGui::ColorConvertU32ToFloat4(keel::ui::color(r)); } -inline ImVec4 accentText() { return roleColor(keel::ui::Role::AccentText); } -// The one button that does it, filled with the accent (until popAccent). -inline void pushAccent() { - using keel::ui::Role; - ImGui::PushStyleColor(ImGuiCol_Button, roleColor(Role::Accent)); - ImGui::PushStyleColor(ImGuiCol_ButtonHovered, roleColor(Role::AccentHover)); - ImGui::PushStyleColor(ImGuiCol_ButtonActive, roleColor(Role::AccentActive)); - ImGui::PushStyleColor(ImGuiCol_Text, roleColor(Role::OnAccent)); -} -inline void popAccent() { ImGui::PopStyleColor(4); } - -// An object as it'll be printed: where it stands, with its finish, checked (kept until it changes). -struct PrintInfo { - uint64_t key = ~0ull; - Mesh world; - PrintCheck check; - glm::vec3 lo{0.0f}, hi{0.0f}; - keel::ModelId overhang{}; - bool hasOverhang = false; - // Walls too thin to print: how thick at each face (worked out again only when its shape or - // size changes: moving or turning it doesn't), how many under the least, the thinnest. - uint64_t thickKey = ~0ull; - std::vector thick; - int thinFaces = 0; - float thinnest = 1e30f; - keel::ModelId thin{}; - // The slice preview's layer: where it was cut, and for which shape (kept beside what it's of). - mutable uint64_t sliceKey = ~0ull; - mutable float sliceZ = -1; - mutable std::vector> slice; -}; - -extern keel::Cvar fry_printer; -extern keel::Cvar fry_bed_x; -extern keel::Cvar fry_bed_y; -extern keel::Cvar fry_bed_z; -extern keel::Cvar fry_touch; -extern keel::Cvar fry_uiscale; -extern keel::Cvar fry_uisize; -extern keel::Cvar fry_theme; -extern keel::Cvar fry_movespeed; -extern keel::Cvar fry_camera; -extern keel::Cvar fry_pluginsoff; -extern keel::Cvar fry_autosave; - -class App : public keel::Game {public: - explicit App(std::string path) : startPath_(std::move(path)) {} - const char* name() const override { return "Fry3DX"; } // (its settings folder, and bin/Fry3DX/) - const char* title() const override { return "Fry3DX"; } - bool wantsCursor() const override { return true; } - bool dockingUi() const override { return true; } - - // "Spiral_stairs" is "Spiral stairs" (typed in the console). - static bool sameCommand(const std::string& name, std::string typed); - void init() override; - - // Going into the background: the work is saved now (a phone may close the app while it's - // away, to free memory), and a phone's next start carries on with it, view and all. - std::string resumeFile() const { return keel::prefPath() + "resume.txt"; } - void onBackground() override; - void resumeWork(); - - void frame(double) override { autosave(); } - bool allowQuit() override; - void shutdown() override { - if (autosaveJob_.valid()) autosaveJob_.wait(); - } - void onEscape() override; - - void render(keel::Renderer& r) override; - -private: - // ── The document ── - Scene scene_; - std::string startPath_, path_; - bool dirty_ = false; - // An undo step. Its objects without their shapes (points, faces, painting's layout): those are - // kept apart and shared with the steps before and after where they're the same, so a step - // that moved one thing doesn't copy every shape in the scene again. - struct Shape { - std::shared_ptr mesh; - std::shared_ptr>> uvs; - }; - struct Snapshot { - Scene scene; - std::map shapes; - std::set selected; - uint32_t active; - Mode mode; - std::set verts, faces; - std::set> edges; - std::string label; // (what was done after it: the message said then) - double at = 0; - }; - std::vector undo_, redo_; - std::map lastShapes_; // (each object's shape as last kept: the next step shares it if it's the same) - Snapshot snapshot(std::string label, double at); - std::vector redoKept_; // (what redo had before the last undo point: back if that step is dropped) - - Mode mode_ = Mode::Object; - Tool tool_ = Tool::Move; - Elem elem_ = Elem::Face; - std::set selected_; - uint32_t active_ = 0; - // the object last picked (edited in shape editing) - uint32_t activeRef_ = 0; // a reference picture picked in the Layers panel - std::set selVerts_, selFaces_; - std::set> selEdges_; - - // ── Tabs: modelling, painting pictures onto it, making a picture of it ── - Tab tab_ = Tab::Model; - - // ── The view ── - keel::Renderer* r_ = nullptr; - glm::vec2 shift_{0.0f}; // (the view's middle on the screen: it sits between the panels) - View view_ = View::Perspective; - glm::vec3 target_{0, 0, 0.4f}; - float yaw_ = -135.0f, pitch_ = 25.0f, dist_ = 4.0f, fov_ = 50.0f; - glm::vec2 nearFar_{2.0f, 16384.0f}; - struct Reach { - uint64_t version = 0; - float radius = 0.0f; // (how far its mesh reaches from its place) - glm::vec3 lo{0.0f}, hi{0.0f}; // (the box round its mesh, its mirrored copies too) - }; - std::map radii_; // (each object's) - ImVec2 viewMin_{0, 0}, viewMax_{1, 1}; - glm::mat4 vp_{1.0f}; - glm::vec3 eye_{0.0f}; - bool orbiting_ = false, panning_ = false, looking_ = false; // (looking: right drag turns the camera where it stands) - float nearest_ = 1.0f; // (how far the nearest thing is from the eye, m: worked out with the view) - bool xray_ = false, showGrid_ = true, renderGrid_ = false; - std::string appliedLook_; - keel::ModelId ground_{}; - bool haveGround_ = false; - Shading shading_ = Shading::Solid; - - // ── Tools ── - bool snap_ = false; - int snapKind_ = 0; // (moving: 0 steps, 1 points, 2 edges' middles, 3 faces' middles) - float snapMove_ = 0.05f, snapAngle_ = 15.0f, snapScale_ = 0.1f; - float drawThickness_ = 0.05f, extrudeAmount_ = 0.1f, insetAmount_ = 0.2f, mergeDistance_ = 0.001f; - float bevelWidth_ = 0.05f; - int bevelSteps_ = 3, cuts_ = 1; - Orient orientObject_ = Orient::Local, orientEdit_ = Orient::Normal; - Pivot rotatePivot_ = Pivot::Middle, scalePivot_ = Pivot::Opposite; - SelShape selShape_ = SelShape::Box; - float brushRadius_ = 30.0f; - std::vector drawPts_; - // Drawing on an object: the plane of the face the first point went on, and what's made with it - // (0 a new object standing on it, 1 added to it, 2 cut into it). - glm::vec3 drawOnP_{0.0f}, drawOnN_{0.0f, 0.0f, 1.0f}; - uint32_t drawOnto_ = 0; - int drawOnMode_ = 0; - struct Drag { - bool active = false; - int handle = -1; // 0-2 an axis, 3 free / even / about the view, 4-6 an axis's other end - Tool tool = Tool::Select; - Axes axes{}; - glm::vec3 pivot{0.0f}, anchor{0.0f}, handlePoint{0.0f}; - ImVec2 startMouse{0, 0}; - float startT = 0.0f, startDist = 0.0f, startMid = 0.0f; // (the handle's way from what stays put; from the middle) - glm::vec3 startHit{0.0f}; - Scene before; - std::set> bevelEdges; - std::vector> soft; // points near the selection that follow it, and how much - std::string typed; // (a number typed while dragging: exactly that much) - } drag_; - // Soft selection (shape editing): points within `softReach_` of the selection follow it, less - // the further they are (Blender's proportional editing, Maya's soft select). - bool softSel_ = false; - float softReach_ = 0.3f; - // Push out / pull in (Alt+S): the points and the shape as it was when it started. - bool showPush_ = false; - float pushBy_ = 0.0f; - std::set pushPts_; - Mesh pushFrom_; - uint32_t pushObject_ = 0; - bool boxing_ = false, painting_ = false, lassoing_ = false, tapDraw_ = false; - ImVec2 boxStart_{0, 0}; - std::vector lasso_; - std::pair cutHover_{-1, -1}; - // The knife: points clicked on the screen (or a straight drag). - std::vector knifePts_; - bool knifeDown_ = false; - ImVec2 knifePress_{0, 0}; - bool knifeThrough_ = false, knifeFill_ = true; - int knifeAfter_ = 1; // cut right through, then: 0 just the lines, 1 pieces of the one object, 2 objects of their own, 3 the smaller ones gone - // Holes: pressed on a face, dragged out to size. - // (sizes in metres where it stands: across = a round's width, a rectangle's width, a triangle's - // side, a hexagon's flats; tall: a rectangle's height) - float holeSize_ = 0.2f, holeTall_ = 0.1f, holeDepth_ = 0.1f, holeTurn_ = 0.0f, holeRoom_ = 0.0003f; - int holeShape_ = 0, holeSides_ = 32; // a HoleShape - int holeMode_ = 0; - // 0 all the way through, 1 a pocket, 2 the outline only (a new face) - struct HoleDrag { - bool active = false; - uint32_t object = 0; - int face = -1; - glm::vec3 centre{0.0f}; // (the object's own space) - float radius = 0.0f; - ImVec2 press{0, 0}; - } hole_; - - // ── Painting ── - PaintTool paintTool_ = PaintTool::Brush; - glm::vec4 paintColor_{0.85f, 0.25f, 0.1f, 1.0f}; // a: how strong - float brushPx_ = 16.0f, hardness_ = 0.5f, fillTolerance_ = 0.15f; - int pictureSize_ = 1024; - bool showUvLines_ = true; - struct PaintStroke { - bool active = false, in2D = false, haveLast = false, waiting = false; // waiting: pressed off the object, starts when the brush gets to it - uint32_t object = 0; - std::shared_ptr image; // (the object's picture: a copy made for this stroke) - Stroke stroke; - glm::vec3 last{0.0f}; - glm::vec2 last2D{0.0f}; - } paint_; - // Sculpting (the Paint tab's Shape side): brushes that push the surface about like clay. - bool sculpting_ = false; - SculptBrush sculptBrush_ = SculptBrush::Draw; - float sculptStrength_ = 0.5f, sculptPx_ = 60.0f; // (its own brush size: sculpting wants a bigger one than painting) - bool paintMirror_ = false, stencilOn_ = false, drawingStencil_ = false; - std::vector stencil_; // (on the screen) - bool sculptMirror_ = false; // the same on the other side (left and right: the object's Y) - struct SculptStroke { - bool active = false, haveLast = false, waiting = false; - uint32_t object = 0; - std::unique_ptr mesh; - glm::vec3 last{0.0f}; - // Grab: where it was taken hold of (the world), and what (the object's own space). - glm::vec3 start{0.0f}; - std::vector> held[2]; - std::vector from[2]; - } sculpt_; - // A picture layer unlocked to be moved: which, and what's being dragged (a corner 0-3, 4 the - // whole of it, 5 the turning handle) from where. - struct LayerEdit { - uint32_t object = 0; - int index = -1, drag = -1; - bool fresh = false; // (just pasted: Esc takes it away again) - glm::vec2 grab{0.0f}, offset{0.0f}; - std::array startCorners{}; - PictureLayer start; - } ledit_; - bool pasteOnSurface_ = true; - uint32_t renamingFolder_ = 0; - // Touch (set when it starts: fry_touch). - bool touchMode_ = false, portrait_ = false, showToolbox_ = false, panelsOpen_ = false, viewsOpen_ = false, shiftLatch_ = false, ctrlLatch_ = false, touchOrbit_ = false; - struct Gesture { - bool two = false, longDone = false, rightUp = false; - ImVec2 mid{}, downPos{}; - float spread = 1; - double downAt = -1; - } gest_; - std::string captureUri_; // (a picture of the view going into a picked file) - struct CopiesSetting { - int count = 3, axis = 2; - bool round = false, joined = false, path = false, follow = true; // (path: along the last picked object) - glm::vec3 step{0.3f, 0.0f, 0.0f}, centre{0.0f}; - float angle = 360.0f; - } copies_; - bool showCopies_ = false, showThicken_ = false, thicknessOut_ = false; - float thickness_ = 0.002f; - // The Layout tab: what's picked (frames, or corners as face and corner) and what's dragged. - struct LayoutState { - uint32_t object = 0; - bool corners = false; - std::set frames; - std::set> points; - int drag = 0; // 1 moving / turning / sizing, 2 a box - glm::vec2 grab{0.0f}, centre{0.0f}; - ImVec2 boxFrom{}; - std::vector> start; - std::shared_ptr startPicture; - double remapAt = 0; - } lay_; - LayTool layTool_ = LayTool::Move; - bool layKeepPaint_ = true, layChecker_ = false; - // The Print tab. - PrintTool printTool_ = PrintTool::None; - std::vector measure_; - bool measureSnap_ = true, showOverhangs_ = true, sizing_ = false, showThin_ = true, showLayer_ = false; - int layer_ = 1; - float layerHeight_ = 0.0002f; // (m) - float minWall_ = 0.0008f; // (m: the thinnest wall that prints well, twice a 0.4 mm nozzle) - float overhangDeg_ = 45.0f, infill_ = 15.0f; - int material_ = 0; - std::map printInfos_; - keel::ModelId bed_{}; - glm::vec3 bedFor_{0.0f}; - // Closing, New or Open with changes not saved: asked first (askSave_), then done; afterSave_: - // what to do once the Save it chose has finished (0 nothing, 1 quit, 2 new, 3 open). - int askSave_ = 0, afterSave_ = 0; // (what to do then: 1 quit, 2 new, 3 open..., 4 open dropped_) - std::string dropped_; - bool dialogCancelled_ = false; - // Autosave: a copy packed small in the background every fry_autosave minutes while there are - // changes, kept until saved or closed properly; offered back after a crash. - std::string autosavePath_; - uint64_t edits_ = 0, autosavedEdits_ = 0; - double lastAutosave_ = 0; - std::future autosaveJob_; - std::vector recover_; - bool showRecover_ = false; - std::map> owner_; // which layer shows at each pixel - struct Uploaded { - uint64_t stamp = 0; - double at = 0; - bool dirty = false; - }; - std::map uploaded_; - std::map paintTex_; - float picZoom_ = 0.0f; // the Picture panel: screen pixels a picture pixel, and where its corner is - ImVec2 picPan_{0, 0}; - uint32_t picFor_ = 0; - - // ── Saving a picture of the view (cropped to it, without the gizmos) ── - int captureFrames_ = 0; - std::string capturePath_; - - // ── GPU side ── - std::map> models_; // object id → its model, the state built - std::map versions_; - uint64_t versionClock_ = 1; - struct Shown { - uint64_t version = 0; // (the shape key it was made for) - bool mirrorOnly = false; - Mesh mesh; - std::shared_ptr tree; // (made with it, when it was made on another thread) - std::future>> coming; // (a big one being made on another thread) - uint64_t comingVersion = 0; - bool comingMirror = false; - }; - std::map shown_; - struct Picture { - bool ok = false; - float aspect = 1.0f; - }; - std::map pictures_; - keel::TextureId logo_{}; - bool logoTried_ = false, haveLogo_ = false; - - // ── UI ── - int appliedTheme_ = -1; - // The Script panel: the script being written, where it's from, what it printed. - bool showScript_ = false, showScriptHelp_ = false; - size_t script_ = 0; // the file's script shown in the Script panel - std::string scriptPath_; // where it was last saved or opened from (Save as starts there) - // Plug-ins (Script > Plug-ins): each file, whether it's on, and how loading it went. - struct PluginEntry { - std::string path, file, error; - bool bundled = false, on = true; - std::unique_ptr p; - }; - std::vector plugins_; - bool showPlugins_ = false; - std::pair pluginTool_; // (the plug-in tool that's on: its file and name) - std::string pluginFilter_; - // ── Commands: found by typing, on keys of your own, round the mouse ── - std::vector commands_; - fry::CommandSearch search_; - std::vector recentCommands_; - std::map myKeys_; // (a command's id: the key you gave it) - int pie_ = 0; - // 1: the tools round the mouse (Space held), 2: the views (Shift+Space) - ImVec2 pieAt_{0, 0}; - bool pieStays_ = false; // (opened from the menu or console: until a click) - std::string settingKey_; - bool showHistory_ = false; - // ── Workspaces: the panels as they were, kept under a name ── - bool showWorkspaces_ = false, layoutAgain_ = false; - std::string pendingLayout_; - char workspaceName_[64] = "My workspace"; - // (the command waiting for its new key, in Keys and mouse) - // A plug-in's command waiting for its settings (the form), and a material being named. - struct PendingCommand { - size_t plugin = 0, command = 0; - std::vector values; - }; - std::optional pendingCommand_; - bool nameMaterial_ = false; - std::string materialName_; - bool materialKeepColor_ = false; - std::vector> scriptLog_; // (line, an error) - bool scriptLogToBottom_ = false; - bool styled_ = false, layoutDone_ = false, showHelp_ = false, showAbout_ = false; - std::string iniPath_, message_; - double messageAt_ = -100.0; - char renameBuf_[128] = ""; - uint32_t renaming_ = 0; - std::mutex dialogMutex_; - std::string dialogResult_; - int dialogKind_ = 0; // 1 open, 2 save, 3 export, 4 reference picture, 5 picture of the view, 6 a picture to paint on, 7 save the painted picture - int pendingKind_ = 0, dialogFilter_ = -1; - - // ───────────────────────────── document ───────────────────────────── - - Object* find(uint32_t id); - Reference* findRef(uint32_t id); - Object* editing() { return mode_ == Mode::Edit ? find(active_) : nullptr; } - void touched(uint32_t id) { versions_[id] = ++versionClock_, dirty_ = true, ++edits_; } - // What decides the shape an object shows, as a number: its points and faces, its finishing - // touches, creases, cuts, shading and painting's layout (not where it is). Worked out again - // only when it's been touched: moving a smoothed object doesn't smooth it again each frame. - struct ShapeKey { - uint64_t version = 0, key = 0; - }; - std::map shapeKeys_; - uint64_t shapeKey(const Object& o); - void touchAll() { - for (const Object& o : scene_.objects) versions_[o.id] = ++versionClock_; - } - // Its shape has been changed by hand: the library settings no longer apply. - void handMade(Object& o) { o.recipe.clear(), o.settings.clear(); } - void say(std::string text); - - void pushUndo(); - // The undo point just made, dropped (what it was for didn't happen): redo as it was before. - void dropUndo(); - // Back to the undo point just made, and it dropped. - void takeBack(); - void restore(Snapshot& s); - void undo(); - void redo(); - - void newScene(); - Object& addObject(const std::string& base, Mesh mesh); - std::string uniqueName(const std::string& base); - - // A shape from the library, standing on the ground under the middle of the view. - void addShape(const std::string& id, bool undoable = true); - - void deleteSelected(); - - void duplicateSelected(); - - // The selected objects become one (the last picked keeps its place, look and name). - void joinSelected(); - - // The selected faces become an object of their own. - void separateSelected(); - - // ───────────────────────────── files ───────────────────────────── - - // ── Files a phone's file picker gives (content://...): read and written through SDL ── - static bool isUri(const std::string& p) { return p.starts_with("content://"); } - // A file's name as shown (a picked file's: from the end of its address, decoded). - static std::string fileName(const std::string& p); - // A picked file copied to a file of our own to read ("" if it couldn't be). - static std::string fromUri(const std::string& uri, const std::string& name); - // A file of ours written into the picked one. - static bool toUri(const std::string& local, const std::string& uri); - static std::string tempFile(const std::string& name); - - void openFile(const std::string& picked); - bool saveFile(std::string path); - static std::string sizeText(uintmax_t n) { - return n < 10240 ? std::format("{} bytes", n) : n < 10 << 20 ? std::format("{} KB", n >> 10) : std::format("{:.1f} MB", double(n) / 1048576.0); - } - static std::vector pngBytes(const Image& im); - void exportFile(std::string path, int filter = -1, const std::string& asExt = ""); - - // ── Closing with changes, autosave ── - - // New, Open, Quit: asks about saving first if there are changes. - void whenSaved(int action); - void doAfterAsking(int action); - // A file dropped on the window (or opened with Fry3DX from the desktop): a .fry opens (after - // asking about changes), a script goes in the Script panel, a picture goes on as a reference - // (or, in the Paint tab, as a sticker on the model). - void onFileDropped(const std::string& path) override; - void saveQuestion(); - std::string autosaveDir() const { return keel::prefPath() + "autosave/"; } - // Every fry_autosave minutes (when there are changes and nothing's being dragged): a copy - // packed as small as it goes, in the background. - void autosave(bool force = false); - // Its autosave isn't needed (saved, or closed on purpose). - void dropAutosave(); - // Autosaves left behind (it didn't close properly): offered back. Only the newest 15 are - // kept; older ones are deleted. - void findAutosaves(); - void recoverWindow(); - - // A picture of the 3D view only (no panels, gizmos or outlines): the screen is saved a few - // frames on, with those hidden, then cut down to the view. - void savePicture(std::string path); - bool capturing() const { return captureFrames_ > 0; } - void captureStep(); - static bool loadImageFile(const std::string& path, Image& out); - static bool writePng(const std::string& path, const Image& im) { return stbi_write_png(path.c_str(), im.w, im.h, 4, im.px.data(), im.w * 4) != 0; } - void addPicture(const std::string& path); - - // SDL's own file dialogs (the desktop's): the answer comes back later, maybe on another thread. - void dialog(int kind); - void takeDialogResult(); - - // ───────────────────────────── modes and tools ───────────────────────────── - - void setTool(Tool t); - void enterEdit(); - void leaveEdit(); - void clearElements() { selVerts_.clear(), selFaces_.clear(), selEdges_.clear(); } - void selectAllElements(); - void selectAll(); - void selectNone(); - // The points the selection covers, whichever kind is picked. - std::set selectedPoints(); - // Selects (in the current kind) what these points cover. - void selectPoints(const std::set& pts); - void growSelection() { - if (Object* o = editing()) selectPoints(growVerts(o->mesh, selectedPoints())); - } - void shrinkSelection(); - // Like the picked: faces that face the same way, edges as long, points with as many edges. - void selectSimilar(); - // The picked edges' whole loops (or the rings across them). - void loopsOfSelected(bool ring); - void selectLinked() { - if (Object* o = editing()) selectPoints(linkedVerts(o->mesh, selectedPoints())); - } - void invertSelection(); - // The edges a rounding would work on: those selected, or round the selected faces, or between - // selected points. - std::set> edgesToRound(); - - void deleteElements(); - // After a change that renumbers things: nothing selected, the shape is hand-made now. - void changedShape(Object& o, bool keepSelection = false); - void extrudeNow(float amount); - void insetNow(float amount); - void bevelNow(); - void subdivideNow(bool smooth); - void cutAt(Object& o, std::pair edge); - void makeFaceNow(); - void mergeNow(); - void smoothPointsNow(); - void startPush(); - void pushWindow(); - void bridgeNow(); - void flipNow(); - // Rotation and scale baked into the points (so the object's turn is none and its size is 1). - void applyTransform(Object& o); - void applyFinish(Object& o); - void originToMiddle(Object& o); - void originToBottom(Object& o); - void mirror(Object& o, int axis); - - - // ───────────────────────────── tabs ───────────────────────────── - - void setTab(Tab t); - Shading shading() const { return tab_ == Tab::Render ? Shading::Rendered : shading_; } - - // ───────────────────────────── the knife and holes ───────────────────────────── - - // Snapped to steps of 45 degrees from the last point while Ctrl is held. - ImVec2 knifeEnd(const ImGuiIO& io) const; - static Mesh subMesh(const Mesh& m, const std::set& faces); - // A new object like `from` (its look, place and finish), with another shape. - uint32_t objectLike(uint32_t from, Mesh mesh, const std::string& name); - void knifeApply(uint32_t id, std::vector pts); - static float objectScale(const Object& o) { return std::max(std::cbrt(std::abs(glm::determinant(glm::mat3(objectMatrix(o))))), 1e-6f); } - // How wide (across) a shape is when it reaches `reach` from its middle (dragging it out). - float holeAcrossFor(float reach) const { - return HoleShape(holeShape_) == HoleShape::Triangle || HoleShape(holeShape_) == HoleShape::Hexagon ? reach * std::sqrt(3.0f) : reach * 2.0f; - } - // The outline, `across` wide in the object's own size (a rectangle keeps its proportions). - std::vector holeShapeOutline(const Object& o, int face, const glm::vec3& centre, float across) const; - void applyHole(uint32_t id, int face, const glm::vec3& centre, float across); - // The hole tool's outline under the mouse (or being dragged out), in the world. - std::vector holePreview(const ImGuiIO& io); - // Where the mouse is on a face's plane (the object's own space). - std::optional onFacePlane(const Object& o, int face, ImVec2 mouse); - - // ───────────────────────────── 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). - Object* readyToPaint(uint32_t id); - // The object's picture, copied so this change can be undone; the copy is now the object's. - Image& changePicture(Object& o); - void beginStroke(Object& o, bool in2D); - void endStroke(); - // Where on its picture a ray hits an object (0..1), or nothing. - std::optional uvUnder(const Object& o, const Ray& ray); - // 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). - void dab3D(Object& o, const glm::vec3& at, float radius, const glm::vec3* eyeAt = nullptr, const glm::mat4* back = nullptr); - // The brush at a point of the view: starts a stroke on what's there, or carries one on. - void paintAt(ImVec2 mouse, bool start); - void pickColor(const Object& o, glm::vec2 uv); - void fillAt(Object& o, glm::vec2 uv); - - // ── 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. - Object* readyToSculpt(uint32_t id); - void endSculpt(); - // The brush at a point of the view: starts a stroke on what's there, or carries one on. - void sculptAt(ImVec2 mouse, bool start, bool ctrl, bool shift); - void sculptViewport(const ImGuiIO& io); - - // ── Picture layers ── - - PictureLayer* editLayer(); - // 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. - void startFloating(Image img); - void floatFromFile(const std::string& path); - void pasteFromClipboard(); - // Enter: it stays where it is, a layer (click it in the Picture panel to move it again). - void lockLayer(); - void unlockLayer(uint32_t object, int index); - // Esc: a picture just pasted goes again; one unlocked is locked again. - void cancelFloating(); - void deleteLayer(Object& o, int i); - // The layer painted into the painting for good (it can't be moved after). - void mergeLayer(Object& o, int i); - // 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. - bool stickerHandles(const Object& o, const PictureLayer& l, std::array& out); - int stickerHandle(const Object& o, const PictureLayer& l, ImVec2 mouse); - // Moving a sticker on the model: it slides over the surface (onto other faces too), its - // corners size it, its handle turns it. - void stickerMouse(const ImGuiIO& io); - // A cheap fingerprint of what the object's picture shows (its painting, its layers, its shape). - uint64_t pictureStamp(const Object& o); - // 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. - void uploadPicture(const Object& o, bool 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. - std::vector> layoutPicked(const Object& o, const std::vector>& islands) const; - // A corner and every other on the same point of the model at the same place on the picture. - std::set> layoutCorner(const Object& o, int f, int c) const; - void layoutSelectAll(); - // The picked corners moved by `to` (picture pixels), painting carried along if wanted. - void layoutChange(Object& o, const std::vector>& from, const std::shared_ptr& picture, - const std::vector>& picked, const std::function& to, bool final); - // Buttons' changes (turn, flip, straighten): about the middle of what's picked. - void layoutApply(const std::function& to); - void layoutStraighten(); - // Laid out again by itself, what's painted carried over onto the new layout. - void layoutAgain(Object& o); - - template - void layoutCanvas(Object& o, ImDrawList* dl, F toScreen, glm::vec2 mouse, bool hovered, int W, int H) { - const ImGuiIO& io = ImGui::GetIO(); - if (lay_.object != o.id) lay_ = {}, lay_.object = o.id; - const glm::vec2 size{float(W), float(H)}; - const auto islands = uvIslands(o.mesh, o.uvs); - std::vector islandOf(o.mesh.faces.size(), -1); - for (size_t i = 0; i < islands.size(); ++i) - for (int f : islands[i]) islandOf[size_t(f)] = int(i); - auto screen = [&](int f, int c) { return toScreen(o.uvs[size_t(f)][size_t(c)] * size); }; - auto faceUnder = [&]() { - for (size_t f = 0; f < o.mesh.faces.size(); ++f) { - std::vector poly; - for (size_t c = 0; c < o.uvs[f].size(); ++c) poly.push_back(screen(int(f), int(c))); - if (insidePolygon(io.MousePos, poly)) return int(f); - } - return -1; - }; - auto cornerUnder = [&]() { - std::pair best{-1, -1}; - float d = 9.0f; - for (size_t f = 0; f < o.uvs.size(); ++f) - for (size_t c = 0; c < o.uvs[f].size(); ++c) { - const ImVec2 q = screen(int(f), int(c)); - if (const float e = std::hypot(q.x - io.MousePos.x, q.y - io.MousePos.y); e < d) d = e, best = {int(f), int(c)}; - } - return best; - }; - const auto picked = layoutPicked(o, islands); - std::set pickedFaces; - for (auto [f, c] : picked) pickedFaces.insert(f); - // Pressing: pick (what's under it, or a box), then move / turn / size what's picked. - if (hovered && ImGui::IsMouseClicked(ImGuiMouseButton_Left) && !lay_.drag) { - bool hit = false, wasPicked = false; - if (lay_.corners) { - const auto [f, c] = cornerUnder(); - if (f >= 0) { - const auto same = layoutCorner(o, f, c); - hit = true, wasPicked = lay_.points.count({f, c}) > 0; - if (io.KeyShift && wasPicked) - for (auto p : same) lay_.points.erase(p); - else { - if (!io.KeyShift && !wasPicked) lay_.points.clear(); - lay_.points.insert(same.begin(), same.end()); - } - } - } else if (const int f = faceUnder(); f >= 0) { - const int i = islandOf[size_t(f)]; - hit = true, wasPicked = lay_.frames.count(i) > 0; - if (io.KeyShift && wasPicked) lay_.frames.erase(i); - else { - if (!io.KeyShift && !wasPicked) lay_.frames.clear(); - lay_.frames.insert(i); - } - } - const bool anything = !lay_.frames.empty() || !lay_.points.empty(); - const bool transform = layTool_ != LayTool::Select && anything && !(io.KeyShift && hit) && (hit || layTool_ != LayTool::Move); - if (transform) { - pushUndo(); - lay_.drag = 1, lay_.grab = mouse, lay_.start = o.uvs, lay_.startPicture = o.picture; - glm::vec2 lo(1e30f), hi(-1e30f); - for (auto [f, c] : layoutPicked(o, islands)) 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); - lay_.centre = (lo + hi) * 0.5f; - } else if (!hit) { - if (!io.KeyShift) lay_.frames.clear(), lay_.points.clear(); - lay_.drag = 2, lay_.boxFrom = io.MousePos; - } - } - if (lay_.drag == 1) { - const glm::vec2 g = lay_.grab - lay_.centre, m = mouse - lay_.centre; - std::function to; - if (layTool_ == LayTool::Move || layTool_ == LayTool::Select) { - const glm::vec2 d = mouse - lay_.grab; - to = [d](glm::vec2 p) { return p + d; }; - message_ = std::format("Moved {:.0f}, {:.0f} px", d.x, d.y); - } else if (layTool_ == LayTool::Rotate) { - float a = std::atan2(m.y, m.x) - std::atan2(g.y, g.x); - if (io.KeyCtrl) a = glm::radians(snapTo(glm::degrees(a), 15.0f)); - const glm::vec2 c = lay_.centre; - to = [a, c](glm::vec2 p) { return c + glm::vec2(std::cos(a) * (p - c).x - std::sin(a) * (p - c).y, std::sin(a) * (p - c).x + std::cos(a) * (p - c).y); }; - message_ = std::format("Turned {:.0f} degrees", glm::degrees(a)); - } else { - glm::vec2 k(glm::length(m) / std::max(glm::length(g), 1.0f)); - if (io.KeyShift) // (only across or only up: whichever the drag goes along more) - k = std::abs(g.x) > std::abs(g.y) ? glm::vec2(m.x / (std::abs(g.x) > 1 ? g.x : 1.0f), 1.0f) : glm::vec2(1.0f, m.y / (std::abs(g.y) > 1 ? g.y : 1.0f)); - const glm::vec2 c = lay_.centre; - to = [k, c](glm::vec2 p) { return c + (p - c) * k; }; - message_ = std::format("Size {:.0f}% x {:.0f}%", k.x * 100, k.y * 100); - } - messageAt_ = now(); - const bool done = !ImGui::IsMouseDown(ImGuiMouseButton_Left); - layoutChange(o, lay_.start, lay_.startPicture, layoutPicked(o, islands), to, done); - if (done) lay_.drag = 0, lay_.startPicture.reset(); - } - // Drawing: every face's outline, what's picked filled; corners as dots. - const ImU32 fill = (kAccent & 0x00FFFFFF) | 0x38000000; - for (size_t f = 0; f < o.mesh.faces.size(); ++f) { - std::vector pts; - for (size_t c = 0; c < o.uvs[f].size(); ++c) pts.push_back(screen(int(f), int(c))); - const bool on = pickedFaces.count(int(f)) && !lay_.corners; - if (on) - for (const auto& t : triangulate(o.mesh, int(f))) { - ImVec2 q[3]; - for (int k = 0; k < 3; ++k) q[k] = pts[size_t(std::find(o.mesh.faces[f].begin(), o.mesh.faces[f].end(), t[size_t(k)]) - o.mesh.faces[f].begin())]; - dl->AddTriangleFilled(q[0], q[1], q[2], fill); - } - dl->AddPolyline(pts.data(), int(pts.size()), on ? kAccent : IM_COL32(255, 255, 255, 150), ImDrawFlags_Closed, on ? 2.0f : 1.2f); - } - if (lay_.corners) { - const auto hot = cornerUnder(); - for (size_t f = 0; f < o.uvs.size(); ++f) - for (size_t c = 0; c < o.uvs[f].size(); ++c) { - const ImVec2 q = screen(int(f), int(c)); - const bool on = lay_.points.count({int(f), int(c)}) > 0; - dl->AddCircleFilled(q, on ? 4.5f : 3.0f, on ? kAccent : hot == std::pair{int(f), int(c)} ? kHot : IM_COL32(255, 255, 255, 200)); - } - } else if (hovered && !lay_.drag) - if (const int f = faceUnder(); f >= 0 && !lay_.frames.count(islandOf[size_t(f)])) - for (int g : islands[size_t(islandOf[size_t(f)])]) { - std::vector pts; - for (size_t c = 0; c < o.uvs[size_t(g)].size(); ++c) pts.push_back(screen(g, int(c))); - dl->AddPolyline(pts.data(), int(pts.size()), kHot, ImDrawFlags_Closed, 1.5f); - } - if (lay_.drag == 1 && layTool_ != LayTool::Move && layTool_ != LayTool::Select) { // (the middle it turns or sizes about) - const ImVec2 c = toScreen(lay_.centre); - dl->AddCircle(c, 6, kAccent, 16, 2.0f); - dl->AddLine(c, io.MousePos, (kAccent & 0x00FFFFFF) | 0x90000000, 1.0f); - } - if (lay_.drag == 2) { // a box to pick with - const ImVec2 a = lay_.boxFrom, b = io.MousePos; - dl->AddRectFilled(ImVec2(std::min(a.x, b.x), std::min(a.y, b.y)), ImVec2(std::max(a.x, b.x), std::max(a.y, b.y)), IM_COL32(255, 120, 40, 30)); - dl->AddRect(ImVec2(std::min(a.x, b.x), std::min(a.y, b.y)), ImVec2(std::max(a.x, b.x), std::max(a.y, b.y)), kAccent); - if (!ImGui::IsMouseDown(ImGuiMouseButton_Left)) { - auto in = [&](ImVec2 q) { return q.x >= std::min(a.x, b.x) && q.x <= std::max(a.x, b.x) && q.y >= std::min(a.y, b.y) && q.y <= std::max(a.y, b.y); }; - for (size_t f = 0; f < o.uvs.size(); ++f) - for (size_t c = 0; c < o.uvs[f].size(); ++c) - if (in(screen(int(f), int(c)))) { - if (lay_.corners) lay_.points.insert({int(f), int(c)}); - else lay_.frames.insert(islandOf[f]); - } - lay_.drag = 0; - } - } - } - // In the 3D view: clicking the model picks the frame its face is in (Shift: add). - void layoutViewport(const ImGuiIO& io); - void layoutOverlay(ImDrawList* dl); - void layoutToolbox(float w); - void layoutOptions(float w); - void layoutProperties(float w); - - // ───────────────────────────── the Print tab ───────────────────────────── - - glm::vec3 bedSize() const; - const PrintInfo& printInfo(const Object& o); - std::vector printTargets(); - void drawPrintBed(keel::Renderer& r, const keel::ModelLighting& light); - void putOnBed(); - void middleOfBed(); - // Everything picked made `mm` big along `axis`, keeping its shape, standing where it stood. - void printResize(int axis, float mm); - // Everything picked made `k` times as big, standing where it stood. - void printScale(float k); - // The scaler: by a share, to fit the plate, or from the wrong units (a model made in inches, - // centimetres or metres comes in 25.4, 10 or 1000 times off). - float scalePercent_ = 100.0f; - void printScaler(float w); - void printSizeWindow(); - void printToolbox(float w); - void printOptions(float w); - void printViewport(const ImGuiIO& io); - // Where the mouse is on the model (a corner if near one), or on the bed. - std::optional measurePoint(ImVec2 mouse); - // The slice preview: where the printer goes round at the chosen layer, over the model. - void layerOverlay(ImDrawList* dl); - void printOverlay(ImDrawList* dl, const ImGuiIO& io); - void printProperties(float w); - - // ───────────────────────────── layers: folders and going to things ───────────────────────────── - - Folder* findFolder(uint32_t id); - bool inFolder(uint32_t folder, uint32_t within); - std::set objectsIn(uint32_t folder); - // The camera brought round to these (as framing them, but keeping the way it looks). - void goTo(const std::set& ids); - // The picked objects into a new folder (Ctrl+G): a group, picked and moved together. - void groupSelected(); - // Ungrouped: what's inside goes up a level, the folder goes. - void ungroup(uint32_t id); - void deleteFolder(uint32_t id); - // A folder's eye or lock: all in it the same. - void showFolder(Folder& f, bool on); - void lockFolder(Folder& f, bool on); - void moveToFolder(const std::set& ids, uint32_t folder); - // Only these shown (again: everything shown). - void showOnly(const std::set& ids); - void folderMenu(uint32_t into, const std::set& ids, int depth = 0); - // What a right-click on an object (or the picked ones) offers. - void objectMenu(Object& o); - // A row's show / hide or lock button: the picture of how it is now, dim when it's the usual way. - bool rowToggle(const char* id, Icon icon, bool usual, const char* tip); - // The ⌖ button at the end of a row: brings the camera to it. - bool goButton(const char* tip); - // Rows for what's in a folder (0: the top): its folders, then its objects. - void layerRows(uint32_t parent, int depth); - - // ───────────────────────────── combining, copies, thickness ───────────────────────────── - - // The last picked object with the others added, taken out of it, or only where they overlap - // kept; the others go. - void combineSelected(Combine op); - void makeCopies(); - void copiesWindow(); - void thickenActive(); - // A model made elsewhere: each of its objects, standing on the ground in the middle of the view. - void importFile(const std::string& file); - - // ── Spin: an outline turned round an axis (a vase, a wheel, a ring), or a screw ── - bool showSpin_ = false, spinWorld_ = false, spinKeep_ = false; - int spinAxis_ = 2, spinSteps_ = 48; - float spinDegrees_ = 360.0f, spinRise_ = 0.0f; - void spinWindow(); - void spinSelected(); - - // ── Text as a shape ── - bool showText_ = false; - char textBuf_[512] = "Fry3DX"; - int textFont_ = 2, textAlign_ = 0; - float textHeight_ = 0.05f, textDepth_ = 0.01f, textSpacing_ = 0.0f; - bool textStanding_ = false; - std::string textFontFile_; // (a font of your own, picked from a file) - static constexpr const char* kFonts[] = {"Instrument Sans", "Instrument Sans (semi-bold)", "Instrument Sans (bold)", "JetBrains Mono"}; - static constexpr const char* kFontFiles[] = {"fonts/InstrumentSans-Regular.ttf", "fonts/InstrumentSans-SemiBold.ttf", "fonts/InstrumentSans-Bold.ttf", "fonts/JetBrainsMono-Regular.ttf"}; - void textWindow(); - void addText(); - - void thickenWindow(); - void cleanFlatFaces(); - - // ───────────────────────────── touch: phones and tablets ───────────────────────────── - // A phone: everything drawn bigger, the side panels only when asked for, a bar of big buttons - // at the bottom (undo, Shift and Ctrl that stay down, editing the shape...), and fingers: - // one drags the view round (or a tool's arrows), two move it and pinch to zoom, holding one - // still is a right-click. - - bool touch() const { return touchMode_; } - // How much bigger than its pixels the UI is drawn. - float touchScale() const; - void touchFrame(ImGuiIO& io); - // The bar along the bottom of the view: big buttons, in rows if the view's narrow. - void touchBar(); - - // ───────────────────────────── the camera ───────────────────────────── - - glm::vec3 viewDir() const { return forwardOf(yaw_, pitch_); } - float viewHeight() const { return 2.0f * dist_ * std::tan(glm::radians(fov_) * 0.5f); } // metres across the view's height at the target - void setView(View v); - void leaveFlatView(); - void frameBox(glm::vec3 lo, glm::vec3 hi); - void frameAll(); - void frameSelected(); - - bool mouseInView(const ImGuiIO& io) const; - - void orbitBy(ImVec2 d); - void panBy(ImVec2 d); - - void updateCamera(ImGuiIO& io); - // Where the eye is and how the view maps to the screen, from the camera as it is now: again - // just before drawing, so the model and the lines over it agree (a touch can turn it between). - void settleView(); - // How near and far the view draws (units), going by how far away it looks: close up to a - // 5 mm part, or out to see a whole town, the same share of the depth's precision. - glm::vec2 nearFar(); - - // World point → screen (false behind the eye). - // The window and its view are up (commands given at the start run before the first frame). - bool viewReady() const { return ImGui::GetCurrentContext() && viewMax_.x - viewMin_.x > 1 && viewMax_.y - viewMin_.y > 1; } - bool project(const glm::vec3& p, ImVec2& out) const; - Ray mouseRay(ImVec2 m) const; - // How many metres a pixel is, at a point. - float pixelSize(const glm::vec3& at) const; - - // ───────────────────────────── what's shown ───────────────────────────── - - // The mesh as seen: with its rounding, smoothing and mirror — but while its shape is being - // edited, only the mirror (the rest would hide the points you're moving). - // Editable cuts show (but not while its own shape's being edited: the points you move). - // A big one (smoothed into many faces, cut) is made on another thread: what was shown stays - // till it's ready, so the window doesn't stop. - const Mesh& shownMesh(const Object& o); - // The key of the mesh actually shown (the one before, while a new one's being made). - uint64_t meshKey(const Object& o, const Mesh& mesh); - // Each object something's cut out of, and what its cutters are (their shapes, where they are): - // a change, and it's made again (the caches follow its version). - std::map cutKeys_; - std::map followAt_; // (where each cut object and cutter was last frame) - void followCuts(); - bool isCutter(const Object& o) const { return fry::cutTarget(scene_, o) != nullptr; } - // Editable cut: the last picked has the others cut out of it, and they stay objects to move. - void cutEditable(); - // The cut made for good: one cutter (or all of what's cut out of it) into the shape, gone. - void applyCuts(uint32_t target, uint32_t only = 0); - - // ───────────────────────────── picking ───────────────────────────── - - struct Hit { - uint32_t object = 0; - int face = -1; - float t = 1e30f; - }; - // base: against its own mesh (face numbers of that), not the rounded or smoothed one shown. - Hit pickObject(const Ray& ray, uint32_t only = 0, bool base = false); - // Each object's triangles in the world, in a tree of boxes: made again only when it changes - // (its shape, where it is), not each time something's picked. - struct Tree { // (in the object's own space: moving it doesn't make it again) - uint64_t version = 0; - size_t faces = 0; - std::shared_ptr tree; - }; - std::map, Tree> trees_; // (an object's own mesh, or the one shown) - const fry::TriangleTree& treeOf(const Object& o, const Mesh& mesh); - // Can the eye see this point of the object (nothing of it in the way)? - bool visible(const Object& o, const glm::vec3& world); - // Whether the eye can see an object's point (nothing of it in between): each tried when it's - // first asked about, kept till the object or the view changes. (Only what's drawn is asked: - // the outline's points, not every point of a big mesh, as the view turns.) - struct Visibility { - uint64_t version = 0; - glm::vec3 eye{1e30f}; - glm::mat4 vp{0.0f}, matrix{0.0f}; - glm::vec3 eyeIn{0.0f}; // (the eye in the object's own space) - std::vector point; // 0 not tried yet, 1 seen, 2 hidden - }; - std::map, Visibility> visibility_; // (an object's own mesh, or the one shown) - bool seen(const Object& o, const Mesh& mesh, int i); - // Each edge of a mesh and the (first two) faces it's on, with the faces' normals: worked out - // again only when the mesh changes. - struct Adjacency { - uint64_t version = 0; - size_t verts = 0, faces = 0; - std::vector> edges; // a, b, a face, the other (or -1) - std::vector plane; - // each face's normal, and its distance along it - }; - std::map, Adjacency> adjacency_; - const Adjacency& adjacencyOf(const Object& o, const Mesh& mesh); - bool pointSeen(const Object& o, int v) { return xray_ || seen(o, o.mesh, v); } - - // The edge of the edited object nearest the mouse on screen (that can be seen), or (-1, -1). - std::pair nearestEdge(Object& o, ImVec2 mouse, float within); - - void clickSelect(const ImGuiIO& io); - - // A whole loop: of edges (points), or a ring of faces, through the edge under the mouse. - void loopSelect(const ImGuiIO& io, bool ring = false); - - // Selects what's on screen inside a shape (a box, a lasso, the brush). - void regionSelect(const std::function& inside, bool add, bool remove, bool clearFirst); - - // ───────────────────────────── the gizmo ───────────────────────────── - - bool hasSelection(); - // Every point of the selection, in the world. - std::vector selectionPoints(); - glm::vec3 pivotOfSelection(); - // The selection's box, in a gizmo's axes. - std::pair selectionBox(const Axes& a); - glm::vec3 selectionMiddle(const Axes& a); - Orient& orient() { return mode_ == Mode::Edit ? orientEdit_ : orientObject_; } - // The gizmo's axes: the object's own (turning with it), the world's, or (shape editing) the - // way the selected faces face — so "up" on a side face is out of that face. - Axes gizmoAxes(); - // The extrude / inset handle: the selected faces' middle, their normal. - bool faceHandle(glm::vec3& at, glm::vec3& normal); - // The round-edges handle: off to the right of the selection's middle. - bool bevelHandle(ImVec2& centre, ImVec2& handle); - // The scale box's handles: 0-2 the far side along each axis, 4-6 the near side, 3 the middle. - // (Pushed out a little where the box is flat, so they don't sit on top of each other.) - glm::vec3 scaleHandle(const Axes& a, glm::vec3 lo, glm::vec3 hi, int h); - - // Which handle is under the mouse (-1 none). - int hoverHandle(ImVec2 mouse); - - glm::vec3 originPoint(); - glm::vec3 rotatePivot(const Axes& a) { return rotatePivot_ == Pivot::Origin ? originPoint() : selectionMiddle(a); } - - void startDrag(const ImGuiIO& io, int handle); - // The points within reach of the selection (in the world: the object's size counts), each with - // how much it follows: a grid of the selected points, so a big mesh stays quick. - void findSoft(); - void cancelDrag(); - - void updateDrag(const ImGuiIO& io); - // Mirror editing: each point that moved, its twin across the shape's own X (the point that was - // where it is, mirrored) moved the mirrored way. Points on the middle stay on it. - void mirrorEdits(Object& o, const Mesh& before); - // What moving snaps to near the mouse (points, edges' or faces' middles), in the world: not - // what's being moved. Marked with a ring. - std::optional snapTarget(ImVec2 mouse, float within = 22.0f); - // The number typed while dragging, if any (just "-" or "." is none yet). - std::optional typed() const; - void dragNow(const ImGuiIO& io); - - // ───────────────────────────── drawing a shape ───────────────────────────── - - // The plane shapes are drawn on: the one the view looks straight at, or the ground. - void workPlane(glm::vec3& point, glm::vec3& normal) const; - // The plane drawn on: the face under the first point if it was on an object, else the view's. - void drawPlane(glm::vec3& p, glm::vec3& n); - std::optional drawPoint(const ImGuiIO& io); - std::string sketchTyped_; // (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. - bool sketchKeys(const ImGuiIO& io); - void finishShape(); - - // ───────────────────────────── the viewport's mouse ───────────────────────────── - - void viewport(ImGuiIO& io); - void addDrawPoint(const ImGuiIO& io); - // Painting: on the model under the mouse. - void paintViewport(ImGuiIO& io); - - // The brush: whatever passes under it is selected (or, with Ctrl, let go). - void paint(const ImGuiIO& io); - - // ───────────────────────────── keys ───────────────────────────── - - void shortcuts(ImGuiIO& io); - - // ───────────────────────────── 3D ───────────────────────────── - -#if defined(__ANDROID__) - static constexpr int kRayLight = 0; // (light bounced by ray tracing: not on phones) -#else - static constexpr int kRayLight = 1; -#endif - // Solid: a plain grey room and soft studio light, no shadows or effects — for seeing shapes. - // Rendered: sky, sun, shadows and effects. - void applyShading(); - // Painted, and shown as painted (with rounding or smoothing added since, it isn't). - bool painted(const Object& o) const { return o.picture && o.uvsFit() && !o.hasFinish() && !cutKeys_.count(o.id); } - - keel::ModelId modelOf(const Object& o); - - const Picture& picture(const Reference& ref); - - void loadLogo(); - void logoImage(float size); - // A picture of ours in the UI, read the way ImGui's colours are (not turned linear: too dark). - void uiImage(ImDrawList* dl, keel::TextureId t, ImVec2 a, ImVec2 b); - - float gridStep() const { return std::pow(10.0f, std::floor(std::log10(std::max(viewHeight() / 4.0f, 1e-4f)))); } - - void draw3D(keel::Renderer& r); - - // (the Print tab's: the printer's plate and overhangs) - // On top of the scene: outlines and wires, points, the gizmo, the selection shapes. - // An empty scene: how to start, in the middle of the view (gone once there's anything). - void emptyHints(ImDrawList* dl); - void overlays(); - - void gizmo(ImDrawList* dl, const ImGuiIO& io); - - // ───────────────────────────── panels ───────────────────────────── - - void setupUi(); - - // The least a panel can be and still read right (its words, its rows), by the font's size. - static ImVec2 panelMin(std::string_view name); - // Docked panels kept at their least size: where a split leaves one side smaller, the split - // moves (when the other side has room to give). ImGui's own splitters know only one least - // size for everything. Gives what the node needs. - ImVec2 keepMinSizes(ImGuiDockNode* n); - - void defaultLayout(ImGuiID dock); - - // ───────────────────────────── commands ───────────────────────────── - - // Everything that can be done, by name: for the command search, keys of your own, the pies. - void buildCommands(); - void openSearch(); - void runCommand(const std::string& id); void commandItem(const char* id); - void commandSearch(); - // The pies: the tools (Space), or the views (Shift+Space), round the mouse while it's held. - void pieFrame(ImGuiIO& io); - // Keys of your own: kept in keys.txt (a line each: the command's id, a tab, the key). - void loadMyKeys(); - void saveMyKeys(); - - void menus(); - void menuBar(); - - void addMenuItems(); - - // How the last item's key looks: filled when it's the one in use, sunk while held. - static keel::ui::KeyState keyState(bool on); - // A square button with a picture on it (the accent when it's the one in use). - bool iconButton(const char* id, Icon icon, bool on, const char* tip, float size = 38.0f); - // A wide one, with words beside the picture. - bool wideButton(const char* id, Icon icon, const char* label, bool on, float width, const char* tip = nullptr, float h = 30.0f); - // Buttons in rows, as many as fit. - template - void grid(int count, float size, F each) { - const float gap = 5.0f; - const int perRow = std::max(1, int((ImGui::GetContentRegionAvail().x + gap) / (size + gap))); - for (int i = 0; i < count; ++i) { - if (i % perRow) ImGui::SameLine(0, gap); - each(i); - } - } - - void toolbox(); - // The plug-in tool that's on: which plug-in and which of its tools (none: -1). - std::pair pluginToolAt() const; - bool pluginToolFound() const { return pluginToolAt().first >= 0; } - // A plug-in tool's click: the spot on what's under the mouse, or on the ground. - void clickPluginTool(ImVec2 mouse); - - void paintToolbox(float w); - - void sculptOptions(float w); - - void paintOptions(float w); - - void softChoice(float w); - void toolOptions(); - - void layersPanel(); - - // A value that's undoable: the undo point is taken when you start dragging or typing. - template - bool undoable(F widget) { - const bool changed = widget(); - if (ImGui::IsItemActivated()) pushUndo(); - return changed; - } - - void paintProperties(float w); - - void renderProperties(float w); - - // The Paint tab's picture: the selected object's, flat, to paint on and place pictures on. - void picturePanel(); - - void propertiesPanel(); - - // A material to start from: sets colour, metal and rough in one go (each a little ball of it). - // Yours (saved here, or from plug-ins and scripts) first; "Save its look as a material" adds one. - void materialPicker(Object& o); - - void editProperties(Object& o, float w); - - void statusBar(); - - // The view's corner: which way you're looking, buttons to look straight at a side, and - // handles to drag to orbit, pan and zoom (for a touchpad or a pen). - void viewButtons(); - - // ───────────────────────────── scripts ───────────────────────────── - - void scriptLine(std::string text, bool error = false); - fry::SceneScript* currentScript() { return script_ < scene_.scripts.size() ? &scene_.scripts[script_] : nullptr; } - // "little_town.lua" -> "little town" (a script's name in the list). - static std::string scriptName(const std::string& path); - // Adds a script to the file's list and shows it (the same one again: just shows it). - void addScript(std::string name, std::string code); - // Runs the Script panel's script: one undo step; if it stops with an error, everything it did - // is taken back. - void runScriptNow(); - // After a script or plug-in command: kept as one undo step, or all taken back on an error. - void finishRun(const fry::ScriptResult& r, const std::string& name, bool frame); - void openScript(const std::string& path, const std::string& shownName); - void saveScript(std::string file); - // The scripts that come with it (content/Fry3DX/scripts), as menu items that add one. - void exampleScripts(); - void exampleItems(bool selectable); - - // ── fry_bench: frames timed while the camera turns or a setting's dragged (finding what's slow) ── - struct Bench { - std::string what; - int left = 0; - std::vector ms; - } bench_; - std::vector> holding_; // (fry_hold: keys held down for that many more frames) - float aoReach() const; - void lookBy(ImVec2 d); - void refocus(); - float nearestThing(); - auto reachOf(const Object& o) -> const Reach&; - float viewScale() const { return std::max(std::min(dist_, nearest_), 0.001f); } // (how big the view is where it matters: what's ahead, or nearer) - void benchStep(); - void benchEnd(std::chrono::steady_clock::time_point began); - - // ── Own materials (materials.txt in the settings folder) ── - static std::string ownMaterialsPath() { return keel::prefPath() + "materials.txt"; } - void loadOwnMaterials(); - void saveOwnMaterials(); - - // ── Plug-ins: content/Fry3DX/plugins (they come with it) and the settings folder's plugins ── - static std::string pluginsFolder() { return keel::prefPath() + "plugins/"; } - std::set pluginsOff() const; - void setPluginOn(const std::string& file, bool on); - void loadPlugins(); - void copyPlugin(const PluginEntry& e); - void openPluginsFolder(); - bool pluginUsable(size_t i) const { return i < plugins_.size() && plugins_[i].p && plugins_[i].error.empty(); } - void pluginMenuItems(const char* menu); - void pluginMenuItem(size_t i, size_t c); - void runPluginCommand(size_t i, size_t c, const std::vector& values); - // A plug-in command's settings, asked for before it runs. - void commandForm(); - void pluginsWindow(); - static int growString(ImGuiInputTextCallbackData* d); - // A one-line field for a std::string (it grows as it's typed into). - static bool inputString(const char* label, std::string& text, const char* hint = nullptr) { - return ImGui::InputTextWithHint(label, hint ? hint : "", text.data(), text.capacity() + 1, ImGuiInputTextFlags_CallbackResize, growString, &text); - } - void scriptPanel(); - - void helpWindow(); - - // Keys of your own: any command on any key (they come before the usual ones). - void myKeysTable(); - - // ── (being made) ── - bool showPrintSize_ = false, liveMirror_ = false, keepProportions_ = false; - // Each picked object (or all, in the Print tab with none picked) made ready to print. - void repairSelected(); - // Crease (Shift+E): the picked edges (or those between picked points / round picked faces) - // kept sharp when the shape's smoothed; again: not. - void creaseSelected(); - // Slide (Alt+G): the picked points glide along their edges, each along the edge that goes - // most the way the mouse goes, never past its other end. Click: done. Esc / right click: as it was. - struct Slide { - bool active = false; - uint32_t object = 0; - Mesh from; - std::vector pts; - std::vector> rails; // (each point's neighbours that aren't picked: where it can go) - ImVec2 start{0, 0}; - } slide_; - void startSlide(); - void cancelSlide(); - void slideFrame(const ImGuiIO& io); - - // Looking straight at a reference picture's plane (the front picture from the front or the back...). - bool lookingAt(Axis a) const; - std::string workspaceDir() const { return keel::prefPath() + "workspaces/"; } - void resetLayout(); - void workspacesWindow(); - static std::string fileSafe(std::string n); - - // The steps that can be undone, oldest first: a click goes back (or on) to just after it. - void historyWindow(); - - void aboutWindow(); -}; - -} // namespace fryapp diff --git a/src/app_model.cpp b/src/app_model.cpp deleted file mode 100644 index 733ab0e..0000000 --- a/src/app_model.cpp +++ /dev/null @@ -1,809 +0,0 @@ -// Fry3DX: modelling: the knife and holes, combining and copies, spin and text, drawing a -// shape, and the mouse in the view. -#include "app.h" - -using namespace fry; -namespace stdfs = std::filesystem; - -namespace fryapp { - -// ───────────────────────────── the knife and holes ───────────────────────────── - -// Snapped to steps of 45 degrees from the last point while Ctrl is held. -auto App::knifeEnd(const ImGuiIO& io) const -> ImVec2 { - ImVec2 m = io.MousePos; - const bool have = !knifePts_.empty() || knifeDown_; - if (!io.KeyCtrl || !have) return m; - const ImVec2 from = knifePts_.empty() ? knifePress_ : knifePts_.back(); - const float dx = m.x - from.x, dy = m.y - from.y, len = std::hypot(dx, dy); - const float a = std::round(std::atan2(dy, dx) / (kPi / 4)) * (kPi / 4); - return ImVec2(from.x + std::cos(a) * len, from.y + std::sin(a) * len); -} - -auto App::subMesh(const Mesh& m, const std::set& 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 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 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& 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& a, const std::set& 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 others; - for (size_t i = 1; i < n; ++i) others.insert(pieces[i].begin(), pieces[i].end()); - std::vector 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 { - 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 { - 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 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 { - 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 others = [&] { - std::set 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 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 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 made = selected_; - const std::vector originals = [&] { - std::vector 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 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 font; - if (!textFontFile_.empty()) { - std::ifstream in(textFontFile_, std::ios::binary); - font.assign(std::istreambuf_iterator(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 { - 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 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 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 diff --git a/src/app_paint.cpp b/src/app_paint.cpp deleted file mode 100644 index 36f6901..0000000 --- a/src/app_paint.cpp +++ /dev/null @@ -1,757 +0,0 @@ -// Fry3DX: painting and sculpting, picture layers, and the Layout tab. -#include "app.h" - -using namespace fry; -namespace stdfs = std::filesystem; - -namespace fryapp { - -// ───────────────────────────── painting ───────────────────────────── - -// The object ready to be painted: its rounding and smoothing made part of it, its surface laid -// out on a picture (made the plain colour of it at first). -auto App::readyToPaint(uint32_t id) -> Object* { - Object* o = find(id); - if (!o) return nullptr; - if (o->locked) return say("That object is locked"), nullptr; - if (o->picture && o->uvsFit() && !o->hasFinish()) return o; - pushUndo(); - if (o->hasFinish()) { - o->mesh = finishedMesh(*o); - o->mirror = 0, o->round = 0, o->smoothLevels = 0; - handMade(*o); - } - o->uvs = autoUnwrap(o->mesh); - if (!o->picture) o->picture = std::make_shared(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(*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 { - if (!o.uvsFit()) return std::nullopt; - const glm::mat4 m = objectMatrix(o); - float best = 1e30f; - std::optional 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(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(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(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(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& 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 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(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>& islands) const -> std::vector> { - std::vector> 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::set> 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>& from, const std::shared_ptr& picture, const std::vector>& picked, const std::function& to, bool final) -> void { - const glm::vec2 size(float(o.picture->w), float(o.picture->h)); - o.uvs = from; - std::set 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(std::move(img)); - lay_.remapAt = now(); - } -} - -// Buttons' changes (turn, flip, straighten): about the middle of what's picked. -auto App::layoutApply(const std::function& 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 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(), 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 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(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 faces; - for (auto [f, c] : picked) faces.insert(f); - const glm::mat4 m = objectMatrix(*o); - for (int f : faces) { - std::vector 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 diff --git a/src/app_panels.cpp b/src/app_panels.cpp deleted file mode 100644 index 374ca92..0000000 --- a/src/app_panels.cpp +++ /dev/null @@ -1,2060 +0,0 @@ -// Fry3DX: the panels: layers, properties, menus, the commands and their keys. -#include "app.h" -#include "fry_project.h" - -using namespace fry; -namespace stdfs = std::filesystem; - -namespace fryapp { - -// ───────────────────────────── layers: folders and going to things ───────────────────────────── - -auto App::findFolder(uint32_t id) -> Folder* { - for (Folder& f : scene_.folders) - if (f.id == id) return &f; - return nullptr; -} - -auto App::inFolder(uint32_t folder, uint32_t within) -> bool { // (folder is within, or inside it somewhere) - for (int n = 0; folder && n < 256; ++n) { - if (folder == within) return true; - const Folder* f = findFolder(folder); - folder = f ? f->parent : 0; - } - return false; -} - -auto App::objectsIn(uint32_t folder) -> std::set { - std::set out; - for (const Object& o : scene_.objects) - if (inFolder(o.folder, folder)) out.insert(o.id); - return out; -} - -// The camera brought round to these (as framing them, but keeping the way it looks). -auto App::goTo(const std::set& ids) -> void { - glm::vec3 lo(1e30f), hi(-1e30f); - for (const Object& o : scene_.objects) - if (ids.count(o.id)) { - const glm::mat4 m = objectMatrix(o); - for (const glm::vec3& v : o.mesh.verts) { - const glm::vec3 w(m * glm::vec4(v, 1.0f)); - lo = glm::min(lo, w), hi = glm::max(hi, w); - } - } - if (lo.x > hi.x) return; - frameBox(lo, hi); -} - -// The picked objects into a new folder (Ctrl+G): a group, picked and moved together. -auto App::groupSelected() -> void { - if (mode_ == Mode::Edit) leaveEdit(); - if (selected_.empty()) return say("Pick the objects to put in a folder first"); - pushUndo(); - Folder f; - f.id = scene_.nextId++; - int n = 1; - while (std::any_of(scene_.folders.begin(), scene_.folders.end(), [&](const Folder& g) { return g.name == std::format("Group {}", n); })) ++n; - f.name = std::format("Group {}", n); - if (const Object* a = find(active_)) f.parent = a->folder; // (where the last picked was) - for (Object& o : scene_.objects) - if (selected_.count(o.id)) o.folder = f.id; - scene_.folders.push_back(f); - renamingFolder_ = f.id, std::snprintf(renameBuf_, sizeof renameBuf_, "%s", f.name.c_str()); - say(f.name + ": click it to pick all of it; type a name"); -} - -// Ungrouped: what's inside goes up a level, the folder goes. -auto App::ungroup(uint32_t id) -> void { - const Folder* f = findFolder(id); - if (!f) return; - pushUndo(); - const uint32_t up = f->parent; - for (Object& o : scene_.objects) - if (o.folder == id) o.folder = up; - for (Folder& g : scene_.folders) - if (g.parent == id) g.parent = up; - std::erase_if(scene_.folders, [&](const Folder& g) { return g.id == id; }); -} - -auto App::deleteFolder(uint32_t id) -> void { - pushUndo(); - const auto ids = objectsIn(id); - std::erase_if(scene_.objects, [&](const Object& o) { return ids.count(o.id) > 0; }); - std::erase_if(scene_.folders, [&](const Folder& g) { return inFolder(g.id, id); }); - for (uint32_t i : ids) selected_.erase(i); -} - -// A folder's eye or lock: all in it the same. -auto App::showFolder(Folder& f, bool on) -> void { - pushUndo(); - for (Folder& g : scene_.folders) - if (inFolder(g.id, f.id)) g.visible = on; - for (Object& o : scene_.objects) - if (inFolder(o.folder, f.id)) o.visible = on; -} - -auto App::lockFolder(Folder& f, bool on) -> void { - pushUndo(); - for (Folder& g : scene_.folders) - if (inFolder(g.id, f.id)) g.locked = on; - for (Object& o : scene_.objects) - if (inFolder(o.folder, f.id)) o.locked = on; -} - -auto App::moveToFolder(const std::set& ids, uint32_t folder) -> void { - pushUndo(); - for (Object& o : scene_.objects) - if (ids.count(o.id)) o.folder = folder; -} - -// Only these shown (again: everything shown). -auto App::showOnly(const std::set& ids) -> void { - pushUndo(); - const bool already = std::all_of(scene_.objects.begin(), scene_.objects.end(), [&](const Object& o) { return o.visible == (ids.count(o.id) > 0); }); - for (Object& o : scene_.objects) o.visible = already || ids.count(o.id); - for (Folder& f : scene_.folders) f.visible = true; - say(already ? "Everything shown again" : "Only that shown (do it again to show everything)"); -} - -auto App::folderMenu(uint32_t into, const std::set& ids, int depth) -> void { // (Move to folder: the folders inside `into`) - for (const Folder& f : scene_.folders) - if (f.parent == into) { - const std::string label = std::string(size_t(depth) * 3, ' ') + f.name; - if (ImGui::MenuItem(label.c_str())) moveToFolder(ids, f.id); - folderMenu(f.id, ids, depth + 1); - } -} - -// What a right-click on an object (or the picked ones) offers. -auto App::objectMenu(Object& o) -> void { - const std::set ids = selected_.count(o.id) ? selected_ : std::set{o.id}; - if (ImGui::MenuItem("Go to it", "F")) goTo(ids); - if (ImGui::MenuItem("Rename")) renaming_ = o.id, std::snprintf(renameBuf_, sizeof renameBuf_, "%s", o.name.c_str()); - if (ImGui::MenuItem("Duplicate", "Ctrl+D")) selected_ = ids, active_ = o.id, duplicateSelected(); - if (ImGui::MenuItem("Show only this (again: all)")) showOnly(ids); - if (ImGui::BeginMenu("Move to folder")) { - if (ImGui::MenuItem("(the top, no folder)")) moveToFolder(ids, 0); - folderMenu(0, ids); - ImGui::Separator(); - if (ImGui::MenuItem("A new folder", "Ctrl+G")) selected_ = ids, active_ = o.id, groupSelected(); - ImGui::EndMenu(); - } - ImGui::Separator(); - if (ImGui::MenuItem("Delete", "Del")) selected_ = ids, deleteSelected(); -} - -// A row's show / hide or lock button: the picture of how it is now, dim when it's the usual way. -auto App::rowToggle(const char* id, Icon icon, bool usual, const char* tip) -> bool { - const ImVec2 at = ImGui::GetCursorScreenPos(); - const float h = ImGui::GetFrameHeight() - 4; - const bool pressed = ImGui::InvisibleButton(id, ImVec2(h, h)); - const ImU32 col = ImGui::IsItemHovered() ? kHot : usual ? IM_COL32(200, 200, 205, 150) : ImGui::GetColorU32(accentText()); - drawIcon(ImGui::GetWindowDrawList(), icon, ImVec2(at.x + h * 0.1f, at.y + h * 0.1f), h * 0.8f, col); - if (ImGui::IsItemHovered()) ImGui::SetTooltip("%s", tip); - return pressed; -} - -// The ⌖ button at the end of a row: brings the camera to it. -auto App::goButton(const char* tip) -> bool { - const ImVec2 at = ImGui::GetCursorScreenPos(); - const float h = ImGui::GetFrameHeight() - 4; - const bool pressed = ImGui::InvisibleButton("##go", ImVec2(h, h)); - const ImU32 col = ImGui::IsItemHovered() ? kHot : IM_COL32(200, 200, 205, 200); - const ImVec2 c(at.x + h * 0.5f, at.y + h * 0.5f); - ImDrawList* dl = ImGui::GetWindowDrawList(); - dl->AddCircle(c, h * 0.3f, col, 16, 1.5f); - dl->AddLine(ImVec2(c.x - h * 0.48f, c.y), ImVec2(c.x - h * 0.16f, c.y), col, 1.5f), dl->AddLine(ImVec2(c.x + h * 0.16f, c.y), ImVec2(c.x + h * 0.48f, c.y), col, 1.5f); - dl->AddLine(ImVec2(c.x, c.y - h * 0.48f), ImVec2(c.x, c.y - h * 0.16f), col, 1.5f), dl->AddLine(ImVec2(c.x, c.y + h * 0.16f), ImVec2(c.x, c.y + h * 0.48f), col, 1.5f); - if (ImGui::IsItemHovered()) ImGui::SetTooltip("%s", tip); - return pressed; -} - -// Rows for what's in a folder (0: the top): its folders, then its objects. -auto App::layerRows(uint32_t parent, int depth) -> void { - const float indent = float(depth) * 14.0f; - // (copies of the ids: rows may change the lists) - std::vector folders, objects; - for (const Folder& f : scene_.folders) - if (f.parent == parent) folders.push_back(f.id); - for (const Object& o : scene_.objects) - if (o.folder == parent) objects.push_back(o.id); - for (uint32_t id : folders) { - Folder* f = findFolder(id); - if (!f) continue; - const auto inside = objectsIn(id); - ImGui::PushID(int(id) + 1000000); - ImGui::TableNextRow(); - ImGui::TableNextColumn(); - if (rowToggle("##eye", f->visible ? Icon::Eye : Icon::EyeShut, f->visible, f->visible ? "Hide all in it" : "Show all in it")) showFolder(*f, !f->visible); - ImGui::TableNextColumn(); - if (rowToggle("##lock", f->locked ? Icon::Lock : Icon::Unlock, !f->locked, f->locked ? "Unlock all in it" : "Lock all in it")) lockFolder(*f, !f->locked); - ImGui::TableNextColumn(); - ImGui::Indent(indent + 1); - if (ImGui::ArrowButton("##open", f->open ? ImGuiDir_Down : ImGuiDir_Right)) f->open = !f->open; - ImGui::SameLine(0, 4); - const bool picked = !inside.empty() && std::all_of(inside.begin(), inside.end(), [&](uint32_t i) { return selected_.count(i) > 0; }); - if (renamingFolder_ == id) { - ImGui::SetNextItemWidth(-1); - if (!ImGui::IsAnyItemActive()) ImGui::SetKeyboardFocusHere(); - if (ImGui::InputText("##rename", renameBuf_, sizeof renameBuf_, ImGuiInputTextFlags_EnterReturnsTrue | ImGuiInputTextFlags_AutoSelectAll)) { - if (renameBuf_[0]) f->name = renameBuf_; - renamingFolder_ = 0, dirty_ = true; - } - if (ImGui::IsItemDeactivated()) renamingFolder_ = 0; - } else { - ImGui::PushStyleColor(ImGuiCol_Text, accentText()); - const std::string label = std::format("{} ({})", f->name, inside.size()); - if (ImGui::Selectable(label.c_str(), picked, ImGuiSelectableFlags_AllowDoubleClick)) { // a folder is a group: all of it picked - if (mode_ == Mode::Edit) leaveEdit(); - if (ImGui::GetIO().KeyCtrl || ImGui::GetIO().KeyShift) selected_.insert(inside.begin(), inside.end()); - else selected_ = inside; - if (!inside.empty()) active_ = *inside.rbegin(); - activeRef_ = 0; - if (ImGui::IsMouseDoubleClicked(ImGuiMouseButton_Left)) renamingFolder_ = id, std::snprintf(renameBuf_, sizeof renameBuf_, "%s", f->name.c_str()); - } - ImGui::PopStyleColor(); - if (ImGui::BeginDragDropSource()) { - ImGui::SetDragDropPayload("FRY_FOLDER", &id, sizeof id); - ImGui::Text("%s", f->name.c_str()); - ImGui::EndDragDropSource(); - } - if (ImGui::BeginDragDropTarget()) { - if (const ImGuiPayload* p = ImGui::AcceptDragDropPayload("FRY_OBJECT")) { - const uint32_t oid = *static_cast(p->Data); - moveToFolder(selected_.count(oid) ? selected_ : std::set{oid}, id); - } - if (const ImGuiPayload* p = ImGui::AcceptDragDropPayload("FRY_FOLDER")) { - const uint32_t fid = *static_cast(p->Data); - if (Folder* g = findFolder(fid); g && !inFolder(id, fid)) pushUndo(), g->parent = id; // (not into itself) - } - ImGui::EndDragDropTarget(); - } - if (ImGui::IsItemHovered() && !ImGui::IsMouseDragging(ImGuiMouseButton_Left)) ImGui::SetTooltip("Click: pick all of it (a group). Double-click: rename. Drag things onto it."); - if (ImGui::BeginPopupContextItem("folder")) { - if (ImGui::MenuItem("Go to it")) goTo(inside); - if (ImGui::MenuItem("Pick all of it")) selected_ = inside; - if (ImGui::MenuItem("Rename")) renamingFolder_ = id, std::snprintf(renameBuf_, sizeof renameBuf_, "%s", f->name.c_str()); - if (ImGui::MenuItem("Show only this (again: all)")) showOnly(inside); - if (ImGui::MenuItem("A folder inside it")) { - pushUndo(); - Folder g; - g.id = scene_.nextId++, g.name = "Folder", g.parent = id; - scene_.folders.push_back(g); - f = findFolder(id), f->open = true; - renamingFolder_ = g.id, std::snprintf(renameBuf_, sizeof renameBuf_, "%s", g.name.c_str()); - } - if (ImGui::MenuItem("Take it to the top") && f->parent) pushUndo(), f->parent = 0; - ImGui::Separator(); - if (ImGui::MenuItem("Ungroup (keep what's in it)")) ungroup(id); - if (ImGui::MenuItem("Delete it and all in it")) deleteFolder(id); - ImGui::EndPopup(); - } - } - ImGui::Unindent(indent + 1); - ImGui::TableNextColumn(); - if (goButton("Go to it: the camera comes round to all of it")) goTo(inside); - ImGui::PopID(); - if ((f = findFolder(id)) && f->open) layerRows(id, depth + 1); - } - for (uint32_t id : objects) { - Object* op = find(id); - if (!op) continue; - Object& o = *op; - ImGui::PushID(int(o.id)); - ImGui::TableNextRow(); - ImGui::TableNextColumn(); - if (rowToggle("##eye", o.visible ? Icon::Eye : Icon::EyeShut, o.visible, o.visible ? "Shown (click to hide)" : "Hidden (click to show)")) o.visible = !o.visible, dirty_ = true; - ImGui::TableNextColumn(); - if (rowToggle("##lock", o.locked ? Icon::Lock : Icon::Unlock, !o.locked, o.locked ? "Locked: can't be moved or changed (click to unlock)" : "Click to lock")) o.locked = !o.locked, dirty_ = true; - ImGui::TableNextColumn(); - ImGui::Indent(indent + 1); - if (renaming_ == o.id) { - ImGui::SetNextItemWidth(-1); - if (!ImGui::IsAnyItemActive()) ImGui::SetKeyboardFocusHere(); - if (ImGui::InputText("##rename", renameBuf_, sizeof renameBuf_, ImGuiInputTextFlags_EnterReturnsTrue | ImGuiInputTextFlags_AutoSelectAll)) { - pushUndo(); - if (renameBuf_[0]) o.name = renameBuf_; - renaming_ = 0; - } - if (ImGui::IsItemDeactivated()) renaming_ = 0; - } else { - ImGui::PushStyleColor(ImGuiCol_Text, o.visible ? ImGui::GetStyleColorVec4(ImGuiCol_Text) : ImGui::GetStyleColorVec4(ImGuiCol_TextDisabled)); - if (ImGui::Selectable(o.name.c_str(), selected_.count(o.id) > 0, ImGuiSelectableFlags_AllowDoubleClick)) { - if (mode_ == Mode::Edit && o.id != active_) leaveEdit(); - if (ImGui::GetIO().KeyCtrl || ImGui::GetIO().KeyShift) selected_.count(o.id) ? (void)selected_.erase(o.id) : (void)selected_.insert(o.id); - else selected_ = {o.id}; - active_ = o.id, activeRef_ = 0; - if (ImGui::IsMouseDoubleClicked(ImGuiMouseButton_Left)) renaming_ = o.id, std::snprintf(renameBuf_, sizeof renameBuf_, "%s", o.name.c_str()); - } - ImGui::PopStyleColor(); - if (ImGui::BeginDragDropSource()) { - ImGui::SetDragDropPayload("FRY_OBJECT", &id, sizeof id); - ImGui::Text("%s", selected_.count(id) && selected_.size() > 1 ? std::format("{} objects", selected_.size()).c_str() : o.name.c_str()); - ImGui::EndDragDropSource(); - } - if (ImGui::IsItemHovered() && !ImGui::IsMouseDragging(ImGuiMouseButton_Left)) ImGui::SetTooltip("Double-click to rename; drag onto a folder; right-click for more"); - if (ImGui::BeginPopupContextItem("object")) { - objectMenu(o); - ImGui::EndPopup(); - } - } - ImGui::Unindent(indent + 1); - ImGui::TableNextColumn(); - if (goButton("Go to it: the camera comes round to it")) goTo({id}); - ImGui::PopID(); - } -} - -// ───────────────────────────── panels ───────────────────────────── - -auto App::setupUi() -> void { - keel::ui::setPixelScale(touchScale()); - if (iniPath_.empty()) { - touchMode_ = fry_touch.b(); - iniPath_ = keel::prefPath() + (touchMode_ ? "layout_phone.ini" : "layout_v3.ini"); - ImGui::GetIO().IniFilename = iniPath_.c_str(); - if (stdfs::exists(iniPath_)) ImGui::LoadIniSettingsFromDisk(iniPath_.c_str()); - } - if (fry_theme.i() != appliedTheme_) appliedTheme_ = fry_theme.i(), keel::ui::setAppearance(keel::ui::Appearance(std::clamp(appliedTheme_, 0, 2))); - if (styled_) return; - styled_ = true; - ImGui::GetStyle().FontScaleDpi = 0.92f; // (the look itself: keel/ui, Niveus) -} - -// The least a panel can be and still read right (its words, its rows), by the font's size. -auto App::panelMin(std::string_view name) -> ImVec2 { - const float f = ImGui::GetFontSize(); - if (name == "Toolbox") return {15.5f * f, 9 * f}; - if (name == "Tool options") return {15.5f * f, 8 * f}; - if (name == "Properties") return {23 * f, 12 * f}; - if (name == "Layers") return {20 * f, 8 * f}; - if (name == "Picture") return {18 * f, 14 * f}; - if (name == "Script") return {30 * f, 16 * f}; - return {0, 0}; -} - -// Docked panels kept at their least size: where a split leaves one side smaller, the split -// moves (when the other side has room to give). ImGui's own splitters know only one least -// size for everything. Gives what the node needs. -auto App::keepMinSizes(ImGuiDockNode* n) -> ImVec2 { - if (!n) return {0, 0}; - if (n->IsLeafNode()) { - const float f = ImGui::GetFontSize(); - ImVec2 need = n->IsCentralNode() ? ImVec2(16 * f, 12 * f) : ImVec2(0, 0); - for (ImGuiWindow* w : n->Windows) need = ImMax(need, panelMin(w->Name)); - if (need.y > 0 && !n->IsCentralNode()) need.y += ImGui::GetFrameHeight(); // (its tab) - return need; - } - ImGuiDockNode *a = n->ChildNodes[0], *b = n->ChildNodes[1]; - const ImVec2 na = keepMinSizes(a), nb = keepMinSizes(b); - if (!a->IsVisible) return nb; - if (!b->IsVisible) return na; - const int axis = n->SplitAxis; - const float gap = ImGui::GetStyle().DockingSeparatorSize, room = n->Size[axis] - gap; - float sa = a->Size[axis]; - if (sa < na[axis] && room - na[axis] >= nb[axis]) sa = na[axis]; - else if (room - sa < nb[axis] && room - nb[axis] >= na[axis]) sa = room - nb[axis]; - if (sa != a->Size[axis]) a->SizeRef[axis] = sa, b->SizeRef[axis] = room - sa; - ImVec2 need = ImMax(na, nb); - need[axis] = na[axis] + nb[axis] + gap; - return need; -} - -auto App::defaultLayout(ImGuiID dock) -> void { - layoutDone_ = true; - ImGuiDockNode* node = ImGui::DockBuilderGetNode(dock); - if (!touch() && !std::exchange(layoutAgain_, false) && node && (node->IsSplitNode() || node->Windows.Size)) return; // (the user's own, from the .ini) - ImGui::DockBuilderRemoveNodeChildNodes(dock); - ImGui::DockBuilderSetNodeSize(dock, ImGui::GetMainViewport()->WorkSize); // (splits are shares of this) - ImGuiID center = dock; - if (touch() && portrait_) { // a phone held upright: the view on top, the panels as tabs under it - const ImGuiID bottom = ImGui::DockBuilderSplitNode(center, ImGuiDir_Down, 0.36f, nullptr, ¢er); - const ImVec2 size = ImGui::GetMainViewport()->WorkSize; - ImGui::DockBuilderSetNodeSize(bottom, ImVec2(size.x, size.y * 0.36f)); - for (const char* w : {"Toolbox", "Tool options", "Properties", "Layers", "Picture"}) ImGui::DockBuilderDockWindow(w, bottom); - ImGui::DockBuilderFinish(dock); - showToolbox_ = true; // (its tab first) - return; - } - if (touch()) { // a phone: tools down the left, the panels on the right (shown when asked for) - ImGuiID left = ImGui::DockBuilderSplitNode(center, ImGuiDir_Left, 0.2f, nullptr, ¢er); - const ImGuiID leftBottom = ImGui::DockBuilderSplitNode(left, ImGuiDir_Down, 0.45f, nullptr, &left); - const ImGuiID right = ImGui::DockBuilderSplitNode(center, ImGuiDir_Right, 0.36f, nullptr, ¢er); - ImGui::DockBuilderDockWindow("Toolbox", left); - ImGui::DockBuilderDockWindow("Tool options", leftBottom); - ImGui::DockBuilderDockWindow("Properties", right); - ImGui::DockBuilderDockWindow("Layers", right); - ImGui::DockBuilderDockWindow("Picture", right); - return ImGui::DockBuilderFinish(dock); - } - // (shares of the window, but never less than the panels' least sizes) - const float wide = ImGui::GetMainViewport()->WorkSize.x, leftShare = std::min(0.3f, std::max(0.14f, (panelMin("Toolbox").x + 8) / wide)); - const float rightShare = std::min(0.45f, std::max(0.22f, (panelMin("Properties").x + 8) / (wide * (1 - leftShare)))); - ImGuiID left = ImGui::DockBuilderSplitNode(center, ImGuiDir_Left, leftShare, nullptr, ¢er); - const ImGuiID leftBottom = ImGui::DockBuilderSplitNode(left, ImGuiDir_Down, 0.5f, nullptr, &left); - ImGuiID right = ImGui::DockBuilderSplitNode(center, ImGuiDir_Right, rightShare, nullptr, ¢er); - const ImGuiID rightBottom = ImGui::DockBuilderSplitNode(right, ImGuiDir_Down, 0.66f, nullptr, &right); - const ImGuiID bottom = ImGui::DockBuilderSplitNode(center, ImGuiDir_Down, 0.04f, nullptr, ¢er); - const ImGuiID picture = ImGui::DockBuilderSplitNode(center, ImGuiDir_Right, 0.45f, nullptr, ¢er); // (the Paint tab's) - ImGui::DockBuilderDockWindow("Picture", picture); - ImGui::DockBuilderDockWindow("Script", picture); - ImGui::DockBuilderDockWindow("Toolbox", left); - ImGui::DockBuilderDockWindow("Tool options", leftBottom); - ImGui::DockBuilderDockWindow("Layers", right); - ImGui::DockBuilderDockWindow("Properties", rightBottom); - ImGui::DockBuilderDockWindow("Status", bottom); - if (ImGuiDockNode* b = ImGui::DockBuilderGetNode(bottom)) b->SetLocalFlags(ImGuiDockNodeFlags_NoTabBar); - ImGui::DockBuilderFinish(dock); -} - -// ───────────────────────────── commands ───────────────────────────── - -// Everything that can be done, by name: for the command search, keys of your own, the pies. -auto App::buildCommands() -> void { - commands_.clear(); - auto add = [&](std::string id, std::string name, std::string group, std::string keys, std::function run, std::function ok = {}, std::string words = {}) { - commands_.push_back({std::move(id), std::move(name), std::move(group), std::move(keys), std::move(run), std::move(ok), std::move(words)}); - }; - auto editing = [this] { return mode_ == Mode::Edit; }; - auto picked = [this] { return !selected_.empty(); }; - auto many = [this] { return selected_.size() >= 2 && mode_ == Mode::Object; }; - auto model = [this] { return tab_ == Tab::Model; }; - add("file.new", "New", "File", "Ctrl+N", [this] { whenSaved(2); }, {}, "empty start"); - add("file.open", "Open...", "File", "Ctrl+O", [this] { whenSaved(3); }, {}, "load fry"); - add("file.import", "Import a model (STL, OBJ, 3MF, glTF)...", "File", "Ctrl+Shift+O", [this] { dialog(12); }, {}, "load mesh stl obj 3mf"); - add("file.save", "Save", "File", "Ctrl+S", [this] { path_.empty() ? dialog(2) : void(saveFile(path_)); }); - add("file.saveas", "Save as...", "File", "Ctrl+Shift+S", [this] { dialog(2); }); - add("file.export", "Export (GLB, glTF, OBJ, STL, PLY, 3MF, FBX)...", "File", "Ctrl+E", [this] { dialog(3); }, {}, "stl obj gltf glb ply fbx 3mf print"); - add("file.picture", "Save a picture of the view...", "File", "F12", [this] { dialog(5); }, {}, "screenshot render png"); - add("file.recover", "Recover autosaved work...", "File", "", [this] { findAutosaves(), showRecover_ = !recover_.empty(); }); - add("file.quit", "Quit", "File", "Ctrl+Q", [this] { whenSaved(1); }, {}, "exit close"); - add("edit.undo", "Undo", "Edit", "Ctrl+Z", [this] { undo(); }, [this] { return !undo_.empty(); }); - add("edit.redo", "Redo", "Edit", "Ctrl+Y", [this] { redo(); }, [this] { return !redo_.empty(); }); - add("edit.history", "Undo history", "Edit", "", [this] { showHistory_ = true; }, {}, "steps back"); - add("edit.duplicate", "Duplicate", "Edit", "Ctrl+D", [this] { duplicateSelected(); }, [=, this] { return picked() && mode_ == Mode::Object; }, "copy clone"); - add("edit.join", "Join into one", "Edit", "Ctrl+J", [this] { joinSelected(); }, many, "merge objects"); - add("edit.delete", "Delete", "Edit", "Del", [this] { deleteSelected(); }, {}, "remove erase"); - add("edit.shape", "Edit the shape / stop", "Edit", "Tab", [this] { mode_ == Mode::Edit ? leaveEdit() : enterEdit(); }, [=] { return editing() || picked(); }, "edit mode points faces"); - add("select.all", "Select all", "Select", "Ctrl+A", [this] { selectAll(); }); - add("select.none", "Select none", "Select", "Alt+A", [this] { selectNone(); }, {}, "deselect"); - add("select.invert", "Invert the selection", "Select", "Ctrl+I", [this] { invertSelection(); }); - add("select.grow", "Select more (grow)", "Select", "Ctrl +", [this] { growSelection(); }, editing); - add("select.shrink", "Select less (shrink)", "Select", "Ctrl -", [this] { shrinkSelection(); }, editing); - add("select.linked", "Select everything joined to it", "Select", "L", [this] { selectLinked(); }, editing, "linked connected"); - add("select.similar", "Select similar (faces the same way; objects of the same shape)", "Select", "Shift+G", [this] { selectSimilar(); }, [=] { return editing() || picked(); }, "same normal size copies"); - add("select.loop", "Select whole loops (of the picked edges)", "Select", "", [this] { loopsOfSelected(false); }, editing, "edge loop"); - add("select.ring", "Select rings (across the picked edges)", "Select", "", [this] { loopsOfSelected(true); }, editing, "edge ring"); - for (const auto& [t, n] : std::initializer_list>{{SelShape::Box, "box"}, {SelShape::Brush, "brush"}, {SelShape::Lasso, "lasso"}}) - add(std::string("select.with.") + n, std::string("Select with a ") + n, "Select", "", [this, t] { selShape_ = t; }); - for (const ShapeRecipe& r : shapeRecipes()) - add(std::string("add.") + r.id, std::string("Add ") + (r.starter ? "a starter " : "a ") + r.name, "Add", "", [this, id = std::string(r.id)] { setTab(Tab::Model), addShape(id); }, {}, - r.starter ? "starter model" : "shape primitive"); - add("add.text", "Add text (3D letters)...", "Add", "", [this] { setTab(Tab::Model), showText_ = true; }, {}, "font letters words label name"); - add("add.draw", "Draw a shape", "Add", "D", [this] { if (mode_ == Mode::Edit) leaveEdit(); setTool(Tool::Draw); }, {}, "sketch outline polygon"); - add("add.picture", "Add a reference picture...", "Add", "", [this] { dialog(4); }, {}, "image background blueprint"); - add("object.goto", "Go to it (the camera)", "Object", "F", [this] { goTo(selected_); }, picked, "frame focus"); - add("object.copies", "Make copies (in a row, round, along a path)...", "Object", "", [this] { showCopies_ = true; }, picked, "array repeat pattern circular curve"); - add("object.group", "Group into a folder", "Object", "Ctrl+G", [this] { groupSelected(); }, picked); - add("object.add", "Add them together", "Object", "Ctrl+Shift++", [this] { combineSelected(Combine::Add); }, many, "boolean union"); - add("object.subtract", "Take the others out of it", "Object", "Ctrl+Shift+-", [this] { combineSelected(Combine::Subtract); }, many, "boolean difference cut"); - add("object.overlap", "Keep only where they overlap", "Object", "", [this] { combineSelected(Combine::Overlap); }, many, "boolean intersect"); - add("object.thicken", "Make it thick (or hollow)...", "Object", "", [this] { showThicken_ = true; }, picked, "shell solidify wall"); - add("object.cleanflat", "Join flat faces side by side", "Object", "", [this] { cleanFlatFaces(); }, picked, "dissolve tidy"); - add("object.spin", "Spin (turn its outline round an axis)...", "Object", "", [this] { showSpin_ = true; }, [this] { return !selected_.empty(); }, "lathe screw revolve"); - add("tools.measure", "Measure between two points", "Tools", "", [this] { - if (tab_ != Tab::Model && tab_ != Tab::Print) setTab(Tab::Model); - if (mode_ == Mode::Edit) leaveEdit(); - printTool_ = PrintTool::Measure, measure_.clear(); - say("Measure: click two points (corners catch the mouse); Esc: done"); - }, {}, "ruler distance length dimension how far"); - add("object.repair", "Repair for printing (close holes, fix faces)", "Print", "", [this] { repairSelected(); }, picked, "fix manifold watertight holes"); - add("object.scale", "Print size: scale, fit the plate, fix units...", "Print", "", [this] { setTab(Tab::Print), showPrintSize_ = true; }, picked, "resize fit dimensions percent scaler"); - for (const ToolInfo& t : kTools) - add(std::string("tool.") + t.name, std::string("Tool: ") + t.name, "Tools", t.key, [this, tool = t.tool, obj = t.objectMode] { - if (tab_ != Tab::Model) setTab(Tab::Model); - if (!obj && mode_ != Mode::Edit) enterEdit(); - setTool(tool); - }); - add("shape.subdivide", "Cut every face in four", "Shape", "", [this] { subdivideNow(false); }, editing, "subdivide"); - add("shape.smoothall", "Smooth it all (cut and round)", "Shape", "", [this] { subdivideNow(true); }, editing, "subdivision surface"); - add("shape.merge", "Merge points", "Shape", "M", [this] { mergeNow(); }, editing, "weld collapse"); - add("shape.face", "Make a face", "Shape", "", [this] { makeFaceNow(); }, editing, "fill"); - add("shape.flip", "Flip faces", "Shape", "", [this] { flipNow(); }, editing, "normals reverse"); - add("shape.smooth", "Smooth points", "Shape", "", [this] { smoothPointsNow(); }, editing, "relax"); - add("shape.push", "Push out / pull in...", "Shape", "Alt+S", [this] { startPush(); }, editing, "shrink fatten inflate"); - add("shape.bridge", "Bridge two faces", "Shape", "", [this] { bridgeNow(); }, editing, "tunnel connect"); - add("shape.soft", "Soft selection on / off", "Shape", "Shift+O", [this] { softSel_ = !softSel_; }, {}, "proportional falloff"); - add("shape.separate", "Make into its own object", "Shape", "P", [this] { separateSelected(); }, editing, "separate split"); - add("paint.mirror", "Paint both sides (mirrored)", "Paint", "", [this] { paintMirror_ = !paintMirror_, say(paintMirror_ ? "Painting both sides" : "Painting one side"); }, {}, "symmetry symmetric"); - add("paint.stencil", "Draw a paint stencil (paint only inside it)", "Paint", "", [this] { setTab(Tab::Paint), drawingStencil_ = true, stencil_.clear(); }, {}, "mask masking tape area"); - add("object.cutEditable", "Cut out, editable (the cutters stay, the hole follows them)", "Object", "", [this] { cutEditable(); }, many, "boolean subtract difference hole non-destructive live"); - add("shape.crease", "Crease the picked edges (stay sharp when smoothed)", "Shape", "Shift+E", [this] { creaseSelected(); }, editing, "sharp hard edge subdivision smooth"); - add("shape.slide", "Slide the points along their edges...", "Shape", "Alt+G", [this] { startSlide(); }, editing, "edge slide vertex slide"); - add("shape.mirror", "Mirror editing (both sides change together)", "Shape", "", [this] { liveMirror_ = !liveMirror_, say(liveMirror_ ? "Mirror editing on: both sides change together" : "Mirror editing off"); }, {}, "symmetry symmetric x"); - add("view.solid", "Show: solid", "View", "Z", [this] { shading_ = Shading::Solid; }); - add("view.rendered", "Show: rendered (sky, sun, shadows)", "View", "Z", [this] { shading_ = Shading::Rendered; }); - add("view.wire", "Show: wireframe", "View", "Shift+Z", [this] { shading_ = Shading::Wire; }); - add("view.xray", "X-ray on / off", "View", "Alt+Z", [this] { xray_ = !xray_; }, {}, "see through"); - add("view.grid", "Grid on / off", "View", "", [this] { showGrid_ = !showGrid_; }); - add("view.persp", "View: perspective", "View", "Numpad 5", [this] { leaveFlatView(); }); - for (const auto& [v, n, k] : std::initializer_list>{ - {View::Front, "front", "Numpad 1"}, {View::Back, "back", "Shift+Numpad 1"}, {View::Left, "left side", "Numpad 3"}, {View::Right, "right side", "Shift+Numpad 3"}, - {View::Top, "top", "Numpad 7"}, {View::Bottom, "bottom", "Shift+Numpad 7"}}) - add(std::string("view.") + n, std::string("View: ") + n, "View", k, [this, v] { setView(v); }, {}, "orthographic camera"); - add("view.frame", "Frame the selected", "View", "F", [this] { frameSelected(); }, {}, "zoom focus"); - add("view.all", "Frame everything", "View", "Home", [this] { frameAll(); }, {}, "zoom extents"); - add("view.only", "Show only the selected (again: all)", "View", "/", [this] { showOnly(selected_); }, picked, "isolate local view hide"); - add("view.unhide", "Show everything", "View", "Alt+H", [this] { for (Object& o : scene_.objects) o.visible = true; }, {}, "unhide"); - add("view.layout", "Put the panels back as they were", "View", "", [this] { resetLayout(); }, {}, "reset layout workspace default panels"); - add("view.camera", "Camera: fly or orbit (switch)", "View", "Shift+C", [this] { - keel::execute(std::format("fry_camera {}", fry_camera.i() == 1 ? 0 : 1)); - say(fry_camera.i() == 1 ? "Orbit camera: right drag turns round what you look at, the wheel zooms (Shift+C: back to flying)" - : "Fly camera: right drag turns it where it stands, WASD moves, the wheel goes forward and back (Shift+C: orbit)"); - }, {}, "orbit fly navigation blender game turntable"); - add("view.workspace", "Workspaces (save the panels as they are)...", "View", "", [this] { showWorkspaces_ = true; }, {}, "layout save panels"); - for (int i = 0; i < 3; ++i) - add(std::string("view.look") + char('0' + i), std::string("Appearance: ") + (i == 0 ? "like the desktop" : i == 1 ? "dark" : "light"), "View", "", - [i] { keel::execute(std::format("fry_theme {}", i)); }, {}, "theme colours"); - const char* tabs[] = {"Model", "Paint", "Layout", "Render", "Print"}; - for (int i = 0; i < 5; ++i) - add(std::string("tab.") + tabs[i], std::string("Go to ") + tabs[i], "Tabs", std::format("Ctrl+{}", i + 1), [this, i] { setTab(Tab(i)); }, {}, i == 1 ? "sculpt texture" : ""); - add("script.panel", "The Script panel", "Script", "", [this] { showScript_ = !showScript_; }, {}, "lua code"); - add("script.run", "Run the script", "Script", "F5", [this] { runScriptNow(); }, [this] { return currentScript() != nullptr; }); - add("script.new", "New script", "Script", "", [this] { addScript("Script", kFirstScript); }); - add("script.plugins", "Plug-ins...", "Script", "", [this] { showPlugins_ = true; }, {}, "addons extensions"); - add("help.keys", "Keys and mouse (and your own keys)", "Help", "F1", [this] { showHelp_ = true; }, {}, "shortcuts hotkeys keymap bind"); - add("help.about", "About Fry3DX", "Help", "", [this] { showAbout_ = true; }, {}, "version"); - add("help.search", "Search for a command", "Help", "F3", [this] { openSearch(); }, {}, "find palette"); - for (size_t i = 0; i < plugins_.size(); ++i) - if (pluginUsable(i)) - for (size_t c = 0; c < plugins_[i].p->commands.size(); ++c) { - const fry::PluginCommand& cmd = plugins_[i].p->commands[c]; - add("plugin." + plugins_[i].file + "." + cmd.name, cmd.name + (cmd.settings.empty() ? "" : "..."), "Plug-in: " + plugins_[i].p->name, "", [this, i, c] { - if (i >= plugins_.size() || !pluginUsable(i) || c >= plugins_[i].p->commands.size()) return; - const fry::PluginCommand& cm = plugins_[i].p->commands[c]; - if (cm.settings.empty()) return runPluginCommand(i, c, {}); - pendingCommand_ = PendingCommand{i, c, {}}; - for (const auto& [n, v] : cm.settings) pendingCommand_->values.push_back(v); - }, {}, cmd.help); - } - (void)model; -} - -auto App::openSearch() -> void { - buildCommands(); - search_.show(); -} - -auto App::runCommand(const std::string& id) -> void { - for (const fry::Command& c : commands_) - if (c.id == id) { - if (!c.usable()) return say(c.name + ": not now (" + (c.group == "Shape" ? "edit a shape first, Tab" : "pick something first") + ")"); - std::erase(recentCommands_, id); - recentCommands_.insert(recentCommands_.begin(), id); - if (recentCommands_.size() > 8) recentCommands_.pop_back(); - return c.run(); - } -} - -// A menu item for a command: its name, its key (your own if you gave it one), greyed when it can't be done now. -auto App::commandItem(const char* id) -> void { - for (const fry::Command& c : commands_) - if (c.id == id) { - const auto mine = myKeys_.find(c.id); - const std::string keys = mine != myKeys_.end() ? fry::chordName(mine->second) : c.keys; - if (ImGui::MenuItem(c.name.c_str(), keys.empty() ? nullptr : keys.c_str(), false, c.usable())) runCommand(c.id); - return; - } -} - -auto App::commandSearch() -> void { - if (!search_.open) return; - if (const int i = search_.draw(commands_, recentCommands_); i >= 0) runCommand(commands_[size_t(i)].id); -} - -// The pies: the tools (Space), or the views (Shift+Space), round the mouse while it's held. -auto App::pieFrame(ImGuiIO& io) -> void { - const bool released = !pieStays_ && !ImGui::IsKeyDown(ImGuiKey_Space); - std::vector items; - std::vector> runs; - if (pie_ == 1) { - for (const ToolInfo& t : kTools) - if (mode_ == Mode::Edit ? t.editMode : t.objectMode) - items.push_back({t.name, t.icon, true, tool_ == t.tool}), runs.push_back([this, tool = t.tool] { setTool(tool); }); - items.push_back({mode_ == Mode::Edit ? "Done (objects)" : "Edit the shape", mode_ == Mode::Edit ? Icon::Objects : Icon::Shape, true, false}); - runs.push_back([this] { mode_ == Mode::Edit ? leaveEdit() : enterEdit(); }); - } else { - const std::pair views[] = {{View::Top, "Top"}, {View::Right, "Right"}, {View::Front, "Front"}, {View::Perspective, "3D"}, - {View::Bottom, "Bottom"}, {View::Back, "Back"}, {View::Left, "Left"}}; - for (const auto& [v, n] : views) { - items.push_back({n, Icon::Select, false, view_ == v}); - runs.push_back([this, v] { v == View::Perspective ? leaveFlatView() : setView(v); }); - } - items.push_back({"Frame", Icon::Select, false, false}), runs.push_back([this] { frameSelected(); }); - } - const int got = fry::pieMenu(pie_ == 1 ? "Tools" : "Views", pieAt_, items, released); - if (got == -1) return; - if (got >= 0) runs[size_t(got)](); - pie_ = 0, pieStays_ = false; - (void)io; -} - -// Keys of your own: kept in keys.txt (a line each: the command's id, a tab, the key). -auto App::loadMyKeys() -> void { - myKeys_.clear(); - std::ifstream in(keel::prefPath() + "keys.txt"); - for (std::string line; std::getline(in, line);) - if (const size_t tab = line.find('\t'); tab != std::string::npos) - if (const auto c = fry::parseChord(line.substr(tab + 1))) myKeys_[line.substr(0, tab)] = *c; -} - -auto App::saveMyKeys() -> void { - std::ofstream out(keel::prefPath() + "keys.txt"); - for (const auto& [id, c] : myKeys_) out << id << '\t' << fry::chordName(c) << '\n'; -} - -auto App::menus() -> void { - if (ImGui::BeginMenu("File")) { - if (ImGui::MenuItem("New", "Ctrl+N")) whenSaved(2); - if (ImGui::MenuItem("Open...", "Ctrl+O")) whenSaved(3); - if (ImGui::MenuItem("Save", "Ctrl+S")) path_.empty() ? dialog(2) : void(saveFile(path_)); - if (ImGui::MenuItem("Save as...", "Ctrl+Shift+S")) dialog(2); - ImGui::Separator(); - if (ImGui::MenuItem("Import a model (STL, OBJ, 3MF, glTF)...", "Ctrl+Shift+O")) dialog(12); - if (ImGui::MenuItem("Export (GLB, glTF, OBJ, STL, PLY)...", "Ctrl+E")) dialog(3); - if (ImGui::BeginMenu("Autosave")) { - static const int every[] = {1, 2, 5, 10, 0}; - for (int m : every) - if (ImGui::MenuItem(m ? std::format("Every {} minute{}", m, m > 1 ? "s" : "").c_str() : "Off", nullptr, fry_autosave.i() == m)) - keel::execute(std::format("fry_autosave {}", m)); - ImGui::Separator(); - if (ImGui::MenuItem("Recover autosaved work...")) findAutosaves(), showRecover_ = !recover_.empty(); - ImGui::EndMenu(); - } - if (ImGui::MenuItem("Save a picture of the view...", "F12")) dialog(5); - ImGui::Separator(); - if (ImGui::MenuItem("Quit", "Ctrl+Q")) whenSaved(1); - ImGui::EndMenu(); - } - if (ImGui::BeginMenu("Edit")) { - if (ImGui::MenuItem("Undo", "Ctrl+Z", false, !undo_.empty())) undo(); - if (ImGui::MenuItem("Redo", "Ctrl+Y", false, !redo_.empty())) redo(); - commandItem("edit.history"); - ImGui::Separator(); - if (ImGui::MenuItem("Duplicate", "Ctrl+D", false, mode_ == Mode::Object)) duplicateSelected(); - if (ImGui::MenuItem("Join into one", "Ctrl+J", false, mode_ == Mode::Object && selected_.size() > 1)) joinSelected(); - if (ImGui::MenuItem("Delete", "Del")) deleteSelected(); - ImGui::Separator(); - if (ImGui::MenuItem(mode_ == Mode::Edit ? "Stop editing the shape" : "Edit the shape", "Tab")) mode_ == Mode::Edit ? leaveEdit() : enterEdit(); - ImGui::EndMenu(); - } - if (ImGui::BeginMenu("Select")) { - if (ImGui::MenuItem("All", "Ctrl+A")) selectAll(); - if (ImGui::MenuItem("None", "Alt+A")) selectNone(); - if (ImGui::MenuItem("Invert", "Ctrl+I")) invertSelection(); - ImGui::Separator(); - ImGui::BeginDisabled(mode_ != Mode::Edit); - if (ImGui::MenuItem("More (grow)", "Ctrl +")) growSelection(); - if (ImGui::MenuItem("Less (shrink)", "Ctrl -")) shrinkSelection(); - if (ImGui::MenuItem("Everything joined to it", "L")) selectLinked(); - commandItem("select.similar"); - commandItem("select.loop"); - commandItem("select.ring"); - ImGui::MenuItem("A loop: Alt+click or double-click", nullptr, false, false); - ImGui::EndDisabled(); - ImGui::Separator(); - if (ImGui::MenuItem("Select with a box", nullptr, selShape_ == SelShape::Box)) selShape_ = SelShape::Box; - if (ImGui::MenuItem("Select with a brush", nullptr, selShape_ == SelShape::Brush)) selShape_ = SelShape::Brush; - if (ImGui::MenuItem("Select with a lasso", nullptr, selShape_ == SelShape::Lasso)) selShape_ = SelShape::Lasso; - ImGui::EndMenu(); - } - if (ImGui::BeginMenu("Add")) { - addMenuItems(); - pluginMenuItems("add"); - ImGui::EndMenu(); - } - if (ImGui::BeginMenu("Object")) { - const bool many = selected_.size() >= 2, any = !selected_.empty(); - if (ImGui::MenuItem("Go to it", "F", false, any)) goTo(selected_); - if (ImGui::MenuItem("Duplicate", "Ctrl+D", false, any)) duplicateSelected(); - if (ImGui::MenuItem("Make copies (in a row, round)...", nullptr, false, any)) showCopies_ = true; - if (ImGui::MenuItem("Group into a folder", "Ctrl+G", false, any)) groupSelected(); - ImGui::Separator(); - ImGui::TextDisabled("Two or more: the last picked is kept"); - if (ImGui::MenuItem("Add them together", "Ctrl+Shift++", false, many)) combineSelected(Combine::Add); - if (ImGui::MenuItem("Take the others out of it", "Ctrl+Shift+-", false, many)) combineSelected(Combine::Subtract); - if (ImGui::MenuItem("Cut out, editable", nullptr, false, many)) cutEditable(); - if (ImGui::IsItemHovered()) ImGui::SetTooltip("The others cut into the last picked but stay objects: move or change them and the hole follows (Apply makes it for good)"); - if (ImGui::MenuItem("Keep only where they overlap", nullptr, false, many)) combineSelected(Combine::Overlap); - if (ImGui::MenuItem("Join (just as they are)", "Ctrl+J", false, many)) joinSelected(); - ImGui::Separator(); - if (ImGui::MenuItem("Make it thick (or hollow)...", nullptr, false, any)) showThicken_ = true; - commandItem("object.spin"); - if (ImGui::MenuItem("Join flat faces side by side", nullptr, false, any)) cleanFlatFaces(); - pluginMenuItems("object"); - ImGui::EndMenu(); - } - if (ImGui::BeginMenu("Shape")) { - const bool ed = mode_ == Mode::Edit; - if (!ed) ImGui::TextDisabled("Edit an object's shape first (Tab)"); - ImGui::BeginDisabled(!ed); - if (ImGui::MenuItem("Extrude", "X")) setTool(Tool::Extrude); - if (ImGui::MenuItem("Inset", "I")) setTool(Tool::Inset); - if (ImGui::MenuItem("Round edges", "B")) setTool(Tool::Bevel); - if (ImGui::MenuItem("Loop cut", "C")) setTool(Tool::LoopCut); - ImGui::Separator(); - if (ImGui::MenuItem("Cut every face in four")) subdivideNow(false); - if (ImGui::MenuItem("Smooth it all (cut and round)")) subdivideNow(true); - if (ImGui::MenuItem("Merge points", "M")) mergeNow(); - if (ImGui::MenuItem("Make a face")) makeFaceNow(); - if (ImGui::MenuItem("Flip faces")) flipNow(); - ImGui::Separator(); - if (ImGui::MenuItem("Smooth points")) smoothPointsNow(); - if (ImGui::MenuItem("Push out / pull in...", "Alt+S")) startPush(); - if (ImGui::MenuItem("Bridge two faces")) bridgeNow(); - ImGui::MenuItem("Soft selection", "Shift+O", &softSel_); - ImGui::EndDisabled(); - commandItem("shape.crease"); - commandItem("shape.slide"); - commandItem("shape.mirror"); - ImGui::BeginDisabled(!ed); - if (ImGui::MenuItem("Make into its own object", "P")) separateSelected(); - if (ImGui::MenuItem("Delete", "Del")) deleteElements(); - ImGui::EndDisabled(); - ImGui::EndMenu(); - } - if (ImGui::BeginMenu("View")) { - if (ImGui::MenuItem("Solid (for modelling)", "Z", shading_ == Shading::Solid)) shading_ = Shading::Solid; - if (ImGui::MenuItem("Rendered (sky, sun, shadows)", "Z", shading_ == Shading::Rendered)) shading_ = Shading::Rendered; - if (ImGui::MenuItem("Wireframe", "Shift+Z", shading_ == Shading::Wire)) shading_ = Shading::Wire; - ImGui::MenuItem("X-ray (see and pick through)", "Alt+Z", &xray_); - ImGui::MenuItem("Grid", nullptr, &showGrid_); - ImGui::MenuItem("Ground (in Rendered)", nullptr, &scene_.look.ground); - if (ImGui::BeginMenu("Appearance")) { - const char* looks[] = {"Like the desktop", "Dark", "Light"}; - for (int i = 0; i < 3; ++i) - if (ImGui::MenuItem(looks[i], nullptr, fry_theme.i() == i)) keel::execute(std::format("fry_theme {}", i)); - ImGui::EndMenu(); - } - ImGui::Separator(); - if (ImGui::MenuItem("Perspective", "Numpad 5", view_ == View::Perspective)) leaveFlatView(); - if (ImGui::MenuItem("Front", "Numpad 1", view_ == View::Front)) setView(View::Front); - if (ImGui::MenuItem("Back", "Shift+Numpad 1", view_ == View::Back)) setView(View::Back); - if (ImGui::MenuItem("Left side", "Numpad 3", view_ == View::Left)) setView(View::Left); - if (ImGui::MenuItem("Right side", "Shift+Numpad 3", view_ == View::Right)) setView(View::Right); - if (ImGui::MenuItem("Top", "Numpad 7", view_ == View::Top)) setView(View::Top); - if (ImGui::MenuItem("Bottom", "Shift+Numpad 7", view_ == View::Bottom)) setView(View::Bottom); - ImGui::Separator(); - if (ImGui::MenuItem("Frame selected", "F")) frameSelected(); - if (ImGui::MenuItem("Frame everything", "Home")) frameAll(); - if (ImGui::MenuItem("Show only the selected (again: all)", "/", false, !selected_.empty())) showOnly(selected_); - ImGui::Separator(); - if (ImGui::MenuItem("Camera: fly (as in games)", nullptr, fry_camera.i() == 0)) keel::execute("fry_camera 0"); - if (ImGui::MenuItem("Camera: orbit (as in Blender)", "Shift+C", fry_camera.i() == 1)) keel::execute("fry_camera 1"); - ImGui::Separator(); - commandItem("tools.measure"); - commandItem("view.workspace"); - ImGui::EndMenu(); - } - if (ImGui::BeginMenu("Script")) { - ImGui::MenuItem("The Script panel", nullptr, &showScript_); - if (ImGui::MenuItem("Run it", "F5", false, currentScript())) runScriptNow(); - if (!scene_.scripts.empty() && ImGui::BeginMenu("This file's scripts")) { - for (size_t i = 0; i < scene_.scripts.size(); ++i) - if (ImGui::MenuItem(scene_.scripts[i].name.c_str(), nullptr, i == script_)) script_ = i, showScript_ = true; - ImGui::EndMenu(); - } - ImGui::Separator(); - if (ImGui::MenuItem("New script")) addScript("Script", kFirstScript); - if (ImGui::MenuItem("Open a script...")) showScript_ = true, dialog(10); - if (ImGui::MenuItem("Save the script as...", nullptr, false, currentScript())) dialog(11); - exampleScripts(); - ImGui::Separator(); - if (ImGui::BeginMenu("Plug-ins")) { - bool any = false; - for (size_t i = 0; i < plugins_.size(); ++i) - if (plugins_[i].p) - for (size_t c = 0; c < plugins_[i].p->commands.size(); ++c) - if (plugins_[i].p->commands[c].menu == "script") pluginMenuItem(i, c), any = true; - if (any) ImGui::Separator(); - if (ImGui::MenuItem("Plug-ins...")) showPlugins_ = true; - if (ImGui::MenuItem("Open the plug-ins folder")) openPluginsFolder(); - if (ImGui::MenuItem("Load them again")) loadPlugins(); - ImGui::EndMenu(); - } - if (ImGui::MenuItem("What scripts can do")) showScript_ = showScriptHelp_ = true; - ImGui::EndMenu(); - } - if (ImGui::BeginMenu("Help")) { - if (ImGui::MenuItem("Keys and mouse", "F1")) showHelp_ = true; - commandItem("help.search"); - ImGui::Separator(); - if (ImGui::MenuItem("Website")) SDL_OpenURL(project::website); - if (ImGui::MenuItem("Report a problem")) SDL_OpenURL(project::issues); - if (ImGui::MenuItem("Newest version")) SDL_OpenURL(project::releases); - ImGui::Separator(); - if (ImGui::MenuItem(std::format("About {}", project::name).c_str())) showAbout_ = true; - ImGui::EndMenu(); - } -} - -auto App::menuBar() -> void { - if (!ImGui::BeginMainMenuBar()) return; - if (haveLogo_) { - const float h = ImGui::GetFrameHeight() - 4; - ImGui::SetCursorPosY(ImGui::GetCursorPosY() + 1); - logoImage(h); - } - ImGui::PushFont(keel::ui::font(keel::ui::Font::Bold), 0.0f), ImGui::TextColored(accentText(), "Fry3DX"), ImGui::PopFont(); - if (!touch()) menus(); - else if (ImGui::BeginMenu("Menu")) { // (a phone: one menu, the others inside it) - menus(); - ImGui::EndMenu(); - } - const std::string file = (path_.empty() ? "Untitled" : fileName(path_)) + (dirty_ ? " *" : ""); - // The tabs: what you're doing (as wide as there's room for between the menus and the file's - // name; the pictures alone when it's tight). - { - const char* names[] = {"Model", "Paint", "Layout", "Render", "Print"}; - const Icon icons[] = {Icon::Objects, Icon::PaintBrush, Icon::Layout, Icon::Render, Icon::Print}; - const char* tips[] = {"Make the shape (Ctrl+1)", "Paint pictures onto it (Ctrl+2)", "Change how its picture is laid out: move, turn and size its frames (Ctrl+3)", - "Light it and save pictures of it (Ctrl+4)", "Get it ready for a 3D printer: size, checks, the printer's plate (Ctrl+5)"}; - const float h = ImGui::GetFrameHeight(), from = ImGui::GetCursorPosX() + 20.0f; - const float room = ImGui::GetWindowWidth() - from - (touch() ? 8.0f : ImGui::CalcTextSize(file.c_str()).x + 32.0f); - float tw = std::min(touch() ? 72.0f : 92.0f, room / 5 - 4); - const bool words = tw >= h + 2 + ImGui::CalcTextSize("Layout").x + 6; - if (!words) tw = h + 4; - ImGui::SetCursorPosX(std::max(from, std::min((ImGui::GetWindowWidth() - (tw + 4) * 5) * 0.5f, from + room - (tw + 4) * 5))); - for (int i = 0; i < 5; ++i) { - if (i) ImGui::SameLine(0, 4); - ImGui::PushID(i); - if (wideButton("##tab", icons[i], words ? names[i] : "", int(tab_) == i, tw, words ? tips[i] : std::format("{}: {}", names[i], tips[i]).c_str(), h)) setTab(Tab(i)); - ImGui::PopID(); - } - } - if (touch()) return ImGui::EndMainMenuBar(); // (no room on a phone) - ImGui::SameLine(ImGui::GetWindowWidth() - ImGui::CalcTextSize(file.c_str()).x - 16); - ImGui::TextDisabled("%s", file.c_str()); - ImGui::EndMainMenuBar(); -} - -auto App::addMenuItems() -> void { - ImGui::SeparatorText("Shapes"); - for (const ShapeRecipe& r : shapeRecipes()) - if (!r.starter && ImGui::MenuItem(r.name)) addShape(r.id); - ImGui::SeparatorText("Starter models"); - for (const ShapeRecipe& r : shapeRecipes()) - if (r.starter && ImGui::MenuItem(r.name)) addShape(r.id); - ImGui::Separator(); - if (ImGui::MenuItem("Draw a shape", "D")) { - if (mode_ == Mode::Edit) leaveEdit(); - setTool(Tool::Draw); - } - if (ImGui::MenuItem("Reference picture...")) dialog(4); - commandItem("add.text"); -} - -// How the last item's key looks: filled when it's the one in use, sunk while held. -auto App::keyState(bool on) -> keel::ui::KeyState { - using keel::ui::KeyState; - return on ? KeyState::On : ImGui::IsItemActive() ? KeyState::Pressed : ImGui::IsItemHovered() ? KeyState::Hovered : KeyState::Normal; -} - -// A square button with a picture on it (the accent when it's the one in use). -auto App::iconButton(const char* id, Icon icon, bool on, const char* tip, float size) -> bool { - const ImVec2 p = ImGui::GetCursorScreenPos(); - const bool hit = ImGui::InvisibleButton(id, ImVec2(size, size)); - const keel::ui::KeyState k = keyState(on); - ImDrawList* dl = ImGui::GetWindowDrawList(); - keel::ui::drawKey(dl, p, ImVec2(p.x + size, p.y + size), 7.0f, k); - const float sink = k == keel::ui::KeyState::Pressed ? 1.0f : 0.0f; - drawIcon(dl, icon, ImVec2(p.x + size * 0.14f, p.y + size * 0.14f + sink), size * 0.72f, keel::ui::keyText(k)); - const bool hov = ImGui::IsItemHovered(); - if (hov && tip) ImGui::SetTooltip("%s", tip); - return hit; -} - -// A wide one, with words beside the picture. -auto App::wideButton(const char* id, Icon icon, const char* label, bool on, float width, const char* tip, float h) -> bool { - const ImVec2 p = ImGui::GetCursorScreenPos(); - const bool hit = ImGui::InvisibleButton(id, ImVec2(width, h)); - const keel::ui::KeyState k = keyState(on); - ImDrawList* dl = ImGui::GetWindowDrawList(); - keel::ui::drawKey(dl, p, ImVec2(p.x + width, p.y + h), 7.0f, k); - const float sink = k == keel::ui::KeyState::Pressed ? 1.0f : 0.0f; - drawIcon(dl, icon, ImVec2(p.x + h * 0.15f, p.y + h * 0.13f + sink), h * 0.74f, keel::ui::keyText(k)); - dl->AddText(ImVec2(p.x + h + 2, p.y + (h - ImGui::GetFontSize()) * 0.5f + sink), keel::ui::keyText(k), label); - const bool hov = ImGui::IsItemHovered(); - if (hov && tip) ImGui::SetTooltip("%s", tip); - return hit; -} - -auto App::toolbox() -> void { - if (std::exchange(showToolbox_, false)) ImGui::SetNextWindowFocus(); - ImGui::SetNextWindowSizeConstraints(panelMin("Toolbox"), ImVec2(FLT_MAX, FLT_MAX)); - ImGui::Begin("Toolbox", nullptr, ImGuiWindowFlags_NoCollapse); - const float w = ImGui::GetContentRegionAvail().x; - if (tab_ == Tab::Paint) { - paintToolbox(w); - return ImGui::End(); - } - if (tab_ == Tab::Layout) { - layoutToolbox(w); - return ImGui::End(); - } - if (tab_ == Tab::Render || tab_ == Tab::Print) { - const Tool tools[] = {Tool::Select, Tool::Move, Tool::Rotate, Tool::Scale}; - grid(4, 38.0f, [&](int i) { - const ToolInfo& t = kTools[int(tools[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, tool_ == t.tool, tip.c_str())) setTool(t.tool); - }); - ImGui::Spacing(); - if (tab_ == Tab::Print) printToolbox(w); - else if (wideButton("##save", Icon::Picture, "Save a picture", false, w, "Saves what the view shows, without the panels or gizmos (F12)")) dialog(5); - return ImGui::End(); - } - // The mode: whole objects, or the shape of one. - if (wideButton("##objects", Icon::Objects, "Objects", mode_ == Mode::Object, (w - 5) / 2, "Work with whole objects")) leaveEdit(); - ImGui::SameLine(0, 5); - if (wideButton("##shape", Icon::Shape, "Shape", mode_ == Mode::Edit, (w - 5) / 2, "Edit the selected object's shape: its points, edges and faces (Tab, or double-click it)")) enterEdit(); - if (mode_ == Mode::Edit) { - const Icon icons[] = {Icon::Points, Icon::Edges, Icon::Faces}; - const char* tips[] = {"Points (1)", "Edges (2)", "Faces (3)"}; - const char* names[] = {"Points", "Edges", "Faces"}; - const float third = (w - 10) / 3; - for (int i = 0; i < 3; ++i) { - if (i) ImGui::SameLine(0, 5); - ImGui::PushID(i); - if (wideButton("##elem", icons[i], names[i], int(elem_) == i, third, tips[i])) elem_ = Elem(i), clearElements(); - ImGui::PopID(); - } - } - ImGui::Spacing(); - // The tools, as pictures (hover for the name and key). - std::vector tools; - for (const ToolInfo& t : kTools) - if ((mode_ == Mode::Object && t.objectMode) || (mode_ == Mode::Edit && t.editMode)) tools.push_back(&t); - grid(int(tools.size()) + 1, 38.0f, [&](int i) { - if (i == int(tools.size())) { - if (iconButton("##add", Icon::Add, false, "Add a shape or a starter model")) ImGui::OpenPopup("add"); - return; - } - const ToolInfo& t = *tools[size_t(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, tool_ == t.tool, tip.c_str())) setTool(t.tool); - }); - if (ImGui::BeginPopup("add")) { - addMenuItems(); - ImGui::EndPopup(); - } - // Mouse tools from plug-ins (fry.tool): by name, as plug-ins load again. - if (mode_ == Mode::Object) { - bool first = true; - for (size_t i = 0; i < plugins_.size(); ++i) - if (pluginUsable(i)) - for (const fry::PluginTool& t : plugins_[i].p->tools) { - if (std::exchange(first, false)) ImGui::SeparatorText("Plug-in tools"); - const bool on = pluginTool_ == std::pair(plugins_[i].file, t.name); - if (ImGui::Selectable(std::format("{}##{}", t.name, plugins_[i].file).c_str(), on, 0, ImVec2(w, touch() ? 34.0f : 0.0f))) - on ? setTool(Tool::Select) : void((pluginTool_ = {plugins_[i].file, t.name}, tool_ = Tool::Select, say(t.name + ": click in the view (Esc stops)"))); - if (ImGui::IsItemHovered()) ImGui::SetTooltip("%s%s(%s)", t.help.c_str(), t.help.empty() ? "" : "\n", plugins_[i].p->name.c_str()); - } - } - ImGui::Spacing(); - if (pluginTool_.first.empty()) // (a plug-in tool: its row shows it's on) - for (const ToolInfo& t : kTools) - if (t.tool == tool_) ImGui::TextColored(accentText(), "%s", t.name); - ImGui::End(); -} - -// The plug-in tool that's on: which plug-in and which of its tools (none: -1). -auto App::pluginToolAt() const -> std::pair { - for (size_t i = 0; i < plugins_.size(); ++i) - if (plugins_[i].file == pluginTool_.first && pluginUsable(i)) - for (size_t k = 0; k < plugins_[i].p->tools.size(); ++k) - if (plugins_[i].p->tools[k].name == pluginTool_.second) return {int(i), int(k)}; - return {-1, -1}; -} - -// A plug-in tool's click: the spot on what's under the mouse, or on the ground. -auto App::clickPluginTool(ImVec2 mouse) -> void { - const auto [i, k] = pluginToolAt(); - if (i < 0) return pluginTool_ = {}, say("That plug-in tool isn't loaded any more"); - const Ray ray = mouseRay(mouse); - glm::vec3 at, n(0, 0, 1); - uint32_t onto = 0; - const Hit h = pickObject(ray, 0, true); - if (const Object* o = h.object ? find(h.object) : nullptr) { - at = ray.origin + ray.dir * h.t, onto = o->id; - n = glm::normalize(glm::transpose(glm::inverse(glm::mat3(objectMatrix(*o)))) * faceNormal(o->mesh, h.face)); - } else if (auto g = hitPlane(ray, glm::vec3(0.0f), n)) at = *g; - else return; - pushUndo(); - bool frame = false; - fry::ScriptHost host{&scene_, &selected_, &active_, [this](const std::string& t) { scriptLine(t); }, [&] { frame = true; }}; - const fry::ScriptResult r = plugins_[size_t(i)].p->click(host, size_t(k), at, n, onto); - finishRun(r, pluginTool_.second, frame); -} - -auto App::paintToolbox(float w) -> void { - const float half = (w - ImGui::GetStyle().ItemSpacing.x) * 0.5f; - if (wideButton("##colour-side", Icon::PaintBrush, "Colour", !sculpting_, half, "Paint colours on it (Tab)")) sculpting_ = false, endSculpt(); - ImGui::SameLine(); - if (wideButton("##shape-side", Icon::Sculpt, "Sculpt", sculpting_, half, "Push its surface about like clay (Tab)")) sculpting_ = true, endStroke(); - ImGui::Spacing(); - if (sculpting_) { - grid(int(std::size(kSculptTools)), 38.0f, [&](int i) { - const SculptToolInfo& t = kSculptTools[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, sculptBrush_ == t.brush, tip.c_str())) sculptBrush_ = t.brush; - }); - for (const SculptToolInfo& t : kSculptTools) - if (t.brush == sculptBrush_) ImGui::TextColored(accentText(), "%s", t.name); - return; - } - grid(int(std::size(kPaintTools)), 38.0f, [&](int i) { - const PaintToolInfo& t = kPaintTools[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, paintTool_ == t.tool, tip.c_str())) paintTool_ = t.tool; - }); - ImGui::Spacing(); - // The colour, big, and a few to pick from. - ImGui::ColorButton("##colour", ImVec4(paintColor_.r, paintColor_.g, paintColor_.b, 1.0f), ImGuiColorEditFlags_NoTooltip, ImVec2(w, 34)); - static const glm::vec3 palette[] = {{0.08f, 0.08f, 0.09f}, {0.95f, 0.95f, 0.95f}, {0.55f, 0.55f, 0.57f}, {0.85f, 0.2f, 0.12f}, {1.0f, 0.55f, 0.1f}, {0.98f, 0.85f, 0.2f}, - {0.3f, 0.7f, 0.25f}, {0.15f, 0.45f, 0.85f}, {0.45f, 0.25f, 0.7f}, {0.45f, 0.3f, 0.18f}, {0.75f, 0.6f, 0.45f}, {0.95f, 0.6f, 0.7f}}; - grid(int(std::size(palette)), 20.0f, [&](int i) { - ImGui::PushID(i); - if (ImGui::ColorButton("##p", ImVec4(palette[i].r, palette[i].g, palette[i].b, 1.0f), 0, ImVec2(20, 20))) paintColor_ = glm::vec4(palette[i], paintColor_.a); - ImGui::PopID(); - }); - for (const PaintToolInfo& t : kPaintTools) - if (t.tool == paintTool_) ImGui::TextColored(accentText(), "%s", t.name); -} - -auto App::sculptOptions(float w) -> void { - for (const SculptToolInfo& t : kSculptTools) - if (t.brush == sculptBrush_) ImGui::SeparatorText(t.name), ImGui::TextWrapped("%s", t.hint); - ImGui::Spacing(); - ImGui::SetNextItemWidth(w); - ImGui::SliderFloat("##size", &sculptPx_, 4.0f, 400.0f, "size %.0f px", ImGuiSliderFlags_Logarithmic); - if (sculptBrush_ != SculptBrush::Grab) { - ImGui::SetNextItemWidth(w); - ImGui::SliderFloat("##strength", &sculptStrength_, 0.02f, 1.0f, "strength %.2f"); - } - ImGui::Checkbox("Same on both sides (X)", &sculptMirror_); - if (ImGui::IsItemHovered()) ImGui::SetTooltip("What you do on its left is done on its right too"); - if (Object* o = selected_.size() == 1 ? find(active_) : nullptr) { - ImGui::SeparatorText(o->name.c_str()); - ImGui::TextDisabled("%zu points", o->mesh.verts.size()); - size_t after = 0; - for (const auto& f : o->mesh.faces) after += f.size(); - ImGui::BeginDisabled(after > 400000 || o->locked); - if (ImGui::Button("Finer, for small details", ImVec2(w, 0))) { - pushUndo(); - if (o->hasFinish()) o->mesh = finishedMesh(*o), o->mirror = 0, o->round = 0, o->smoothLevels = 0; - handMade(*o); - subdivide(o->mesh, false); - touched(o->id); - } - ImGui::EndDisabled(); - if (ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled)) ImGui::SetTooltip("Each face split in four: the same shape, more points to push about"); - if (ImGui::Button("Smooth all of it", ImVec2(w, 0)) && !o->locked) { - pushUndo(); - handMade(*o); - const SculptMesh sm(o->mesh); - const glm::vec3 mid = (boundsMin(o->mesh) + boundsMax(o->mesh)) * 0.5f; - sculptDab(o->mesh, sm, SculptBrush::Smooth, {mid, glm::distance(boundsMin(o->mesh), boundsMax(o->mesh)) * 2.0f, 0.5f}); - touched(o->id); - } - } - ImGui::Spacing(); - ImGui::TextDisabled("Ctrl: the other way Shift: smooth\n[ and ]: brush size 1-6: brushes"); -} - -auto App::paintOptions(float w) -> void { - if (sculpting_) return sculptOptions(w); - for (const PaintToolInfo& t : kPaintTools) - if (t.tool == paintTool_) ImGui::SeparatorText(t.name); - if (paintTool_ == PaintTool::Brush || paintTool_ == PaintTool::Eraser) { - ImGui::SetNextItemWidth(w); - ImGui::SliderFloat("##size", &brushPx_, 1.0f, 300.0f, "size %.0f px", ImGuiSliderFlags_Logarithmic); - ImGui::SetNextItemWidth(w); - ImGui::SliderFloat("##hard", &hardness_, 0.0f, 1.0f, "hard edge %.2f"); - ImGui::SetNextItemWidth(w); - ImGui::SliderFloat("##strength", &paintColor_.a, 0.02f, 1.0f, "strength %.2f"); - ImGui::Checkbox("Both sides (mirrored, X)", &paintMirror_); - if (ImGui::IsItemHovered()) ImGui::SetTooltip("What you paint on one side goes on the other too, mirrored (left and right: the object's own Y)"); - ImGui::SeparatorText("Stencil"); - if (stencil_.size() >= 3) { - ImGui::Checkbox("Paint only inside it", &stencilOn_); - if (ImGui::Button("Draw it again", ImVec2(w * 0.5f - 2, 0))) drawingStencil_ = true, stencil_.clear(); - ImGui::SameLine(0, 4); - if (ImGui::Button("Take it off", ImVec2(w * 0.5f - 2, 0))) stencil_.clear(), stencilOn_ = false; - } else { - if (ImGui::Button(drawingStencil_ ? "Drawing: drag round the area..." : "Draw a stencil", ImVec2(w, 0))) drawingStencil_ = !drawingStencil_, stencil_.clear(); - if (ImGui::IsItemHovered()) ImGui::SetTooltip("Drag round an area on the screen: paint goes only inside it (it stays put on the screen as you turn the view)"); - } - } - if (paintTool_ == PaintTool::Fill) { - ImGui::SetNextItemWidth(w); - ImGui::SliderFloat("##tol", &fillTolerance_, 0.0f, 1.0f, "how alike %.2f"); - if (ImGui::IsItemHovered()) ImGui::SetTooltip("How different a colour can be and still be filled"); - } - if (paintTool_ == PaintTool::Brush || paintTool_ == PaintTool::Fill || paintTool_ == PaintTool::Picker) { - ImGui::SetNextItemWidth(w); - ImGui::ColorPicker3("##picker", &paintColor_.x, ImGuiColorEditFlags_NoSidePreview | ImGuiColorEditFlags_NoSmallPreview | ImGuiColorEditFlags_PickerHueBar); - } - if (paintTool_ == PaintTool::Eraser) ImGui::TextWrapped("Paints the object's plain colour back (its colour in the Model tab)."); - if (paintTool_ == PaintTool::Picture) { - if (ImGui::Button("Paste (Ctrl+V)", ImVec2(w, 0))) pasteFromClipboard(); - if (ImGui::Button("Add a picture from a file...", ImVec2(w, 0))) dialog(6); - if (PictureLayer* l = editLayer()) { - Object* o = find(ledit_.object); - ImGui::SeparatorText(l->name.c_str()); - if (ImGui::Button("Lock it in place (Enter)", ImVec2(w, 0))) lockLayer(); - if (ImGui::Button(ledit_.fresh ? "Take it away (Esc)" : "Delete it (Del)", ImVec2(w, 0))) ledit_.fresh ? cancelFloating() : deleteLayer(*o, ledit_.index); - ImGui::SetNextItemWidth(w); - undoable([&] { return ImGui::SliderFloat("##op", &l->opacity, 0.0f, 1.0f, "see-through %.2f"); }); - if (l->sticker) { - float size[2] = {l->width, l->height}; - ImGui::SetNextItemWidth(w); - if (undoable([&] { return ImGui::DragFloat2("##wh", size, 0.002f, 0.002f, 100.0f, "%.3f m"); })) l->width = size[0], l->height = size[1]; - } else if (o && o->picture) { - if (ImGui::Button("Cover the whole picture", ImVec2(w, 0))) { - pushUndo(); - const float W = float(o->picture->w), H = float(o->picture->h); - l->corners = {glm::vec2(0, 0), glm::vec2(W, 0), glm::vec2(W, H), glm::vec2(0, H)}; - } - if (ImGui::Button("Square it up again", ImVec2(w, 0))) { - pushUndo(); - glm::vec2 c(0.0f); - for (const glm::vec2& p : l->corners) c += p * 0.25f; - const float across = (glm::distance(l->corners[0], l->corners[1]) + glm::distance(l->corners[3], l->corners[2])) * 0.5f; - const glm::vec2 half(across * 0.5f, across * 0.5f * float(l->image->h) / float(std::max(l->image->w, 1))); - 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)}; - } - } - ImGui::Separator(); - } - ImGui::TextDisabled("New pictures go"); - int where = pasteOnSurface_ ? 0 : 1; - ImGui::SetNextItemWidth(w); - if (ImGui::Combo("##where", &where, "On the model (a sticker)\0On the flat picture\0")) pasteOnSurface_ = where == 0; - ImGui::TextWrapped(pasteOnSurface_ ? "Copy a picture in any app (GIMP, a browser...) and paste it. Drag it round the model: over an edge it bends like a sticker. " - "Corners size it (Shift: stretch), the round handle turns it (Ctrl: steps). Enter locks it as a layer; click it in the Picture panel to move it again." - : "Copy a picture in any app and paste it. In the Picture panel drag its corners where you want them: it bends to fit " - "(Shift keeps its shape; drag inside to move it). Enter locks it as a layer; click it there to move it again."); - } -} - -auto App::softChoice(float w) -> void { - if (mode_ != Mode::Edit) return; - ImGui::Checkbox("Soft selection (Shift+O)", &softSel_); - ImGui::Checkbox("Mirror editing (across X)", &liveMirror_); - if (ImGui::IsItemHovered()) ImGui::SetTooltip("A symmetric shape stays so: move a point, its twin on the other side moves too"); - if (ImGui::IsItemHovered()) ImGui::SetTooltip("Points near the selection follow it, less the further they are"); - if (!softSel_) return; - ImGui::SetNextItemWidth(w); - ImGui::DragFloat("##reach", &softReach_, 0.002f, 0.001f, 1000.0f, "reaches %.3f m"); - ImGui::TextDisabled("(the wheel while dragging too)"); -} - -auto App::toolOptions() -> void { - ImGui::SetNextWindowSizeConstraints(panelMin("Tool options"), ImVec2(FLT_MAX, FLT_MAX)); - ImGui::Begin("Tool options", nullptr, ImGuiWindowFlags_NoCollapse); - const float w = ImGui::GetContentRegionAvail().x; - if (tab_ == Tab::Paint) { - paintOptions(w); - return ImGui::End(); - } - if (tab_ == Tab::Layout) { - layoutOptions(w); - return ImGui::End(); - } - if ((tab_ == Tab::Print && printTool_ != PrintTool::None) || printTool_ == PrintTool::Measure) { - printOptions(w); - return ImGui::End(); - } - const char* name = ""; - for (const ToolInfo& t : kTools) - if (t.tool == tool_) name = t.name; - ImGui::SeparatorText(name); - auto orientChoice = [&] { - ImGui::TextDisabled("Arrows point"); - int o = int(orient()); - const char* items = mode_ == Mode::Edit ? "The object's way\0The world's way\0Out of the faces\0" : "The object's way\0The world's way\0"; - ImGui::SetNextItemWidth(w); - if (ImGui::Combo("##orient", &o, items)) orient() = Orient(o); - }; - auto snapChoice = [&](bool angle) { - ImGui::Checkbox("Snap", &snap_); - if (ImGui::IsItemHovered()) ImGui::SetTooltip("In steps (holding Ctrl does the opposite)"); - if (!snap_) return; - ImGui::SetNextItemWidth(w); - if (angle) return void(ImGui::DragFloat("##snapangle", &snapAngle_, 0.5f, 1.0f, 90.0f, "every %.0f deg")); - if (tool_ == Tool::Move) { - ImGui::SetNextItemWidth(w); - ImGui::Combo("##snapkind", &snapKind_, "To steps\0To points\0To edges' middles\0To faces' middles\0"); - if (ImGui::IsItemHovered()) ImGui::SetTooltip("Points, middles: the arrows' point goes to the one nearest the mouse (on other shapes, or what isn't picked)"); - if (snapKind_) return; - ImGui::SetNextItemWidth(w); - } - ImGui::DragFloat("##snapmove", &snapMove_, 0.001f, 0.001f, 10.0f, "every %.3f m"); - }; - switch (tool_) { - case Tool::Select: { - ImGui::TextDisabled("Drag to select with"); - const Icon icons[] = {Icon::Box, Icon::Brush, Icon::Lasso}; - const char* tips[] = {"A box", "A brush: paint over what you want", "A lasso: draw round it"}; - for (int i = 0; i < 3; ++i) { - if (i) ImGui::SameLine(0, 5); - ImGui::PushID(i); - if (iconButton("##shape", icons[i], int(selShape_) == i, tips[i], 34.0f)) selShape_ = SelShape(i); - ImGui::PopID(); - } - if (selShape_ == SelShape::Brush) { - ImGui::SetNextItemWidth(w); - ImGui::SliderFloat("##brush", &brushRadius_, 4.0f, 200.0f, "brush %.0f px"); - } - ImGui::Checkbox("See through (X-ray)", &xray_); - if (ImGui::IsItemHovered()) ImGui::SetTooltip("Select things behind others too (Alt+Z)"); - break; - } - case Tool::Move: - orientChoice(); - snapChoice(false); - softChoice(w); - break; - case Tool::Rotate: { - orientChoice(); - ImGui::TextDisabled("Turn around"); - int p = rotatePivot_ == Pivot::Origin ? 1 : 0; - ImGui::SetNextItemWidth(w); - if (ImGui::Combo("##rpivot", &p, "The middle\0The origin\0")) rotatePivot_ = p ? Pivot::Origin : Pivot::Middle; - snapChoice(true); - softChoice(w); - break; - } - case Tool::Scale: { - if (mode_ == Mode::Edit) orientChoice(); - ImGui::TextDisabled("What stays put"); - int p = scalePivot_ == Pivot::Opposite ? 0 : scalePivot_ == Pivot::Middle ? 1 : scalePivot_ == Pivot::Bottom ? 2 : 3; - ImGui::SetNextItemWidth(w); - if (ImGui::Combo("##spivot", &p, "The other side\0The middle\0The bottom\0The origin\0")) - scalePivot_ = p == 0 ? Pivot::Opposite : p == 1 ? Pivot::Middle : p == 2 ? Pivot::Bottom : Pivot::Origin; - if (ImGui::IsItemHovered()) - ImGui::SetTooltip("The other side: drag a side, the opposite side stays (like stretching in a paint program).\nThe middle: both sides move.\nThe bottom: it stays standing on the ground."); - snapChoice(false); - softChoice(w); - break; - } - case Tool::Draw: - ImGui::SetNextItemWidth(w); - ImGui::DragFloat("##thick", &drawThickness_, 0.001f, 0.0f, 10.0f, "thickness %.3f m"); - ImGui::TextWrapped("Drawn on the face you start on, or on %s.", - view_ == View::Perspective || view_ == View::Top || view_ == View::Bottom ? "the ground (a side view draws upright)" : "the plane you look at"); - ImGui::TextDisabled("Drawn on an object:"); - ImGui::SetNextItemWidth(w); - ImGui::Combo("##drawon", &drawOnMode_, "A new object on it\0Added to it (raised)\0Cut into it\0"); - snapChoice(false); - if (!drawPts_.empty() && ImGui::Button("Finish (Enter)", ImVec2(w, 0))) finishShape(); - break; - case Tool::Extrude: - ImGui::SetNextItemWidth(w); - ImGui::DragFloat("##ext", &extrudeAmount_, 0.001f, -10, 10, "%.3f m"); - if (ImGui::Button("Extrude by that", ImVec2(w, 0))) extrudeNow(extrudeAmount_); - snapChoice(false); - break; - case Tool::Inset: - ImGui::SetNextItemWidth(w); - ImGui::SliderFloat("##ins", &insetAmount_, 0.01f, 0.99f, "%.2f of the way in"); - if (ImGui::Button("Inset by that", ImVec2(w, 0))) insetNow(insetAmount_); - break; - case Tool::Bevel: - ImGui::SetNextItemWidth(w); - ImGui::DragFloat("##bw", &bevelWidth_, 0.001f, 0.0f, 10.0f, "width %.3f m"); - ImGui::SetNextItemWidth(w); - ImGui::SliderInt("##bs", &bevelSteps_, 1, 10, bevelSteps_ == 1 ? "1 step (a flat cut)" : "%d steps round"); - if (ImGui::Button("Round them", ImVec2(w, 0))) bevelNow(); - break; - case Tool::LoopCut: - ImGui::SetNextItemWidth(w); - ImGui::SliderInt("##cuts", &cuts_, 1, 16, cuts_ == 1 ? "1 cut" : "%d cuts"); - ImGui::TextWrapped("Cuts go across four-sided faces, all the way round."); - break; - case Tool::Knife: { - ImGui::Checkbox("Cut right through", &knifeThrough_); - if (ImGui::IsItemHovered()) ImGui::SetTooltip("Through everything in the line's way, the back too (else only what you can see).\nThe line goes on past its ends."); - if (knifeThrough_) { - ImGui::TextDisabled("Then"); - ImGui::SetNextItemWidth(w); - ImGui::Combo("##after", &knifeAfter_, "Just add the edges\0Pieces of the one object\0Each piece its own object\0Delete the smaller piece\0"); - if (knifeAfter_ > 0) { - ImGui::TextDisabled("Inside, where it's cut"); - int fill = knifeFill_ ? 0 : 1; - ImGui::SetNextItemWidth(w); - if (ImGui::Combo("##fill", &fill, "Filled (solid)\0Left open (hollow)\0")) knifeFill_ = fill == 0; - } - } - ImGui::TextWrapped("Click points along the cut, or drag a straight line. Ctrl: at 45 degree steps. Enter or double-click cuts, Esc lets go. " - "Each face it crosses is cut from edge to edge."); - if (knifePts_.size() >= 2 && ImGui::Button("Cut (Enter)", ImVec2(w, 0))) { - std::vector pts = knifePts_; - knifePts_.clear(); - knifeApply(active_, pts); - } - break; - } - case Tool::Hole: { - ImGui::SetNextItemWidth(w); - ImGui::Combo("##shape", &holeShape_, "Round\0Rectangle\0Triangle\0Hexagon (for nuts)\0"); - const HoleShape shape = HoleShape(holeShape_); - // Ready-made sizes for 3D prints: screws go through round holes, nuts sit in hexagons - // (a little room added, as prints come out a touch tight). - struct Fit { - const char* name; - float across, deep; // mm - }; - static const Fit screws[] = {{"M2 screw (2.4 mm)", 2.4f, 0}, {"M2.5 screw (2.9 mm)", 2.9f, 0}, {"M3 screw (3.4 mm)", 3.4f, 0}, {"M4 screw (4.5 mm)", 4.5f, 0}, - {"M5 screw (5.5 mm)", 5.5f, 0}, {"M6 screw (6.6 mm)", 6.6f, 0}, {"M8 screw (9 mm)", 9.0f, 0}}; - static const Fit nuts[] = {{"M2 nut (4 mm, 1.6 thick)", 4.0f, 1.6f}, {"M2.5 nut (5 mm, 2 thick)", 5.0f, 2.0f}, {"M3 nut (5.5 mm, 2.4 thick)", 5.5f, 2.4f}, - {"M4 nut (7 mm, 3.2 thick)", 7.0f, 3.2f}, {"M5 nut (8 mm, 4 thick)", 8.0f, 4.0f}, {"M6 nut (10 mm, 5 thick)", 10.0f, 5.0f}, - {"M8 nut (13 mm, 6.5 thick)", 13.0f, 6.5f}}; - if (shape == HoleShape::Round || shape == HoleShape::Hexagon) { - const bool nut = shape == HoleShape::Hexagon; - ImGui::SetNextItemWidth(w); - if (ImGui::BeginCombo("##fit", nut ? "Size for a nut..." : "Size for a screw...")) { - for (const Fit& f : nut ? std::span(nuts) : std::span(screws)) - if (ImGui::Selectable(f.name)) { - holeSize_ = (f.across + (nut ? holeRoom_ * 1000 : 0)) / 1000; - if (nut) holeMode_ = 1, holeDepth_ = (f.deep + 0.2f) / 1000; - else holeMode_ = 0; - } - ImGui::EndCombo(); - } - if (nut) { - float room = holeRoom_ * 1000; - ImGui::SetNextItemWidth(w); - if (ImGui::SliderFloat("##room", &room, 0.0f, 0.8f, "+ %.2f mm room")) holeRoom_ = room / 1000; - if (ImGui::IsItemHovered()) ImGui::SetTooltip("Added to a nut's size so it pushes in (0.2 to 0.4 mm suits most printers)"); - } - } - if (shape == HoleShape::Round) { - ImGui::SetNextItemWidth(w); - ImGui::SliderInt("##sides", &holeSides_, 6, 96, "%d sides round"); - } - const char* acrossName = shape == HoleShape::Round ? "%.2f mm across" : shape == HoleShape::Rectangle ? "%.2f mm wide" : shape == HoleShape::Triangle ? "%.2f mm sides" : "%.2f mm across flats"; - float across = holeSize_ * 1000, tall = holeTall_ * 1000; - ImGui::SetNextItemWidth(w); - if (ImGui::DragFloat("##size", &across, 0.1f, 0.1f, 100000.0f, acrossName)) holeSize_ = across / 1000; - if (shape == HoleShape::Rectangle) { - ImGui::SetNextItemWidth(w); - if (ImGui::DragFloat("##tall", &tall, 0.1f, 0.1f, 100000.0f, "%.2f mm high")) holeTall_ = tall / 1000; - } - if (shape != HoleShape::Round) { - ImGui::SetNextItemWidth(w); - ImGui::SliderFloat("##turn", &holeTurn_, -180.0f, 180.0f, "turned %.0f degrees"); - } - ImGui::TextDisabled("Cut"); - ImGui::SetNextItemWidth(w); - ImGui::Combo("##mode", &holeMode_, "All the way through\0A pocket\0Only the outline (a new face)\0"); - if (holeMode_ == 1) { - float deep = holeDepth_ * 1000; - ImGui::SetNextItemWidth(w); - if (ImGui::DragFloat("##depth", &deep, 0.1f, 0.1f, 100000.0f, "%.2f mm deep")) holeDepth_ = deep / 1000; - } - ImGui::TextWrapped(holeMode_ == 2 ? "Press on a face and drag out the size, or just click. Nothing's taken away: the outline is cut into the face, " - "and the new face inside it is picked, ready to Extrude, Bevel or paint." - : "Press on a face and drag out the size, or just click (the size above). It has to fit inside the face%s. " - "Round edges in Properties rounds its rim too.", - holeMode_ == 0 ? ", and the face behind" : ""); - break; - } - } - ImGui::End(); -} - -auto App::layersPanel() -> void { - ImGui::SetNextWindowSizeConstraints(panelMin("Layers"), ImVec2(FLT_MAX, FLT_MAX)); - ImGui::Begin("Layers", nullptr, ImGuiWindowFlags_NoCollapse); - // Along the top: a new folder, the picked ones into one. - if (ImGui::SmallButton("+ Folder")) { - pushUndo(); - Folder f; - f.id = scene_.nextId++, f.name = "Folder"; - scene_.folders.push_back(f); - renamingFolder_ = f.id, std::snprintf(renameBuf_, sizeof renameBuf_, "%s", f.name.c_str()); - } - if (ImGui::IsItemHovered()) ImGui::SetTooltip("A new folder: drag things onto it"); - ImGui::SameLine(); - ImGui::BeginDisabled(selected_.empty()); - if (ImGui::SmallButton("Group (Ctrl+G)")) groupSelected(); - ImGui::EndDisabled(); - if (ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled)) ImGui::SetTooltip("The picked objects into a new folder: picked and moved together"); - if (ImGui::BeginTable("objects", 4, ImGuiTableFlags_RowBg | ImGuiTableFlags_ScrollY)) { - ImGui::TableSetupColumn("eye", ImGuiTableColumnFlags_WidthFixed, 22); - ImGui::TableSetupColumn("lock", ImGuiTableColumnFlags_WidthFixed, 22); - ImGui::TableSetupColumn("name"); - ImGui::TableSetupColumn("go", ImGuiTableColumnFlags_WidthFixed, 22); - layerRows(0, 0); - for (Reference& ref : scene_.references) { - ImGui::PushID(int(ref.id)); - ImGui::TableNextRow(); - ImGui::TableNextColumn(); - if (rowToggle("##eye", ref.visible ? Icon::Eye : Icon::EyeShut, ref.visible, ref.visible ? "Shown (click to hide)" : "Hidden (click to show)")) ref.visible = !ref.visible; - ImGui::TableNextColumn(); - ImGui::TableNextColumn(); - const std::string label = "Picture: " + stdfs::path(ref.path).filename().string(); - if (ImGui::Selectable(label.c_str(), activeRef_ == ref.id)) activeRef_ = ref.id, selected_.clear(), leaveEdit(); - ImGui::TableNextColumn(); - ImGui::PopID(); - } - ImGui::EndTable(); - } - // (dropped below the list: out of its folder, to the top) - ImGui::InvisibleButton("##top", ImVec2(std::max(ImGui::GetContentRegionAvail().x, 1.0f), std::max(ImGui::GetContentRegionAvail().y, 8.0f))); - if (ImGui::BeginDragDropTarget()) { - if (const ImGuiPayload* p = ImGui::AcceptDragDropPayload("FRY_OBJECT")) { - const uint32_t oid = *static_cast(p->Data); - moveToFolder(selected_.count(oid) ? selected_ : std::set{oid}, 0); - } - if (const ImGuiPayload* p = ImGui::AcceptDragDropPayload("FRY_FOLDER")) - if (Folder* g = findFolder(*static_cast(p->Data))) pushUndo(), g->parent = 0; - ImGui::EndDragDropTarget(); - } - ImGui::End(); -} - -auto App::paintProperties(float w) -> void { - Object* o = selected_.empty() ? nullptr : find(active_); - if (sculpting_) { // (its look, while you shape it: what it's made of shows how the light runs over it) - if (!o) return ImGui::TextWrapped("Brush over an object to sculpt it.\n\nA shape with few points is made finer first, keeping its shape. Tab goes back to painting colours."); - ImGui::TextColored(accentText(), "%s", o->name.c_str()); - ImGui::TextDisabled("%zu points, %zu faces", o->mesh.verts.size(), o->mesh.faces.size()); - ImGui::SeparatorText("Look"); - materialPicker(*o); - undoable([&] { return ImGui::ColorEdit3("Colour", &o->color.x); }); - if (ImGui::Checkbox("Smooth shading", &o->smooth)) pushUndo(), touched(o->id); - return; - } - if (!o) { - ImGui::TextWrapped("Click an object to paint on it.\n\nIts surface gets laid out flat on a picture (the Picture panel), which you can paint on too, " - "or put pictures onto: copy one in any app and press Ctrl+V."); - return; - } - ImGui::TextColored(accentText(), "%s", o->name.c_str()); - if (!o->picture) { - int size = pictureSize_ <= 512 ? 0 : pictureSize_ <= 1024 ? 1 : pictureSize_ <= 2048 ? 2 : 3; - ImGui::SetNextItemWidth(w); - if (ImGui::Combo("##psize", &size, "512 x 512\0001024 x 1024\0002048 x 2048\0004096 x 4096\0")) pictureSize_ = 512 << size; - if (ImGui::Button("Start painting it", ImVec2(w, 0))) readyToPaint(o->id); - ImGui::TextDisabled("(or just paint on it)"); - return; - } - ImGui::TextDisabled("Picture %d x %d", o->picture->w, o->picture->h); - if (o->hasFinish()) ImGui::TextWrapped("It's had rounding or smoothing added since: the picture shows again when you paint (they become part of the shape)."); - ImGui::Checkbox("Show the layout lines", &showUvLines_); - // The pictures put on it, top first: click one to move it (again), the eye hides it. - ImGui::SeparatorText("Pictures on it (layers)"); - if (o->layers.empty()) ImGui::TextDisabled("None yet: paste one (Ctrl+V)"); - int move = -1, by = 0, drop = -1, merge = -1; - for (int i = int(o->layers.size()) - 1; i >= 0; --i) { - PictureLayer& l = o->layers[size_t(i)]; - const bool on = ledit_.object == o->id && ledit_.index == i; - ImGui::PushID(i); - if (rowToggle("##eye", l.visible ? Icon::Eye : Icon::EyeShut, l.visible, l.visible ? "Hide it" : "Show it")) pushUndo(), l.visible = !l.visible; - ImGui::SameLine(); - if (ImGui::Selectable(std::format("{}{}", l.name, on ? " (moving)" : l.sticker ? "" : " (flat)").c_str(), on, 0, ImVec2(w - 92, 0))) - on ? lockLayer() : unlockLayer(o->id, i); - ImGui::SameLine(w - 62); - ImGui::BeginDisabled(i == int(o->layers.size()) - 1); - if (ImGui::ArrowButton("up", ImGuiDir_Up)) move = i, by = 1; - ImGui::EndDisabled(); - ImGui::SameLine(0, 2); - ImGui::BeginDisabled(i == 0); - if (ImGui::ArrowButton("down", ImGuiDir_Down)) move = i, by = -1; - ImGui::EndDisabled(); - ImGui::SameLine(0, 2); - if (ImGui::SmallButton("x")) drop = i; - if (ImGui::IsItemHovered()) ImGui::SetTooltip("Delete it"); - if (ImGui::BeginPopupContextItem("menu")) { - ImGui::SetNextItemWidth(180); - undoable([&] { return ImGui::SliderFloat("##op", &l.opacity, 0.0f, 1.0f, "see-through %.2f"); }); - if (ImGui::MenuItem("Paint it in for good")) merge = i; - if (ImGui::MenuItem("Delete")) drop = i; - ImGui::EndPopup(); - } - ImGui::PopID(); - } - if (move >= 0) pushUndo(), std::swap(o->layers[size_t(move)], o->layers[size_t(move + by)]), ledit_ = {}; - if (drop >= 0) deleteLayer(*o, drop); - if (merge >= 0) mergeLayer(*o, merge); - ImGui::Spacing(); - if (ImGui::Button("Fill it all with the colour", ImVec2(w, 0))) { - Image& img = changePicture(*o); - img = makeImage(img.w, img.h, glm::vec4(glm::vec3(paintColor_), 1.0f)); - paint_.image.reset(); - } - if (ImGui::Button("Save the picture as PNG...", ImVec2(w, 0))) dialog(7); - if (ImGui::Button("Lay it out again", ImVec2(w, 0))) { - pushUndo(); - o->uvs = autoUnwrap(o->mesh); - touched(o->id); - } - if (ImGui::IsItemHovered()) ImGui::SetTooltip("Lays its surface out flat on the picture again (what's painted won't fit it any more)"); - if (ImGui::Button("Remove the picture", ImVec2(w, 0))) { - pushUndo(); - o->picture.reset(), o->uvs.clear(); - touched(o->id); - } - ImGui::Spacing(); - ImGui::TextWrapped("Tip: paint last. Changing the shape's faces (extrude, knife...) lays it out again, and what's painted moves."); -} - -auto App::renderProperties(float w) -> void { - Look& k = scene_.look; - auto slider = [&](const char* label, float* v, float lo, float hi, const char* fmt) { - if (undoable([&] { return ImGui::SliderFloat(label, v, lo, hi, fmt); })) dirty_ = true; - }; - ImGui::SeparatorText("Sun"); - slider("Round", &k.sunTurn, 0, 360, "%.0f deg"); - slider("Up", &k.sunHeight, 2, 90, "%.0f deg"); - slider("Strength", &k.sunStrength, 0, 2, "%.2f"); - undoable([&] { return ImGui::ColorEdit3("Sun colour", &k.sunColor.x); }); - ImGui::SeparatorText("Round it"); - if (ImGui::Checkbox("Sky", &k.sky)) dirty_ = true; - if (!k.sky) undoable([&] { return ImGui::ColorEdit3("Background", &k.background.x); }); - if (ImGui::Checkbox("Ground", &k.ground)) dirty_ = true; - if (k.ground) undoable([&] { return ImGui::ColorEdit3("Ground colour", &k.groundColor.x); }); - ImGui::Checkbox("Grid", &renderGrid_); - ImGui::SeparatorText("Look"); - slider("Brightness", &k.exposure, 0.2f, 3.0f, "%.2f"); - slider("Shade in corners", &k.ao, 0, 1, "%.2f"); - slider("Soft shadows", &k.shadowSoft, 0, 4, "%.2f"); - slider("Glow", &k.bloom, 0, 0.3f, "%.3f"); - slider("Colour", &k.saturation, 0, 2, "%.2f"); - slider("Dark edges", &k.vignette, 0, 1, "%.2f"); - slider("Sharpen", &k.sharpen, 0, 1, "%.2f"); - if (ImGui::Button("Back to how it was", ImVec2(w, 0))) pushUndo(), k = Look{}; - if (ImGui::Button("Save a picture (F12)", ImVec2(w, 0))) dialog(5); - if (Object* o = selected_.size() == 1 ? find(active_) : nullptr) { - ImGui::SeparatorText(o->name.c_str()); - materialPicker(*o); - undoable([&] { return ImGui::ColorEdit3("Colour", &o->color.x); }); - undoable([&] { return ImGui::SliderFloat("Metal", &o->metal, 0, 1); }); - undoable([&] { return ImGui::SliderFloat("Rough", &o->rough, 0, 1); }); - ImGui::TextDisabled("(metal and rough go with it into games)"); - } -} - -// The Paint tab's picture: the selected object's, flat, to paint on and place pictures on. -auto App::picturePanel() -> void { - ImGui::SetNextWindowSizeConstraints(panelMin("Picture"), ImVec2(FLT_MAX, FLT_MAX)); - ImGui::Begin("Picture", nullptr, ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse); - Object* o = selected_.empty() ? nullptr : find(active_); - if (!o || !o->picture) { - if (tab_ == Tab::Layout) { - ImGui::TextWrapped(o ? "It has no picture yet, so nothing's laid out." : "Click an object: how its picture is laid out shows here."); - if (o && ImGui::Button("Lay it out (and start its picture)")) readyToPaint(o->id); - return ImGui::End(); - } - ImGui::TextWrapped(sculpting_ ? "Sculpting changes its shape, not its picture. Tab goes back to painting colours." - : o ? "Paint on it (or press Start painting it in Properties) and its picture shows here." : "Click an object to paint it: its picture shows here."); - return ImGui::End(); - } - uploadPicture(*o, !paint_.active); - const int W = o->picture->w, H = o->picture->h; - const ImVec2 origin = ImGui::GetCursorScreenPos(), avail = ImGui::GetContentRegionAvail(); - if (avail.x < 16 || avail.y < 16) return ImGui::End(); - if (picZoom_ <= 0 || picFor_ != o->id) { - picZoom_ = std::min(avail.x / float(W), avail.y / float(H)) * 0.96f; - picPan_ = ImVec2((avail.x - float(W) * picZoom_) * 0.5f, (avail.y - float(H) * picZoom_) * 0.5f), picFor_ = o->id; - } - ImGui::InvisibleButton("canvas", avail, ImGuiButtonFlags_MouseButtonLeft | ImGuiButtonFlags_MouseButtonMiddle | ImGuiButtonFlags_MouseButtonRight); - const bool hovered = ImGui::IsItemHovered(); - ImGuiIO& io = ImGui::GetIO(); - auto toScreen = [&](glm::vec2 p) { return ImVec2(origin.x + picPan_.x + p.x * picZoom_, origin.y + picPan_.y + p.y * picZoom_); }; - const glm::vec2 mouse((io.MousePos.x - origin.x - picPan_.x) / picZoom_, (io.MousePos.y - origin.y - picPan_.y) / picZoom_); - if (hovered && io.MouseWheel != 0) { // zoom about the mouse - const float z = std::clamp(picZoom_ * std::pow(1.2f, io.MouseWheel), 0.05f, 64.0f); - picPan_ = ImVec2(io.MousePos.x - origin.x - mouse.x * z, io.MousePos.y - origin.y - mouse.y * z), picZoom_ = z; - } - if (ImGui::IsItemActive() && (ImGui::IsMouseDown(ImGuiMouseButton_Middle) || ImGui::IsMouseDown(ImGuiMouseButton_Right))) - picPan_.x += io.MouseDelta.x, picPan_.y += io.MouseDelta.y; - ImDrawList* dl = ImGui::GetWindowDrawList(); - dl->PushClipRect(origin, ImVec2(origin.x + avail.x, origin.y + avail.y), true); - dl->AddRectFilled(origin, ImVec2(origin.x + avail.x, origin.y + avail.y), IM_COL32(24, 24, 28, 255)); - uiImage(dl, paintTex_[o->id], toScreen({0, 0}), toScreen({float(W), float(H)})); - dl->AddRect(toScreen({0, 0}), toScreen({float(W), float(H)}), IM_COL32(0, 0, 0, 255)); - if (tab_ == Tab::Layout) { - if (o->uvsFit()) layoutCanvas(*o, dl, toScreen, mouse, hovered, W, H); - dl->PopClipRect(); - return ImGui::End(); - } - if (showUvLines_ && o->uvsFit()) - for (const auto& face : o->uvs) { - std::vector pts; - for (const glm::vec2& q : face) pts.push_back(toScreen(q * glm::vec2(W, H))); - dl->AddPolyline(pts.data(), int(pts.size()), IM_COL32(255, 255, 255, 60), ImDrawFlags_Closed, 1.0f); - } - PictureLayer* el = ledit_.object == o->id ? editLayer() : nullptr; - if (el && !el->sticker) { - ImVec2 q[4]; - for (int i = 0; i < 4; ++i) q[i] = toScreen(el->corners[size_t(i)]); - if (hovered && ImGui::IsMouseClicked(ImGuiMouseButton_Left)) { - ledit_.drag = -1; - for (int i = 0; i < 4; ++i) - if (std::abs(io.MousePos.x - q[i].x) < 10 && std::abs(io.MousePos.y - q[i].y) < 10) ledit_.drag = i; - if (ledit_.drag < 0 && insidePolygon(io.MousePos, {q[0], q[1], q[2], q[3]})) ledit_.drag = 4; - if (ledit_.drag >= 0) pushUndo(); - else lockLayer(); - ledit_.grab = mouse, ledit_.startCorners = el->corners; - } - if (ledit_.drag >= 0 && ImGui::IsMouseDown(ImGuiMouseButton_Left)) { - const glm::vec2 d = mouse - ledit_.grab; - const int i = ledit_.drag; - if (i == 4) - for (int k = 0; k < 4; ++k) el->corners[size_t(k)] = ledit_.startCorners[size_t(k)] + d; - else if (io.KeyShift) { // bigger or smaller, the opposite corner staying - const glm::vec2 opp = ledit_.startCorners[size_t((i + 2) % 4)]; - const float k = glm::length(mouse - opp) / std::max(glm::length(ledit_.startCorners[size_t(i)] - opp), 1e-3f); - for (int j = 0; j < 4; ++j) el->corners[size_t(j)] = opp + (ledit_.startCorners[size_t(j)] - opp) * k; - } else - el->corners[size_t(i)] = ledit_.startCorners[size_t(i)] + d; - } - if (!ImGui::IsMouseDown(ImGuiMouseButton_Left)) ledit_.drag = -1; - for (int i = 0; i < 4; ++i) q[i] = toScreen(el->corners[size_t(i)]); - dl->AddPolyline(q, 4, kAccent, ImDrawFlags_Closed, 2.0f); - for (int i = 0; i < 4; ++i) { - const bool hot = ledit_.drag == i || (std::abs(io.MousePos.x - q[i].x) < 10 && std::abs(io.MousePos.y - q[i].y) < 10); - dl->AddRectFilled(ImVec2(q[i].x - 6, q[i].y - 6), ImVec2(q[i].x + 6, q[i].y + 6), hot ? kHot : IM_COL32(255, 255, 255, 240)); - dl->AddRect(ImVec2(q[i].x - 6, q[i].y - 6), ImVec2(q[i].x + 6, q[i].y + 6), IM_COL32(20, 20, 25, 255)); - } - } else { - const bool onPicture = mouse.x >= 0 && mouse.y >= 0 && mouse.x < float(W) && mouse.y < float(H); - const glm::vec2 uv = mouse / glm::vec2(W, H); - // Which layer is on top under the mouse (0: the painting). - int under = 0; - if (const auto it = owner_.find(o->id); onPicture && it != owner_.end() && it->second.size() == size_t(W) * size_t(H)) - under = it->second[size_t(int(mouse.y)) * size_t(W) + size_t(int(mouse.x))]; - if (hovered && under && !paint_.active) ImGui::SetTooltip("%s: click to move it", o->layers[size_t(under - 1)].name.c_str()); - if (hovered && onPicture && ImGui::IsMouseClicked(ImGuiMouseButton_Left) && (under || el)) { - if (under) unlockLayer(o->id, under - 1); // (clicking a placed picture here unlocks it) - else lockLayer(); - } else if (hovered && onPicture && ImGui::IsMouseClicked(ImGuiMouseButton_Left)) { - if (paintTool_ == PaintTool::Picker || io.KeyAlt) pickColor(*o, uv); - else if (paintTool_ == PaintTool::Fill) fillAt(*o, uv); - else if (paintTool_ == PaintTool::Brush || paintTool_ == PaintTool::Eraser) { - beginStroke(*o, true); - paint_.stroke.dab(*paint_.image, mouse, brushPx_ / picZoom_, hardness_); - paint_.last2D = mouse, paint_.haveLast = true; - } - } - if (paint_.active && paint_.in2D) { - if (!ImGui::IsMouseDown(ImGuiMouseButton_Left)) endStroke(); - else { - const float r = brushPx_ / picZoom_, gap = glm::distance(mouse, paint_.last2D); - const int steps = std::max(1, int(std::ceil(gap / std::max(r * 0.3f, 0.5f)))); - for (int i = 1; i <= steps; ++i) paint_.stroke.dab(*paint_.image, glm::mix(paint_.last2D, mouse, float(i) / float(steps)), r, hardness_); - paint_.last2D = mouse; - uploaded_[o->id].dirty = true; - } - } - if (hovered && (paintTool_ == PaintTool::Brush || paintTool_ == PaintTool::Eraser)) dl->AddCircle(io.MousePos, brushPx_, IM_COL32(255, 255, 255, 200), 40, 1.5f); - } - dl->PopClipRect(); - ImGui::End(); -} - -auto App::propertiesPanel() -> void { - ImGui::SetNextWindowSizeConstraints(panelMin("Properties"), ImVec2(FLT_MAX, FLT_MAX)); - ImGui::Begin("Properties", nullptr, ImGuiWindowFlags_NoCollapse); - const float w = ImGui::GetContentRegionAvail().x; - if (tab_ == Tab::Paint) { - paintProperties(w); - return ImGui::End(); - } - if (tab_ == Tab::Render) { - renderProperties(w); - return ImGui::End(); - } - if (tab_ == Tab::Layout) { - layoutProperties(w); - return ImGui::End(); - } - if (tab_ == Tab::Print) { - printProperties(w); - return ImGui::End(); - } - if (Reference* ref = activeRef_ && selected_.empty() ? findRef(activeRef_) : nullptr) { - ImGui::TextWrapped("%s", ref->path.c_str()); - int axis = int(ref->axis); - if (ImGui::Combo("Facing", &axis, "Front view\0Side view\0Top view\0")) pushUndo(), ref->axis = Axis(axis); - undoable([&] { return ImGui::DragFloat2("Middle (m)", &ref->offset.x, 0.005f); }); - undoable([&] { return ImGui::DragFloat("Width (m)", &ref->width, 0.005f, -100.0f, 100.0f, "%.3f"); }); - ImGui::TextDisabled("A minus width flips it."); - undoable([&] { return ImGui::SliderFloat("See-through", &ref->opacity, 0.05f, 1.0f); }); - if (ImGui::Checkbox("Only in its own view", &ref->ownViewOnly)) dirty_ = true; - if (ImGui::IsItemHovered()) ImGui::SetTooltip("Shown only looking straight at it (front, side or top): out of the way in the 3D view and the others"); - if (ImGui::Button("Remove picture", ImVec2(w, 0))) deleteSelected(); - ImGui::End(); - return; - } - if (Object* o = editing()) { - editProperties(*o, w); - ImGui::End(); - return; - } - if (selected_.empty()) { - ImGui::TextWrapped("Nothing selected.\n\nClick an object to select it, or add one with + in the toolbox (or the Add menu): " - "shapes and starter models, with sizes you can change. Draw shape (D) turns an outline you click into a solid."); - ImGui::End(); - return; - } - if (selected_.size() > 1) { - ImGui::Text("%zu objects selected", selected_.size()); - glm::vec3 col = find(active_) ? find(active_)->color : glm::vec3(0.8f); - if (undoable([&] { return ImGui::ColorEdit3("Colour (all)", &col.x); })) - for (uint32_t id : selected_) - if (Object* o = find(id)) o->color = col, dirty_ = true; - if (ImGui::Button("Join into one (Ctrl+J)", ImVec2(w, 0))) joinSelected(); - ImGui::End(); - return; - } - Object* o = find(*selected_.begin()); - if (!o) { - ImGui::End(); - return; - } - ImGui::BeginDisabled(o->locked); - char name[128]; - std::snprintf(name, sizeof name, "%s", o->name.c_str()); - if (undoable([&] { return ImGui::InputText("Name", name, sizeof name); }) && name[0]) o->name = name; - // A library shape: its own sizes, until its shape's edited by hand. - if (const ShapeRecipe* r = findRecipe(o->recipe)) { - ImGui::SeparatorText(std::format("{} settings", r->name).c_str()); - bool changed = false; - o->settings.resize(r->settings.size()); - for (size_t i = 0; i < r->settings.size(); ++i) { - const ShapeSetting& s = r->settings[i]; - const float speed = s.whole ? 0.1f : std::max(0.001f, (s.max - s.min) * 0.0005f); - changed |= undoable([&] { return ImGui::DragFloat(s.name, &o->settings[i], speed, s.min, s.max, s.whole ? "%.0f" : "%.3f"); }); - } - if (changed) o->mesh = makeFromRecipe(*r, o->settings), touched(o->id); - ImGui::TextDisabled("(These go once you edit the shape by hand.)"); - } - ImGui::SeparatorText("Place"); - undoable([&] { return ImGui::DragFloat3("Position (m)", &o->position.x, 0.005f, 0, 0, "%.3f"); }); - glm::vec3 euler = toEuler(o->rotation); - if (undoable([&] { return ImGui::DragFloat3("Turn (deg)", &euler.x, 0.5f, 0, 0, "%.1f"); })) o->rotation = fromEuler(euler); - if (undoable([&] { return ImGui::DragFloat3("Size", &o->scale.x, 0.005f, 0, 0, "%.3f"); })) touched(o->id); - const glm::vec3 lo = boundsMin(shownMesh(*o)) * glm::abs(o->scale), hi = boundsMax(shownMesh(*o)) * glm::abs(o->scale); - // Its size itself, typed (the scale worked out from it): 0.25 makes it 25 cm, whatever it was. - glm::vec3 dims = hi - lo; - const glm::vec3 was = dims; - if (undoable([&] { return ImGui::DragFloat3("Dimensions (m)", &dims.x, 0.002f, 0.0001f, 1e5f, "%.4g"); })) { - int k = -1; - for (int i = 0; i < 3; ++i) - if (std::abs(dims[i] - was[i]) > 1e-9f) k = i; - if (k >= 0 && was[k] > 1e-7f && dims[k] > 1e-7f) { - const float f = dims[k] / was[k]; - if (keepProportions_) o->scale *= f; - else o->scale[k] *= f; - touched(o->id); - } - } - if (ImGui::IsItemHovered()) ImGui::SetTooltip("Across, deep, high: type a size and it's made that big"); - ImGui::Checkbox("Keep its proportions", &keepProportions_); - ImGui::SeparatorText("Look"); - materialPicker(*o); - undoable([&] { return ImGui::ColorEdit3("Colour", &o->color.x); }); - undoable([&] { return ImGui::SliderFloat("Metal", &o->metal, 0, 1); }); - undoable([&] { return ImGui::SliderFloat("Rough", &o->rough, 0, 1); }); - if (ImGui::Checkbox("Smooth shading", &o->smooth)) pushUndo(), touched(o->id); - if (ImGui::IsItemHovered()) ImGui::SetTooltip("Round surfaces look round; edges sharper than the angle stay sharp"); - if (o->smooth) { - ImGui::SameLine(); - ImGui::SetNextItemWidth(-1); - if (undoable([&] { return ImGui::SliderFloat("##angle", &o->smoothAngle, 1, 180, "creases > %.0f deg"); })) touched(o->id); - } - // Finishing touches that stay changeable: rounded edges, smoothing, a mirrored half. - ImGui::SeparatorText("Round, smooth, mirror"); - if (undoable([&] { return ImGui::DragFloat("Round edges", &o->round, 0.001f, 0.0f, 10.0f, o->round > 0 ? "%.3f m" : "off"); })) { - if (o->round > 0 && o->roundSteps > 1) o->smooth = true; - touched(o->id); - } - if (ImGui::IsItemHovered()) ImGui::SetTooltip("Rounds off every sharp edge and corner (drag right)"); - if (o->round > 0) { - if (undoable([&] { return ImGui::SliderInt("Steps round", &o->roundSteps, 1, 8); })) touched(o->id); - if (undoable([&] { return ImGui::SliderFloat("Edges sharper than", &o->roundAngle, 1, 179, "%.0f deg"); })) touched(o->id); - } - if (undoable([&] { return ImGui::SliderInt("Smooth out", &o->smoothLevels, 0, 3, o->smoothLevels ? "%d times" : "off"); })) { - if (o->smoothLevels) o->smooth = true; - touched(o->id); - } - if (ImGui::IsItemHovered()) ImGui::SetTooltip("Cuts every face into smaller ones and rounds the whole shape (like clay)"); - if (o->smoothLevels && ImGui::Checkbox("Keep sharp edges sharp", &o->smoothKeepSharp)) pushUndo(), touched(o->id); - ImGui::TextDisabled("Mirror across"); - for (int a = 0; a < 3; ++a) { - ImGui::SameLine(); - bool on = o->mirror & (1 << a); - if (ImGui::Checkbox(a == 0 ? "X" : a == 1 ? "Y" : "Z", &on)) pushUndo(), o->mirror ^= 1 << a, touched(o->id); - } - if (o->hasFinish() && ImGui::Button("Make it part of the shape", ImVec2(w, 0))) applyFinish(*o); - if (o->hasFinish() && ImGui::IsItemHovered()) ImGui::SetTooltip("Bake the rounding, smoothing and mirror into the points (so you can edit them)"); - if (const Object* t = fry::cutTarget(scene_, *o)) { // (an editable cutter) - ImGui::SeparatorText("Cut"); - ImGui::TextColored(accentText(), "Cuts into %s", t->name.c_str()); - ImGui::TextDisabled("Move it, turn it, change its shape: the hole follows."); - const uint32_t target = t->id, me = o->id; - if (ImGui::Button("Apply: make the cut for good", ImVec2(w, 0))) return applyCuts(target, me); - if (ImGui::Button("Stop cutting (keep it as an object)", ImVec2(w, 0))) pushUndo(), o->cuts = 0, dirty_ = true; - } else if (cutKeys_.count(o->id)) { - int n = 0; - for (const Object& c : scene_.objects) n += c.cuts == o->id && isCutter(c); - ImGui::SeparatorText("Cut"); - ImGui::TextDisabled("%d editable cut%s in it", n, n == 1 ? "" : "s"); - if (ImGui::Button(n == 1 ? "Apply: make the cut for good" : "Apply: make them all for good", ImVec2(w, 0))) return applyCuts(o->id); - if (ImGui::Button("Pick the cutters", ImVec2(w, 0))) { - selected_.clear(); - for (const Object& c : scene_.objects) - if (c.cuts == o->id) selected_.insert(c.id), active_ = c.id; - } - } - ImGui::SeparatorText("Do"); - if (ImGui::Button("Edit shape (Tab)", ImVec2(w, 0))) enterEdit(); - if (ImGui::Button("Apply turn and size", ImVec2(w, 0))) applyTransform(*o); - if (ImGui::IsItemHovered()) ImGui::SetTooltip("Bake them into the shape: turn becomes 0, size 1"); - if (ImGui::Button("Origin to middle", ImVec2((w - 6) / 2, 0))) originToMiddle(*o); - ImGui::SameLine(0, 6); - if (ImGui::Button("Origin to bottom", ImVec2(-1, 0))) originToBottom(*o); - if (ImGui::Button("Flip X", ImVec2((w - 12) / 3, 0))) mirror(*o, 0); - ImGui::SameLine(0, 6); - if (ImGui::Button("Flip Y", ImVec2((w - 12) / 3, 0))) mirror(*o, 1); - ImGui::SameLine(0, 6); - if (ImGui::Button("Flip Z", ImVec2(-1, 0))) mirror(*o, 2); - ImGui::EndDisabled(); - if (ImGui::Button(o->locked ? "Unlock" : "Lock", ImVec2((w - 6) / 2, 0))) o->locked = !o->locked; - ImGui::SameLine(0, 6); - if (ImGui::Button("Delete", ImVec2(-1, 0))) deleteSelected(); - ImGui::End(); -} - -// A material to start from: sets colour, metal and rough in one go (each a little ball of it). -// Yours (saved here, or from plug-ins and scripts) first; "Save its look as a material" adds one. -auto App::materialPicker(Object& o) -> void { - auto matches = [&](const MaterialPreset& m) { - return std::abs(m.metal - o.metal) < 0.01f && std::abs(m.rough - o.rough) < 0.01f && - (m.keepColor || glm::all(glm::lessThan(glm::abs(m.color - o.color), glm::vec3(0.01f)))); - }; - const MaterialPreset* now = nullptr; - for (const MaterialPreset& m : materialPresets()) - if (matches(m)) now = &m; - for (const MaterialPreset& m : ownMaterials()) - if (matches(m)) now = &m; - ImGui::SetNextItemWidth(ImGui::CalcItemWidth()); - if (ImGui::BeginCombo("Material", now ? now->name.c_str() : "(its own)", ImGuiComboFlags_HeightLarge)) { - std::optional remove; - auto item = [&](const MaterialPreset& m, bool own) { - const ImVec2 p = ImGui::GetCursorScreenPos(); - const float h = ImGui::GetTextLineHeight(); - ImGui::PushID(m.id.c_str()); - ImGui::PushID(own); - if (ImGui::Selectable("##m", now == &m, 0, ImVec2(0, h))) pushUndo(), applyMaterial(o, m), touched(o.id); - if (own && m.from.empty() && ImGui::BeginPopupContextItem()) { - if (ImGui::MenuItem("Remove it")) remove = m.id; - ImGui::EndPopup(); - } - if (own && ImGui::IsItemHovered()) - ImGui::SetTooltip("%s", m.from.empty() ? "Yours (right-click to remove)" : ("From " + m.from).c_str()); - ImGui::PopID(); - ImGui::PopID(); - // A ball: its colour, darker round the edge, a highlight as sharp as it's smooth. - const glm::vec3 c = m.keepColor ? o.color : m.color; - const ImVec2 mid(p.x + h * 0.5f, p.y + h * 0.5f); - ImDrawList* dl = ImGui::GetWindowDrawList(); - dl->AddCircleFilled(mid, h * 0.45f, ImGui::ColorConvertFloat4ToU32(ImVec4(c.r * 0.55f, c.g * 0.55f, c.b * 0.55f, 1))); - dl->AddCircleFilled(ImVec2(mid.x - h * 0.05f, mid.y - h * 0.05f), h * 0.36f, ImGui::ColorConvertFloat4ToU32(ImVec4(c.r, c.g, c.b, 1))); - dl->AddCircleFilled(ImVec2(mid.x - h * 0.14f, mid.y - h * 0.14f), h * (0.06f + 0.1f * m.rough), IM_COL32(255, 255, 255, int(230 * (1.0f - m.rough * 0.8f)))); - dl->AddText(ImVec2(p.x + h * 1.4f, p.y), ImGui::GetColorU32(ImGuiCol_Text), m.name.c_str()); - }; - if (!ownMaterials().empty()) { - ImGui::SeparatorText("Yours"); - for (const MaterialPreset& m : ownMaterials()) item(m, true); - ImGui::SeparatorText("Fry3DX's"); - } - for (const MaterialPreset& m : materialPresets()) item(m, false); - ImGui::Separator(); - if (ImGui::Selectable("Save its look as a material...")) nameMaterial_ = true, materialName_.clear(), materialKeepColor_ = false; - ImGui::EndCombo(); - if (remove && removeOwnMaterial(*remove)) saveOwnMaterials(); - } - if (std::exchange(nameMaterial_, false)) ImGui::OpenPopup("Save as a material"); - ImGui::SetNextWindowSize(ImVec2(340, 0)); - if (ImGui::BeginPopupModal("Save as a material", nullptr, ImGuiWindowFlags_NoResize)) { - ImGui::TextWrapped("Its colour, metal and rough as one of your materials, in the picker for every file."); - if (ImGui::IsWindowAppearing()) ImGui::SetKeyboardFocusHere(); - ImGui::SetNextItemWidth(-FLT_MIN); - inputString("##name", materialName_, "Its name (\"Teal plastic\")"); - ImGui::Checkbox("Only how metal and rough (keep each object's colour)", &materialKeepColor_); - const bool taken = std::any_of(materialPresets().begin(), materialPresets().end(), [&](const MaterialPreset& m) { return m.id == materialId(materialName_); }); - if (taken) ImGui::TextColored(roleColor(keel::ui::Role::DangerText), "That's one of Fry3DX's own: another name"); - ImGui::BeginDisabled(materialId(materialName_).empty() || taken); - pushAccent(); - const bool save = ImGui::Button("Save", ImVec2(120, 0)) || ImGui::IsKeyPressed(ImGuiKey_Enter, false); - popAccent(); - ImGui::EndDisabled(); - ImGui::SameLine(); - if (save && !materialId(materialName_).empty() && !taken) { - addOwnMaterial({materialId(materialName_), materialName_, o.color, o.metal, o.rough, materialKeepColor_, ""}); - saveOwnMaterials(); - say("Saved \"" + materialName_ + "\": it's in the Material picker now"); - ImGui::CloseCurrentPopup(); - } - if (ImGui::Button("Cancel", ImVec2(120, 0))) ImGui::CloseCurrentPopup(); - ImGui::EndPopup(); - } -} - -auto App::editProperties(Object& o, float w) -> void { - ImGui::Text("Editing the shape of %s", o.name.c_str()); - ImGui::TextDisabled("%zu points, %zu faces", o.mesh.verts.size(), o.mesh.faces.size()); - if (o.hasFinish()) ImGui::TextDisabled("(its rounding and smoothing show again when you're done)"); - const std::set pts = selectedPoints(); - if (!pts.empty()) { - // Where the selection's middle is (in the world); typing moves it there. - const glm::vec3 c = pivotOfSelection(); - glm::vec3 at = c; - if (undoable([&] { return ImGui::DragFloat3("Middle (m)", &at.x, 0.002f, 0, 0, "%.3f"); })) { - const glm::mat4 m = objectMatrix(o), local = glm::inverse(m) * glm::translate(glm::mat4(1.0f), at - c) * m; - for (int v : pts) o.mesh.verts[size_t(v)] = glm::vec3(local * glm::vec4(o.mesh.verts[size_t(v)], 1.0f)); - handMade(o); - touched(o.id); - } - } - ImGui::SeparatorText("Faces"); - ImGui::SetNextItemWidth(w * 0.45f); - ImGui::DragFloat("##ext", &extrudeAmount_, 0.001f, -10, 10, "%.3f m"); - ImGui::SameLine(); - if (ImGui::Button("Extrude", ImVec2(-1, 0))) extrudeNow(extrudeAmount_); - ImGui::SetNextItemWidth(w * 0.45f); - ImGui::SliderFloat("##ins", &insetAmount_, 0.01f, 0.99f, "%.2f"); - ImGui::SameLine(); - if (ImGui::Button("Inset", ImVec2(-1, 0))) insetNow(insetAmount_); - if (ImGui::Button("Flip", ImVec2((w - 6) / 2, 0))) flipNow(); - ImGui::SameLine(0, 6); - if (ImGui::Button("Make face", ImVec2(-1, 0))) makeFaceNow(); - if (ImGui::Button("Bridge two faces", ImVec2(w, 0))) bridgeNow(); - if (ImGui::IsItemHovered()) ImGui::SetTooltip("Two faces with as many corners: taken out and joined by a tunnel"); - if (ImGui::Button("Make into its own object (P)", ImVec2(w, 0))) separateSelected(); - ImGui::SeparatorText("Edges"); - ImGui::SetNextItemWidth(w * 0.45f); - ImGui::DragFloat("##bw", &bevelWidth_, 0.001f, 0.0f, 10.0f, "%.3f m"); - ImGui::SameLine(); - ImGui::SetNextItemWidth(w * 0.2f); - ImGui::DragInt("##bs", &bevelSteps_, 0.1f, 1, 10); - ImGui::SameLine(); - if (ImGui::Button("Round", ImVec2(-1, 0))) bevelNow(); - if (ImGui::IsItemHovered()) ImGui::SetTooltip("Round the selected edges (or the edges round the selected faces): width, steps"); - ImGui::SeparatorText("Points"); - if (ImGui::Button("Merge to middle (M)", ImVec2(w, 0))) mergeNow(); - if (ImGui::Button("Smooth", ImVec2((w - 6) / 2, 0))) smoothPointsNow(); - if (ImGui::IsItemHovered()) ImGui::SetTooltip("Even out bumps: each point towards its neighbours"); - ImGui::SameLine(0, 6); - if (ImGui::Button("Push / pull (Alt+S)", ImVec2(-1, 0))) startPush(); - ImGui::SetNextItemWidth(w * 0.45f); - ImGui::DragFloat("##md", &mergeDistance_, 0.0001f, 0.0f, 1.0f, "%.4f m"); - ImGui::SameLine(); - if (ImGui::Button("Merge close", ImVec2(-1, 0))) { - pushUndo(); - const int gone = mergeByDistance(o.mesh, mergeDistance_); - changedShape(o); - say(std::format("{} points merged", gone)); - } - ImGui::SeparatorText("Whole shape"); - if (ImGui::Button("Cut in four", ImVec2((w - 6) / 2, 0))) subdivideNow(false); - ImGui::SameLine(0, 6); - if (ImGui::Button("Smooth it", ImVec2(-1, 0))) subdivideNow(true); - ImGui::SeparatorText("Select"); - if (ImGui::Button("All", ImVec2((w - 12) / 3, 0))) selectAllElements(); - ImGui::SameLine(0, 6); - if (ImGui::Button("None", ImVec2((w - 12) / 3, 0))) clearElements(); - ImGui::SameLine(0, 6); - if (ImGui::Button("Invert", ImVec2(-1, 0))) invertSelection(); - if (ImGui::Button("More", ImVec2((w - 12) / 3, 0))) growSelection(); - ImGui::SameLine(0, 6); - if (ImGui::Button("Less", ImVec2((w - 12) / 3, 0))) shrinkSelection(); - ImGui::SameLine(0, 6); - if (ImGui::Button("Joined", ImVec2(-1, 0))) selectLinked(); - ImGui::Separator(); - if (ImGui::Button("Delete (Del)", ImVec2(w, 0))) deleteElements(); - if (ImGui::Button("Done (Tab)", ImVec2(w, 0))) leaveEdit(); -} - -auto App::statusBar() -> void { - ImGui::Begin("Status", nullptr, ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoScrollbar); - if (now() - messageAt_ < 4.0) ImGui::TextColored(accentText(), "%s", message_.c_str()); - else if (tab_ == Tab::Layout) { - for (const LayToolInfo& t : kLayTools) - if (t.tool == layTool_) - ImGui::TextDisabled("%s: %s · Tab: frames or corners Right drag: look (pan in the Picture panel) F1: all keys", t.name, t.hint); - } else if ((tab_ == Tab::Print && printTool_ != PrintTool::None) || printTool_ == PrintTool::Measure) { - ImGui::TextDisabled(printTool_ == PrintTool::Measure ? "Measure: click two points on the model (corners catch the mouse); a third click starts again · M (Print) or Esc: done" - : "Lay flat: click the face it should stand on: it turns so that face is down, on the bed · L: done"); - } else if (tab_ == Tab::Paint && sculpting_) { - for (const SculptToolInfo& t : kSculptTools) - if (t.brush == sculptBrush_) ImGui::TextDisabled("%s: %s · Shift: smooth X: both sides Tab: colour Right drag: look F1: all keys", t.name, t.hint); - } else if (tab_ == Tab::Paint) { - for (const PaintToolInfo& t : kPaintTools) - if (t.tool == paintTool_) ImGui::TextDisabled("%s: %s · Right drag: look Wheel: forward / back (zoom in the Picture panel) F1: all keys", t.name, t.hint); - } else - for (const ToolInfo& t : kTools) - if (t.tool == tool_) - ImGui::TextDisabled("%s: %s · %s F1: all keys", t.name, t.hint, - fry_camera.i() == 1 ? "Right drag: orbit Middle drag: pan Wheel: zoom Shift+C: fly" - : "Right drag + WASD: look and move Alt+right: orbit Wheel: forward / back Shift+C: orbit"); - ImGui::End(); -} - -// The view's corner: which way you're looking, buttons to look straight at a side, and -// handles to drag to orbit, pan and zoom (for a touchpad or a pen). -auto App::viewButtons() -> void { - const ImVec2 at(viewMax_.x - 10, viewMin_.y + 10); - ImGui::SetNextWindowPos(at, ImGuiCond_Always, ImVec2(1, 0)); - ImGui::SetNextWindowBgAlpha(0.55f); - ImGui::Begin("##views", nullptr, ImGuiWindowFlags_NoDecoration | ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoSavedSettings | ImGuiWindowFlags_NoFocusOnAppearing | - ImGuiWindowFlags_NoNav | ImGuiWindowFlags_NoDocking); - static const char* names[] = {"Perspective", "Front", "Back", "Left", "Right", "Top", "Bottom"}; - static const char* shadings[] = {"Solid", "Rendered", "Wireframe"}; - ImGui::TextColored(accentText(), "%s", names[int(view_)]); - ImGui::SameLine(); - ImGui::TextDisabled("%s%s", shadings[int(shading_)], xray_ ? ", X-ray" : ""); - for (int i = 1; i < 7; ++i) { - if (i != 1 && i != 4) ImGui::SameLine(0, 3); - if (ImGui::Button(names[i], ImVec2(56, 0))) setView(View(i)); - } - if (ImGui::Button("3D", ImVec2(56, 0))) leaveFlatView(); - ImGui::SameLine(0, 3); - if (ImGui::Button("Frame", ImVec2(56, 0))) frameSelected(); - ImGui::SameLine(0, 3); - if (ImGui::Button(xray_ ? "Solid" : "X-ray", ImVec2(56, 0))) xray_ = !xray_; - const ImVec2 d = ImGui::GetIO().MouseDelta; - ImGui::Button("Orbit", ImVec2(56, 0)); - if (ImGui::IsItemActive()) orbitBy(d); - if (ImGui::IsItemHovered()) ImGui::SetTooltip("Drag to orbit"); - ImGui::SameLine(0, 3); - ImGui::Button("Pan", ImVec2(56, 0)); - if (ImGui::IsItemActive()) panBy(d); - if (ImGui::IsItemHovered()) ImGui::SetTooltip("Drag to pan"); - ImGui::SameLine(0, 3); - ImGui::Button("Zoom", ImVec2(56, 0)); - if (ImGui::IsItemActive()) dist_ = std::clamp(dist_ * std::pow(1.01f, d.y), 0.01f, 5000.0f); - if (ImGui::IsItemHovered()) ImGui::SetTooltip("Drag up and down to zoom"); - if (tab_ != Tab::Render && ImGui::Button(shading_ == Shading::Rendered ? "Show: Solid" : "Show: Rendered", ImVec2(56 * 3 + 6, 0))) - shading_ = shading_ == Shading::Rendered ? Shading::Solid : Shading::Rendered; - ImGui::End(); -} - -} // namespace fryapp diff --git a/src/app_print.cpp b/src/app_print.cpp deleted file mode 100644 index c80fdfa..0000000 --- a/src/app_print.cpp +++ /dev/null @@ -1,489 +0,0 @@ -// Fry3DX: the Print tab: the plate, checks (overhangs, thin walls), the slice preview, sizes. -#include "app.h" - -using namespace fry; -namespace stdfs = std::filesystem; - -namespace fryapp { - -// ───────────────────────────── the Print tab ───────────────────────────── - -auto App::bedSize() const -> glm::vec3 { // (metres) - const int i = std::clamp(fry_printer.i(), 0, int(std::size(kPrinters)) - 1); - const PrinterInfo& p = kPrinters[i]; - return p.x > 0 ? glm::vec3(p.x, p.y, p.z) * 0.001f : glm::vec3(fry_bed_x.f(), fry_bed_y.f(), fry_bed_z.f()) * 0.001f; -} - -auto App::printInfo(const Object& o) -> const PrintInfo& { - PrintInfo& p = printInfos_[o.id]; - uint64_t key = versions_[o.id] * 1000003ull + uint64_t(overhangDeg_ * 10) + uint64_t(minWall_ * 1e5f) * 7919ull; - for (float f : {o.position.x, o.position.y, o.position.z, o.rotation.x, o.rotation.y, o.rotation.z, o.rotation.w, o.scale.x, o.scale.y, o.scale.z}) { - uint32_t b; - std::memcpy(&b, &f, 4); - key = (key ^ b) * 1099511628211ull; - } - if (p.key == key) return p; - p.key = key; - p.world = cutKeys_.count(o.id) ? fry::cutMesh(scene_, o) : o.hasFinish() ? finishedMesh(o) : o.mesh; - transform(p.world, objectMatrix(o)); - p.lo = boundsMin(p.world), p.hi = boundsMax(p.world); - p.check = checkForPrint(p.world, overhangDeg_, 0.0f); - // Overhangs: those faces again a hair above the surface, to show red. - keel::KmdlModel km; - keel::KmdlMaterial mat{}; - mat.baseColor[0] = mat.baseColor[1] = mat.baseColor[2] = mat.baseColor[3] = 1.0f; - km.materials.push_back(mat); - for (size_t f = 0; f < p.world.faces.size(); ++f) { - if (!overhangs(p.world, int(f), overhangDeg_, 0.0f)) continue; - const glm::vec3 n = faceNormal(p.world, int(f)); - for (const auto& t : triangulate(p.world, int(f))) - for (int k : t) { - const glm::vec3 q = p.world.verts[size_t(k)] + n * 0.0004f; - km.indices.push_back(uint32_t(km.vertices.size())); - km.vertices.push_back({{q.x, q.y, q.z}, {n.x, n.y, n.z}, {0, 0}, {0, 0}}); - } - } - p.hasOverhang = !km.indices.empty(); - if (p.hasOverhang) { - km.submeshes.push_back({0, uint32_t(km.indices.size()), 0}); - p.overhang = r_->updateModel(std::format("fry:overhang:{}", o.id), km); - } - // Thin walls (only of a closed shape: an open one has no inside to be thick). - uint64_t thickKey = shapeKey(o) * 1000003ull; - for (float f : {o.scale.x, o.scale.y, o.scale.z}) { - uint32_t b; - std::memcpy(&b, &f, 4); - thickKey = (thickKey ^ b) * 1099511628211ull; - } - if (p.thickKey != thickKey || p.thick.size() != p.world.faces.size()) - p.thickKey = thickKey, p.thick = p.check.closed() ? fry::wallThickness(p.world) : std::vector(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 { // (what's picked, or everything shown) - std::vector 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 { - 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 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 diff --git a/src/app_scripts.cpp b/src/app_scripts.cpp deleted file mode 100644 index 52ec120..0000000 --- a/src/app_scripts.cpp +++ /dev/null @@ -1,821 +0,0 @@ -// Fry3DX: scripts, your own materials, plug-ins, the windows that go with them. -#include "app.h" -#include "fry_project.h" - -using namespace fry; -namespace stdfs = std::filesystem; - -namespace fryapp { - -// ───────────────────────────── scripts ───────────────────────────── - -auto App::scriptLine(std::string text, bool error) -> void { - size_t at = 0; - for (size_t nl; (nl = text.find('\n', at)) != std::string::npos; at = nl + 1) scriptLog_.push_back({text.substr(at, nl - at), error}); - scriptLog_.push_back({text.substr(at), error}); - if (scriptLog_.size() > 2000) scriptLog_.erase(scriptLog_.begin(), scriptLog_.begin() + 500); - scriptLogToBottom_ = true; -} - -// "little_town.lua" -> "little town" (a script's name in the list). -auto App::scriptName(const std::string& path) -> std::string { - std::string n = stdfs::path(fileName(path)).stem().string(); - std::replace(n.begin(), n.end(), '_', ' '); - return n.empty() ? "Script" : n; -} - -// Adds a script to the file's list and shows it (the same one again: just shows it). -auto App::addScript(std::string name, std::string code) -> void { - for (size_t i = 0; i < scene_.scripts.size(); ++i) - if (scene_.scripts[i].name == name && scene_.scripts[i].code == code) return void((script_ = i, showScript_ = true)); - const std::string base = name; - for (int n = 2; std::any_of(scene_.scripts.begin(), scene_.scripts.end(), [&](const fry::SceneScript& s) { return s.name == name; }); ++n) - name = std::format("{} {}", base, n); - scene_.scripts.push_back({name, std::move(code)}); - script_ = scene_.scripts.size() - 1; - showScript_ = true, dirty_ = true; -} - -// Runs the Script panel's script: one undo step; if it stops with an error, everything it did -// is taken back. -auto App::runScriptNow() -> void { - const fry::SceneScript* sc = currentScript(); - if (!sc) return say("No script to run: Script > New script, or open one"); - if (mode_ == Mode::Edit) leaveEdit(); - const std::string name = sc->name, code = sc->code; // (the list itself may change while it runs) - pushUndo(); - bool frame = false; - fry::ScriptHost host{&scene_, &selected_, &active_, [this](const std::string& t) { scriptLine(t); }, [&] { frame = true; }}; - const fry::ScriptResult r = fry::runScript(host, code, name); - finishRun(r, name, frame); -} - -// After a script or plug-in command: kept as one undo step, or all taken back on an error. -auto App::finishRun(const fry::ScriptResult& r, const std::string& name, bool frame) -> void { - touchAll(); - if (r.ok && frame) frameAll(); - if (!r.ok) { - takeBack(); - scriptLine(r.error, true); - showScript_ = true; - return say(name + " stopped (nothing it did was kept): " + r.error.substr(0, r.error.find('\n'))); - } - dirty_ = true; - say(r.objectsMade ? std::format("{} ran: {} object{} made (Ctrl+Z takes it all back)", name, r.objectsMade, r.objectsMade == 1 ? "" : "s") - : std::format("{} ran (Ctrl+Z takes it back)", name)); -} - -auto App::openScript(const std::string& path, const std::string& shownName) -> void { - std::ifstream in(path, std::ios::binary); - if (!in) return say("Couldn't open " + shownName); - addScript(scriptName(shownName), std::string(std::istreambuf_iterator(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(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::set 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 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> 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 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(in), {})}); - } - const std::set off = pluginsOff(); - fry::Scene scratch; // (loading a plug-in can't touch the open file: only its commands, when run, do) - std::set none; - uint32_t noActive = 0; - for (auto& [e, code] : found) { - e.on = !off.count(e.file); - if (e.on) { - e.p = std::make_unique(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& 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(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> 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(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 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> 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 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 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> picked; - if (elem_ == Elem::Edge) - for (auto [a, b] : selEdges_) picked.insert({std::min(a, b), std::max(a, b)}); - else { - const std::set 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 pts = selectedPoints(); - if (pts.empty()) return say("Pick points, edges or faces to slide first"); - std::vector> 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 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(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> 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 diff --git a/src/app_view.cpp b/src/app_view.cpp deleted file mode 100644 index 150f768..0000000 --- a/src/app_view.cpp +++ /dev/null @@ -1,1734 +0,0 @@ -// Fry3DX: the view: the camera, touch, what's shown (and kept), picking, the gizmo, drawing it. -#include "app.h" - -using namespace fry; -namespace stdfs = std::filesystem; - -namespace fryapp { - -// ───────────────────────────── touch: phones and tablets ───────────────────────────── - -// How much bigger than its pixels the UI is drawn. -auto App::touchScale() const -> float { - if (fry_uiscale.f() > 0) return fry_uiscale.f(); - if (!touch()) return 1.0f; - const float density = SDL_GetDisplayContentScale(SDL_GetPrimaryDisplay()); - return std::clamp((density > 0 ? density : 2.0f) * 0.9f, 1.0f, 4.0f) * std::clamp(fry_uisize.f(), 0.5f, 2.0f); -} - -auto App::touchFrame(ImGuiIO& io) -> void { - if (shiftLatch_) io.KeyShift = true; - if (ctrlLatch_) io.KeyCtrl = true; - ImGui::GetStyle().FontScaleMain = 1.0f; // (the sizes come from touchScale) - if (gest_.rightUp) { // (the long press's right-click: let go the frame after) - io.AddMouseButtonEvent(ImGuiMouseButton_Right, false); - gest_.rightUp = false; - } - // The fingers on the screen now (where, in the UI's units). - std::vector fingers; - int devices = 0; - if (SDL_TouchID* ids = SDL_GetTouchDevices(&devices)) { - for (int d = 0; d < devices; ++d) { - int n = 0; - if (SDL_Finger** f = SDL_GetTouchFingers(ids[d], &n)) { - for (int i = 0; i < n; ++i) fingers.push_back(ImVec2(f[i]->x * io.DisplaySize.x, f[i]->y * io.DisplaySize.y)); - SDL_free(f); - } - } - SDL_free(ids); - } - auto inView = [&](ImVec2 p) { return p.x >= viewMin_.x && p.y >= viewMin_.y && p.x < viewMax_.x && p.y < viewMax_.y; }; - if (fingers.size() >= 2) { - const ImVec2 mid((fingers[0].x + fingers[1].x) * 0.5f, (fingers[0].y + fingers[1].y) * 0.5f); - const float spread = std::max(std::hypot(fingers[0].x - fingers[1].x, fingers[0].y - fingers[1].y), 1.0f); - if (!gest_.two && !drag_.active && inView(mid) && !ImGui::IsAnyItemActive()) { // two fingers: the first's tap or drag is off - gest_.two = true, gest_.mid = mid, gest_.spread = spread; - boxing_ = lassoing_ = touchOrbit_ = tapDraw_ = false; - io.AddMouseButtonEvent(ImGuiMouseButton_Left, false); - } - if (gest_.two) { - panBy(ImVec2(mid.x - gest_.mid.x, mid.y - gest_.mid.y)); - dist_ = std::clamp(dist_ * gest_.spread / spread, 0.01f, 5000.0f); - gest_.mid = mid, gest_.spread = spread; - } - gest_.downAt = -1; - return; - } - if (fingers.empty()) gest_.two = false; - if (fingers.size() != 1 || gest_.two) return void(gest_.downAt = -1); - // One finger held still: a right-click there (menus in the panels, orbit in the view). - const ImVec2 p = fingers[0]; - if (gest_.downAt < 0) gest_.downAt = now(), gest_.downPos = p, gest_.longDone = false; - if (std::hypot(p.x - gest_.downPos.x, p.y - gest_.downPos.y) > 12) gest_.downAt = now() + 1e9; // (moved: not a long press) - if (!gest_.longDone && now() - gest_.downAt > 0.55 && !drag_.active) { - gest_.longDone = true; - boxing_ = lassoing_ = touchOrbit_ = tapDraw_ = false; - io.AddMouseButtonEvent(ImGuiMouseButton_Left, false); - io.AddMouseButtonEvent(ImGuiMouseButton_Right, true); - gest_.rightUp = true; - } -} - -// The bar along the bottom of the view: big buttons, in rows if the view's narrow. -auto App::touchBar() -> void { - const float maxW = std::max(viewMax_.x - viewMin_.x - 16, 120.0f); - ImGui::SetNextWindowPos(ImVec2((viewMin_.x + viewMax_.x) * 0.5f, viewMax_.y - 6), ImGuiCond_Always, ImVec2(0.5f, 1.0f)); - ImGui::SetNextWindowBgAlpha(0.8f); - ImGui::Begin("##touchbar", nullptr, ImGuiWindowFlags_NoDecoration | ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoSavedSettings | ImGuiWindowFlags_NoFocusOnAppearing | - ImGuiWindowFlags_NoNav | ImGuiWindowFlags_NoDocking); - if (now() - messageAt_ < 4.0) ImGui::PushTextWrapPos(maxW), ImGui::TextColored(accentText(), "%s", message_.c_str()), ImGui::PopTextWrapPos(); - ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2(12, 8)); - const ImGuiStyle& st = ImGui::GetStyle(); - float x = 0; // (where the row's got to) - auto button = [&](const char* label, bool on = false, const char* tip = nullptr) { - const float w = ImGui::CalcTextSize(label).x + st.FramePadding.x * 2; - if (x > 0 && x + st.ItemSpacing.x + w <= maxW) ImGui::SameLine(), x += st.ItemSpacing.x; - else x = 0; - x += w; - if (on) pushAccent(); - const bool pressed = ImGui::Button(label); - if (on) popAccent(); - if (tip && ImGui::IsItemHovered()) ImGui::SetTooltip("%s", tip); - return pressed; - }; - if (button("Undo")) undo(); - if (button("Redo")) redo(); - if (button("Shift", shiftLatch_, "Held down until tapped again: adds to the selection, picks loops, scales both sides")) shiftLatch_ = !shiftLatch_; - if (button("Ctrl", ctrlLatch_, "Held down until tapped again: takes from the selection, snaps")) ctrlLatch_ = !ctrlLatch_; - if (tab_ == Tab::Model) { - if (button(mode_ == Mode::Edit ? "Done" : "Edit shape", mode_ == Mode::Edit)) mode_ == Mode::Edit ? leaveEdit() : enterEdit(); - if (button("Delete")) deleteSelected(); - } - if (button("Frame")) frameSelected(); - if (button("Views", viewsOpen_)) viewsOpen_ = !viewsOpen_; - if (button("Panels", panelsOpen_, "Layers, Properties (and the Picture when painting)")) panelsOpen_ = !panelsOpen_; - ImGui::PopStyleVar(); - ImGui::End(); -} - -// ───────────────────────────── the camera ───────────────────────────── - -// metres across the view's height at the target -auto App::setView(View v) -> void { - const float size = viewHeight(); - view_ = v; - switch (v) { - case View::Front: yaw_ = 180, pitch_ = 0; break; // from +X, looking back at the front - case View::Back: yaw_ = 0, pitch_ = 0; break; - case View::Left: yaw_ = -90, pitch_ = 0; break; // from +Y (the model's left side) - case View::Right: yaw_ = 90, pitch_ = 0; break; - case View::Top: yaw_ = 0, pitch_ = 89.9f; break; // front up the screen - case View::Bottom: yaw_ = 0, pitch_ = -89.9f; break; - default: break; - } - // Side views: nearly flat (a narrow lens from far away), the same size on screen. - fov_ = v == View::Perspective ? 50.0f : 2.0f; - dist_ = size / (2.0f * std::tan(glm::radians(fov_) * 0.5f)); -} - -auto App::leaveFlatView() -> void { - if (view_ == View::Perspective) return; - const float size = viewHeight(); - view_ = View::Perspective, fov_ = 50.0f; - dist_ = size / (2.0f * std::tan(glm::radians(fov_) * 0.5f)); -} - -auto App::frameBox(glm::vec3 lo, glm::vec3 hi) -> void { - if (lo.x > hi.x) return; - target_ = (lo + hi) * 0.5f; - const float radius = std::max(glm::length(hi - lo) * 0.5f, 0.05f); - dist_ = radius * 1.4f / std::tan(glm::radians(fov_) * 0.5f); -} - -auto App::frameAll() -> void { - glm::vec3 lo(1e30f), hi(-1e30f); - for (const Object& o : scene_.objects) { - if (!o.visible) continue; - const glm::mat4 m = objectMatrix(o); - for (const glm::vec3& v : o.mesh.verts) { - const glm::vec3 w(m * glm::vec4(v, 1.0f)); - lo = glm::min(lo, w), hi = glm::max(hi, w); - } - } - frameBox(lo, hi); -} - -auto App::frameSelected() -> void { - glm::vec3 lo(1e30f), hi(-1e30f); - for (const glm::vec3& w : selectionPoints()) lo = glm::min(lo, w), hi = glm::max(hi, w); - if (lo.x > hi.x) return frameAll(); - frameBox(lo, hi); -} - -auto App::mouseInView(const ImGuiIO& io) const -> bool { - const ImVec2 m = io.MousePos; - return m.x >= viewMin_.x && m.y >= viewMin_.y && m.x < viewMax_.x && m.y < viewMax_.y && !io.WantCaptureMouse; -} - -auto App::orbitBy(ImVec2 d) -> void { - if (std::abs(d.x) + std::abs(d.y) > 0.5f) leaveFlatView(); - yaw_ -= d.x * 0.3f; - pitch_ = std::clamp(pitch_ + d.y * 0.3f, -89.9f, 89.9f); -} - -// Turning the camera where it stands (right drag): what it looks at moves, the eye doesn't. -auto App::lookBy(ImVec2 d) -> void { - if (std::abs(d.x) + std::abs(d.y) > 0.5f) leaveFlatView(); - const glm::vec3 eye = target_ - viewDir() * dist_; - yaw_ -= d.x * 0.2f; - pitch_ = std::clamp(pitch_ + d.y * 0.2f, -89.9f, 89.9f); - target_ = eye + viewDir() * dist_; -} - -// What the camera looks at now: the object or the ground straight ahead (not further than the -// scene reaches). The point it turns round, how fast it moves and how near it draws follow it. -auto App::refocus() -> void { - const glm::vec3 f = viewDir(), eye = target_ - f * dist_; - const Ray ray{eye, f}; - float t = -1.0f; - if (const Hit h = pickObject(ray); h.object) t = h.t; - else if (auto g = hitPlane(ray, glm::vec3(0.0f), glm::vec3(0, 0, 1))) t = std::min(glm::distance(eye, *g), std::max(glm::length(eye) * 2.0f, 50.0f)); - if (t > 0.0f) dist_ = std::clamp(t, 0.01f, 5000.0f), target_ = eye + f * dist_; -} - -// How far an object's mesh reaches (worked out again when its shape changes). -auto App::reachOf(const Object& o) -> const Reach& { - Reach& r = radii_[o.id]; - if (r.version == shapeKey(o) && r.version) return r; - r = {shapeKey(o)}; - if (!o.mesh.verts.empty()) r.lo = boundsMin(o.mesh), r.hi = boundsMax(o.mesh); - for (int a = 0; a < 3; ++a) - if (o.mirror & (1 << a)) r.lo[a] = std::min(r.lo[a], -r.hi[a]), r.hi[a] = std::max(r.hi[a], -r.lo[a]); - for (const glm::vec3& v : o.mesh.verts) r.radius = std::max(r.radius, glm::length(v)); - return r; -} - -// How far the nearest thing (an object, the ground) is from the eye: the near plane and the -// shade in corners go by it, so looking past something close doesn't break them. To each -// object's box (not a ball round it: inside a long flat one's ball, it would come to nothing). -auto App::nearestThing() -> float { - float best = std::abs(eye_.z); // (the ground) - for (const Object& o : scene_.objects) { - if (!o.visible) continue; - const Reach& r = reachOf(o); - const glm::mat4 m = objectMatrix(o); - const glm::vec3 local = glm::inverse(m) * glm::vec4(eye_, 1.0f); - best = std::min(best, glm::distance(eye_, glm::vec3(m * glm::vec4(glm::clamp(local, r.lo, r.hi), 1.0f)))); - } - for (const Reference& ref : scene_.references) { - if (!ref.visible || (ref.ownViewOnly && !lookingAt(ref.axis))) continue; - const Picture& pic = picture(ref); - if (!pic.ok) continue; - const auto c = referenceCorners(ref, pic.aspect); - const glm::vec3 u = c[1] - c[0], v = c[3] - c[0], d = eye_ - c[0]; - const float s = std::clamp(glm::dot(d, u) / std::max(glm::dot(u, u), 1e-12f), 0.0f, 1.0f), t = std::clamp(glm::dot(d, v) / std::max(glm::dot(v, v), 1e-12f), 0.0f, 1.0f); - best = std::min(best, glm::distance(eye_, c[0] + u * s + v * t)); - } - return std::max(best, 0.001f); -} - -auto App::panBy(ImVec2 d) -> void { - 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))); - const glm::vec3 up = glm::cross(right, f); - const float perPixel = viewHeight() / std::max(ImGui::GetIO().DisplaySize.y, 1.0f); - target_ += (-right * d.x + up * d.y) * perPixel * fry_movespeed.f(); -} - -auto App::updateCamera(ImGuiIO& io) -> void { - const bool inView = mouseInView(io); - // Orbit: right drag. Pan: middle drag (or Shift + right). Zoom: the wheel. - const bool rightDown = ImGui::IsMouseDown(ImGuiMouseButton_Right), midDown = ImGui::IsMouseDown(ImGuiMouseButton_Middle); - // Right drag turns the camera where it stands (as in games, Unreal, Unity); Alt + right drag - // turns it round what's picked (or what it looks at). A finger on a phone: round, still. - const bool orbitCamera = fry_camera.i() == 1; // (View > Camera: round what you look at, as in Blender) - if (inView && ImGui::IsMouseClicked(ImGuiMouseButton_Right) && !io.KeyShift) { - if (io.KeyAlt || touch() || orbitCamera) { - orbiting_ = true; - if (!selected_.empty() && !touch() && !orbitCamera) { // (round the middle of what's picked) - glm::vec3 lo(1e30f), hi(-1e30f); - for (const Object& o : scene_.objects) - if (selected_.count(o.id)) lo = glm::min(lo, o.position), hi = glm::max(hi, o.position); - const glm::vec3 eye = target_ - viewDir() * dist_, pivot = (lo + hi) * 0.5f; - if (glm::distance(eye, pivot) > 1e-4f) { - const glm::vec3 f = glm::normalize(pivot - eye); - yaw_ = glm::degrees(std::atan2(f.y, f.x)), pitch_ = glm::degrees(std::asin(std::clamp(-f.z, -1.0f, 1.0f))); - target_ = pivot, dist_ = glm::distance(eye, pivot); - } - } - } else looking_ = true; - } - if (inView && (ImGui::IsMouseClicked(ImGuiMouseButton_Middle) || (io.KeyShift && ImGui::IsMouseClicked(ImGuiMouseButton_Right)))) panning_ = true, orbiting_ = looking_ = false; - if (!rightDown) { - if (looking_) refocus(); - orbiting_ = looking_ = false; - } - if (!midDown && !rightDown) panning_ = false; - const ImVec2 d = io.MouseDelta; - if (orbiting_ && (d.x != 0 || d.y != 0)) orbitBy(d); - if (looking_ && (d.x != 0 || d.y != 0)) lookBy(d); - if (panning_ && (d.x != 0 || d.y != 0)) panBy(d); - if (inView && io.MouseWheel != 0 && drag_.active && softSel_ && mode_ == Mode::Edit) { // the soft selection's reach, not zoom - softReach_ = std::clamp(softReach_ * std::pow(1.15f, io.MouseWheel), 0.001f, 1000.0f); - findSoft(); - message_ = std::format("Soft selection reaches {:.3f} m (wheel)", softReach_), messageAt_ = now(); - } else if (inView && io.MouseWheel != 0 && io.KeyShift) { // Shift + wheel: how fast every way of moving goes - const float speed = std::clamp(fry_movespeed.f() * std::pow(1.25f, io.MouseWheel), 0.05f, 20.0f); - keel::execute(std::format("fry_movespeed {:.3g}", speed)); - message_ = std::format("Moving speed: x{:.2g} (Shift + wheel)", speed), messageAt_ = now(); - } else if (inView && io.MouseWheel != 0) { - // The wheel moves the camera straight forward or back (where it looks), a fifth of the - // way to what's ahead each notch, but never less than a little of the scene's size: so it - // doesn't creep to a stop in front of a surface (it goes through). In a flat view: closer. - // The orbiting camera: closer to (or further from) what it turns round. - if (view_ != View::Perspective || orbitCamera) dist_ = std::clamp(dist_ * std::pow(0.85f, io.MouseWheel), 0.01f, 5000.0f); - else { - float size = 0.0f; // (the biggest object's size: the least step is a little of it) - for (const Object& o : scene_.objects) - size = std::max(size, reachOf(o).radius * std::max({std::abs(o.scale.x), std::abs(o.scale.y), std::abs(o.scale.z)})); - // (what's ahead: no further than ten times how far the nearest thing is, so with only - // the sky or the far ground ahead a notch doesn't leap) - const float step = std::clamp(std::max(std::min(dist_, nearest_ * 10.0f) * 0.2f, size * 0.05f), 0.001f, 500.0f) * fry_movespeed.f(); - target_ += viewDir() * step * io.MouseWheel; - refocus(); - } - } - // Moving: the arrow keys any time, and WASD (Q / E down and up) while the right button turns - // the camera; forward is where it looks (in a flat view: up the screen). Page Up / Down: up - // and down. Shift: faster. As fast as what's near is far. - if (!io.WantTextInput && !search_.open && !ImGui::IsAnyItemActive() && !io.KeyCtrl && !io.KeyAlt) { - auto down = [](ImGuiKey k) { return float(ImGui::IsKeyDown(k)); }; - const float wasd = looking_ ? 1.0f : 0.0f; - const float x = down(ImGuiKey_RightArrow) - down(ImGuiKey_LeftArrow) + wasd * (down(ImGuiKey_D) - down(ImGuiKey_A)); - const float y = down(ImGuiKey_UpArrow) - down(ImGuiKey_DownArrow) + wasd * (down(ImGuiKey_W) - down(ImGuiKey_S)); - const float z = down(ImGuiKey_PageUp) - down(ImGuiKey_PageDown) + wasd * (down(ImGuiKey_E) - down(ImGuiKey_Q)); - if (x != 0 || y != 0 || z != 0) { - 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))); - const glm::vec3 ahead = view_ == View::Perspective ? f : glm::cross(right, f); - const float dt = std::min(io.DeltaTime, 0.1f), speed = std::max(viewScale() * 1.5f, 0.3f) * fry_movespeed.f() * (io.KeyShift ? 3.0f : 1.0f) * dt; - target_ += (right * x + ahead * y + (view_ == View::Perspective ? glm::vec3(0, 0, z) : glm::vec3(0.0f))) * speed; - if (view_ != View::Perspective && z != 0) dist_ = std::clamp(dist_ * std::pow(io.KeyShift ? 0.1f : 0.4f, z * dt), 0.01f, 5000.0f); // (flat: closer or further) - if (view_ == View::Perspective && !looking_) refocus(); // (while turning: when the button's let go) - } - } - settleView(); -} - -// Where the eye is and how the view maps to the screen, from the camera as it is now: again -// just before drawing, so the model and the lines over it agree (a touch can turn it between). -auto App::settleView() -> void { - const ImGuiIO& io = ImGui::GetIO(); - const float aspect = io.DisplaySize.x / std::max(io.DisplaySize.y, 1.0f); - shift_ = glm::vec2((viewMin_.x + viewMax_.x) / std::max(io.DisplaySize.x, 1.0f) - 1.0f, (viewMin_.y + viewMax_.y) / std::max(io.DisplaySize.y, 1.0f) - 1.0f); - eye_ = target_ - viewDir() * dist_; - nearest_ = nearestThing(); - nearFar_ = nearFar(); - vp_ = viewProjection(eye_, yaw_, pitch_, fov_, aspect, shift_, nearFar_); -} - -// How near and far the view draws (units), going by how far away it looks: close up to a -// 5 mm part, or out to see a whole town, the same share of the depth's precision. -auto App::nearFar() -> glm::vec2 { - float reach = 1.0f; // (how far from the middle the furthest object reaches, m) - for (const Object& o : scene_.objects) { - reach = std::max(reach, glm::length(o.position) + reachOf(o).radius * std::max({std::abs(o.scale.x), std::abs(o.scale.y), std::abs(o.scale.z)})); - } - // (the near plane as far out as it can be: the depth's precision is spread between them, so - // the nearer it is, the more surfaces flicker through each other. Nothing is nearer than the - // nearest thing, and a quarter of the way to it keeps even the screen's corners clear of it) - const float nearM = std::clamp(std::max(viewScale() * 0.005f, nearest_ * 0.25f), 1e-5f, 50.0f); - // (far enough for the whole scene and the ground out to near the horizon; what it looks at - // too when it turns round that (flying, that jumps between the ground and the sky as it moves)) - const bool flying = view_ == View::Perspective && fry_camera.i() != 1; - const float farM = std::max({flying ? 0.0f : dist_ * 60.0f, (glm::length(eye_) + reach) * 2.0f, std::abs(eye_.z) * 2000.0f, 100.0f}); - return glm::vec2(nearM, farM) * kUnits; -} - -auto App::project(const glm::vec3& p, ImVec2& out) const -> bool { - const glm::vec4 c = vp_ * glm::vec4(p, 1.0f); - if (c.w < 1e-4f) return false; - const ImVec2 size = ImGui::GetIO().DisplaySize; - out = ImVec2((c.x / c.w * 0.5f + 0.5f) * size.x, (c.y / c.w * 0.5f + 0.5f) * size.y); - return true; -} - -auto App::mouseRay(ImVec2 m) const -> Ray { - const ImVec2 size = ImGui::GetIO().DisplaySize; - const glm::vec2 ndc(m.x / size.x * 2.0f - 1.0f, m.y / size.y * 2.0f - 1.0f); - const glm::mat4 inv = glm::inverse(vp_); - glm::vec4 a = inv * glm::vec4(ndc, 0.0f, 1.0f), b = inv * glm::vec4(ndc, 1.0f, 1.0f); - a /= a.w, b /= b.w; - return {glm::vec3(a), glm::normalize(glm::vec3(b - a))}; -} - -// How many metres a pixel is, at a point. -auto App::pixelSize(const glm::vec3& at) const -> float { - const float d = std::max(glm::dot(at - eye_, viewDir()), 0.001f); - return 2.0f * d * std::tan(glm::radians(fov_) * 0.5f) / std::max(ImGui::GetIO().DisplaySize.y, 1.0f); -} - -// ───────────────────────────── what's shown ───────────────────────────── - -// The mesh as seen: with its rounding, smoothing and mirror — but while its shape is being -// edited, only the mirror (the rest would hide the points you're moving). -// Editable cuts show (but not while its own shape's being edited: the points you move). -// A big one (smoothed into many faces, cut) is made on another thread: what was shown stays -// till it's ready, so the window doesn't stop. -auto App::shownMesh(const Object& o) -> const Mesh& { - const bool editingThis = mode_ == Mode::Edit && o.id == active_; - const bool cut = !editingThis && cutKeys_.count(o.id); - if ((!o.hasFinish() && !cut) || (editingThis && !o.mirror)) return o.mesh; - Shown& s = shown_[o.id]; - const uint64_t v = shapeKey(o); - if (s.coming.valid() && s.coming.wait_for(std::chrono::seconds(0)) == std::future_status::ready) { - auto [got, tree] = s.coming.get(); - if (s.comingVersion == v && s.comingMirror == editingThis) s.mesh = std::move(got), s.tree = std::move(tree), s.version = v, s.mirrorOnly = editingThis; - } - if (s.version == v && v && s.mirrorOnly == editingThis) return s.mesh; - // (what it'll come to: its faces four times over for each level of smoothing, more for each - // step of rounding; past a few thousand it's made on another thread, so dragging a setting - // in Properties doesn't hold up every frame) - const size_t rounded = !editingThis && o.round > 0.0f ? size_t(std::max(o.roundSteps, 1)) + 1 : 1; - const size_t faces = o.mesh.faces.size() * rounded * (size_t(1) << (2 * std::clamp(editingThis ? 0 : o.smoothLevels, 0, 6))) * (o.mirror ? 2 : 1); - const bool big = faces > (editingThis ? 40000u : 6000u) || (cut && o.mesh.faces.size() > 2000); // (the mirror while editing: with each drag, so only when huge) - if (!big || s.mesh.verts.empty()) { // (small: now; the first time: now too, there's nothing to show yet) - s.mesh = cut ? fry::cutMesh(scene_, o) : finishedMesh(o, editingThis), s.tree.reset(), s.version = v, s.mirrorOnly = editingThis; - return s.mesh; - } - if (!s.coming.valid()) { // (one at a time for each object: while one's being made, the newest wanted waits for it to land) - Scene part; // (just it and what cuts it: the thread has its own copies) - part.objects.push_back(o); - if (cut) - for (const Object& c : scene_.objects) - if (c.cuts == o.id && c.id != o.id) part.objects.push_back(c); - s.comingVersion = v, s.comingMirror = editingThis; - s.coming = std::async(std::launch::async, [part = std::move(part), cut, editingThis] { - Mesh m = cut ? fry::cutMesh(part, part.objects[0]) : finishedMesh(part.objects[0], editingThis); - auto tree = std::make_shared(m, glm::mat4(1.0f)); // (its picking tree too, while it's here) - return std::pair{std::move(m), std::move(tree)}; - }); - message_ = "Working it out...", messageAt_ = now(); - } - return s.mesh; -} - -// The key of the mesh actually shown (the one before, while a new one's being made). -auto App::meshKey(const Object& o, const Mesh& mesh) -> uint64_t { - if (&mesh == &o.mesh) return shapeKey(o); - auto it = shown_.find(o.id); - return it != shown_.end() && &it->second.mesh == &mesh ? it->second.version * 2 + it->second.mirrorOnly : shapeKey(o); -} - -// (where each cut object and cutter was last frame) -auto App::followCuts() -> void { - // What's cut moved, turned or sized (not by a drag: that takes them itself), and its cutter - // didn't: the cutter goes the same way, so the hole stays where it was on it. - std::map at; - for (Object& c : scene_.objects) { - const Object* t = fry::cutTarget(scene_, c); - if (!t) continue; - const glm::mat4 mt = objectMatrix(*t); - const auto was = followAt_.find(t->id), cWas = followAt_.find(c.id); - if (!drag_.active && was != followAt_.end() && cWas != followAt_.end() && was->second != mt && cWas->second == objectMatrix(c) && !c.locked) { - const glm::mat4 m = mt * glm::inverse(was->second) * objectMatrix(c); - glm::vec3 skew; - glm::vec4 persp; - glm::vec3 scale, pos; - glm::quat rot; - if (glm::decompose(m, scale, rot, pos, skew, persp)) c.position = pos, c.rotation = glm::normalize(rot), c.scale = scale, touched(c.id); - } - at[t->id] = mt, at[c.id] = objectMatrix(c); - } - followAt_ = std::move(at); - std::map now; - for (const Object& c : scene_.objects) { - const Object* t = fry::cutTarget(scene_, c); - if (!t || c.mesh.faces.empty()) continue; - uint64_t& key = now.emplace(t->id, 1469598103934665603ull).first->second; - key = (key ^ versions_[c.id] ^ (uint64_t(c.id) << 40)) * 1099511628211ull; - const glm::mat4 m = objectMatrix(c); - for (int i = 0; i < 16; ++i) { - uint32_t b; - std::memcpy(&b, &m[i / 4][i % 4], 4); - key = (key ^ b) * 1099511628211ull; - } - } - for (const auto& [id, key] : now) - if (auto it = cutKeys_.find(id); it == cutKeys_.end() || it->second != key) versions_[id] = ++versionClock_; - for (const auto& [id, key] : cutKeys_) - if (!now.count(id)) versions_[id] = ++versionClock_; - cutKeys_ = std::move(now); -} - -// Editable cut: the last picked has the others cut out of it, and they stay objects to move. -auto App::cutEditable() -> void { - if (mode_ == Mode::Edit) leaveEdit(); - Object* a = find(active_); - if (!a || selected_.size() < 2) return say("Pick the cutters, then (Shift) what they cut last: they stay, to move and change, and the hole follows"); - pushUndo(); - int n = 0; - for (Object& o : scene_.objects) - if (selected_.count(o.id) && o.id != a->id) o.cuts = a->id, ++n; - for (Object& o : scene_.objects) // (one that was cutting the cutters: not a loop of cuts) - if (o.id == a->id && o.cuts && selected_.count(o.cuts)) o.cuts = 0; - selected_.erase(a->id), active_ = *selected_.rbegin(); - dirty_ = true; - say(std::format("{} cut out of {} (editable: move or change {}; Apply makes it for good)", n == 1 ? "1 object" : std::format("{} objects", n), find(a->id)->name, n == 1 ? "it" : "them")); -} - -// The cut made for good: one cutter (or all of what's cut out of it) into the shape, gone. -auto App::applyCuts(uint32_t target, uint32_t only) -> void { - Object* t = find(target); - if (!t) return; - pushUndo(); - Scene part = scene_; // (just the cutters wanted) - for (Object& o : part.objects) - if (o.cuts == target && only && o.id != only) o.cuts = 0; - Object* pt = &*std::find_if(part.objects.begin(), part.objects.end(), [&](const Object& o) { return o.id == target; }); - t->mesh = fry::cutMesh(part, *pt); - t->mirror = 0, t->round = 0, t->smoothLevels = 0, t->creases.clear(); - std::set gone; - for (const Object& o : scene_.objects) - if (o.cuts == target && (!only || o.id == only)) gone.insert(o.id); - std::erase_if(scene_.objects, [&](const Object& o) { return gone.count(o.id) > 0; }); - for (uint32_t id : gone) selected_.erase(id); - selected_.insert(target), active_ = target; - changedShape(*find(target)); - say(std::format("Cut made for good in {} ({} faces)", find(target)->name, find(target)->mesh.faces.size())); -} - -// ───────────────────────────── picking ───────────────────────────── - -// base: against its own mesh (face numbers of that), not the rounded or smoothed one shown. -auto App::pickObject(const Ray& ray, uint32_t only, bool base) -> Hit { - Hit best; - for (const Object& o : scene_.objects) { - if (!o.visible || (only && o.id != only)) continue; - const Mesh& mesh = base || (mode_ == Mode::Edit && o.id == active_) ? o.mesh : shownMesh(o); - // (the ray into the object's own space, where its tree is: t is the same there) - const glm::mat4 m = objectMatrix(o); - if (std::abs(glm::determinant(m)) < 1e-12f) continue; // (squashed flat to nothing) - const glm::mat4 inv = glm::inverse(m); - const glm::vec3 from(inv * glm::vec4(ray.origin, 1.0f)), dir(inv * glm::vec4(ray.dir, 0.0f)); - if (auto h = treeOf(o, mesh).nearest(from, dir, best.t)) best = {o.id, h->face, h->t}; - } - return best; -} - -// (an object's own mesh, or the one shown) -auto App::treeOf(const Object& o, const Mesh& mesh) -> const fry::TriangleTree& { - if (auto s = shown_.find(o.id); s != shown_.end() && &s->second.mesh == &mesh && s->second.tree) return *s->second.tree; // (made with it) - Tree& t = trees_[{o.id, &mesh != &o.mesh}]; - const uint64_t version = meshKey(o, mesh); - if (!t.tree || t.version != version || !version || t.faces != mesh.faces.size() || t.tree->points().size() != mesh.verts.size()) - t.version = version, t.faces = mesh.faces.size(), t.tree = std::make_shared(mesh, glm::mat4(1.0f)); - return *t.tree; -} - -// Can the eye see this point of the object (nothing of it in the way)? -auto App::visible(const Object& o, const glm::vec3& world) -> bool { - if (xray_) return true; - const glm::vec3 d = world - eye_; - const float len = glm::length(d); - const Hit h = pickObject({eye_, d / len}, o.id); - return h.t > len - std::max(len * 0.002f, 0.0005f); -} - -// (an object's own mesh, or the one shown) -auto App::seen(const Object& o, const Mesh& mesh, int i) -> bool { - Visibility& v = visibility_[{o.id, &mesh != &o.mesh}]; - const uint64_t version = versions_[o.id]; - const glm::mat4 m = objectMatrix(o); - if (v.version != version || glm::distance(v.eye, eye_) >= 1e-6f || v.vp != vp_ || v.matrix != m || v.point.size() != mesh.verts.size()) { - v.version = version, v.eye = eye_, v.vp = vp_, v.matrix = m, v.point.assign(mesh.verts.size(), 0); - v.eyeIn = std::abs(glm::determinant(m)) < 1e-12f ? eye_ : glm::vec3(glm::inverse(m) * glm::vec4(eye_, 1.0f)); - } - char& got = v.point[size_t(i)]; - if (got) return got == 1; - const fry::TriangleTree& tree = treeOf(o, mesh); - const glm::vec3 p = tree.points()[size_t(i)], w(m * glm::vec4(p, 1.0f)); - const glm::vec4 c = vp_ * glm::vec4(w, 1.0f); - const float len = glm::length(w - eye_); - if (c.w < 1e-5f || std::abs(c.x) > c.w || std::abs(c.y) > c.w || len < 1e-6f) return (got = 1); // (off the screen: not drawn anyway) - // (from the eye to the point in the object's own space: 1 is the point, a hair short of it in the world) - got = tree.blocked(v.eyeIn, p - v.eyeIn, 1.0f - std::max(0.002f, 0.0005f / len), i) ? 2 : 1; - return got == 1; -} - -auto App::adjacencyOf(const Object& o, const Mesh& mesh) -> const Adjacency& { - Adjacency& a = adjacency_[{o.id, &mesh != &o.mesh}]; - const uint64_t version = meshKey(o, mesh); - if (a.version == version && version && a.verts == mesh.verts.size() && a.faces == mesh.faces.size()) return a; - a.version = version, a.verts = mesh.verts.size(), a.faces = mesh.faces.size(); - a.edges.clear(), a.plane.resize(mesh.faces.size()); - std::unordered_map at; - at.reserve(mesh.faces.size() * 2); - for (size_t f = 0; f < mesh.faces.size(); ++f) { - const auto& fv = mesh.faces[f]; - const glm::vec3 n = faceNormal(mesh, int(f)); - a.plane[f] = glm::vec4(n, fv.empty() ? 0.0f : glm::dot(n, mesh.verts[size_t(fv[0])])); - for (size_t i = 0; i < fv.size(); ++i) { - const int p = std::min(fv[i], fv[(i + 1) % fv.size()]), q = std::max(fv[i], fv[(i + 1) % fv.size()]); - auto [it, fresh] = at.try_emplace(uint64_t(uint32_t(p)) << 32 | uint32_t(q), int(a.edges.size())); - if (fresh) a.edges.push_back({p, q, int(f), -1}); - else if (a.edges[size_t(it->second)][3] < 0) a.edges[size_t(it->second)][3] = int(f); - } - } - return a; -} - -// The edge of the edited object nearest the mouse on screen (that can be seen), or (-1, -1). -auto App::nearestEdge(Object& o, ImVec2 mouse, float within) -> std::pair { - const glm::mat4 m = objectMatrix(o); - std::pair best{-1, -1}; - for (const auto& e : adjacencyOf(o, o.mesh).edges) { - const int a = e[0], b = e[1]; - ImVec2 sa, sb; - if (!project(glm::vec3(m * glm::vec4(o.mesh.verts[size_t(a)], 1.0f)), sa) || !project(glm::vec3(m * glm::vec4(o.mesh.verts[size_t(b)], 1.0f)), sb)) continue; - const float d = distToSegment(mouse, sa, sb); - if (d < within && (pointSeen(o, a) || pointSeen(o, b))) within = d, best = {a, b}; - } - return best; -} - -auto App::clickSelect(const ImGuiIO& io) -> void { - const Ray ray = mouseRay(io.MousePos); - bool add = io.KeyShift, remove = io.KeyCtrl; - if (Object* o = editing()) { - // Shapes: Shift picks the whole loop (Shift+Ctrl on an edge: the ring across it), Ctrl - // adds or takes away one. - if (io.KeyShift) return loopSelect(io, io.KeyCtrl); - add = io.KeyCtrl, remove = false; - const glm::mat4 m = objectMatrix(*o); - auto toggle = [&](auto& set, auto key) { - if (remove) set.erase(key); - else if (add && set.count(key)) set.erase(key); - else set.insert(key); - }; - if (!add && !remove) clearElements(); - if (elem_ == Elem::Vertex) { - int best = -1; - float bestD = 14.0f; - for (size_t v = 0; v < o->mesh.verts.size(); ++v) { - ImVec2 s; - if (!project(glm::vec3(m * glm::vec4(o->mesh.verts[v], 1.0f)), s)) continue; - const float d = std::hypot(s.x - io.MousePos.x, s.y - io.MousePos.y); - if (d < bestD && pointSeen(*o, int(v))) bestD = d, best = int(v); - } - if (best >= 0) toggle(selVerts_, best); - } else if (elem_ == Elem::Edge) { - const auto e = nearestEdge(*o, io.MousePos, 10.0f); - if (e.first >= 0) toggle(selEdges_, e); - } else { - const Hit h = pickObject(ray, o->id); - if (h.face >= 0) toggle(selFaces_, h.face); - } - return; - } - const Hit h = pickObject(ray); - if (!add && !remove) selected_.clear(); - if (h.object) { - if (remove || (add && selected_.count(h.object))) selected_.erase(h.object); - else selected_.insert(h.object), active_ = h.object; - activeRef_ = 0; - } -} - -// A whole loop: of edges (points), or a ring of faces, through the edge under the mouse. -auto App::loopSelect(const ImGuiIO& io, bool ring) -> void { - Object* o = editing(); - if (!o) return; - if (!io.KeyShift) clearElements(); - if (elem_ == Elem::Face) { - const Hit h = pickObject(mouseRay(io.MousePos), o->id); - if (h.face < 0) return; - // The face's edge nearest the mouse: the ring runs across it. - const auto& fv = o->mesh.faces[size_t(h.face)]; - const glm::mat4 m = objectMatrix(*o); - std::pair best{-1, -1}; - float bestD = 1e30f; - for (size_t i = 0; i < fv.size(); ++i) { - ImVec2 sa, sb; - if (project(glm::vec3(m * glm::vec4(o->mesh.verts[size_t(fv[i])], 1.0f)), sa) && - project(glm::vec3(m * glm::vec4(o->mesh.verts[size_t(fv[(i + 1) % fv.size()])], 1.0f)), sb)) - if (const float d = distToSegment(io.MousePos, sa, sb); d < bestD) bestD = d, best = {fv[i], fv[(i + 1) % fv.size()]}; - } - if (best.first < 0) return; - const auto ring = faceRing(o->mesh, best); - selFaces_.insert(ring.begin(), ring.end()); - if (ring.empty()) selFaces_.insert(h.face); - return; - } - const auto e = nearestEdge(*o, io.MousePos, 14.0f); - if (e.first < 0) return; - const auto loop = ring ? edgeRing(o->mesh, e) : edgeLoop(o->mesh, e); - if (ring) say(std::format("A ring of {} edges", loop.size())); - if (elem_ == Elem::Edge) selEdges_.insert(loop.begin(), loop.end()); - else - for (auto [a, b] : loop) selVerts_.insert(a), selVerts_.insert(b); -} - -// Selects what's on screen inside a shape (a box, a lasso, the brush). -auto App::regionSelect(const std::function& inside, bool add, bool remove, bool clearFirst) -> void { - auto in = [&](const glm::vec3& w) { - ImVec2 s; - return project(w, s) && inside(s); - }; - if (Object* o = editing()) { - if (clearFirst) clearElements(); - const glm::mat4 m = objectMatrix(*o); - std::vector hit(o->mesh.verts.size()); - for (size_t v = 0; v < hit.size(); ++v) hit[v] = in(glm::vec3(m * glm::vec4(o->mesh.verts[v], 1.0f))) && pointSeen(*o, int(v)); - auto apply = [&](auto& set, auto key, bool yes) { - if (!yes) return; - if (remove) set.erase(key); - else set.insert(key); - }; - if (elem_ == Elem::Vertex) - for (size_t v = 0; v < hit.size(); ++v) apply(selVerts_, int(v), bool(hit[v])); - else if (elem_ == Elem::Edge) - for (auto e : edges(o->mesh)) { - const glm::vec3 mid(m * glm::vec4((o->mesh.verts[size_t(e.first)] + o->mesh.verts[size_t(e.second)]) * 0.5f, 1.0f)); - apply(selEdges_, e, (hit[size_t(e.first)] && hit[size_t(e.second)]) || (in(mid) && pointSeen(*o, e.first) && pointSeen(*o, e.second))); - } - else - for (size_t f = 0; f < o->mesh.faces.size(); ++f) { - const glm::vec3 c(m * glm::vec4(faceCenter(o->mesh, int(f)), 1.0f)); - if (!in(c)) continue; - const glm::vec3 n = glm::transpose(glm::inverse(glm::mat3(m))) * faceNormal(o->mesh, int(f)); - apply(selFaces_, int(f), xray_ || (glm::dot(n, eye_ - c) > 0 && visible(*o, c))); - } - return; - } - if (clearFirst) selected_.clear(); - (void)add; - for (const Object& o : scene_.objects) { - if (!o.visible) continue; - const glm::vec3 c(objectMatrix(o) * glm::vec4((boundsMin(o.mesh) + boundsMax(o.mesh)) * 0.5f, 1.0f)); - if (!in(c)) continue; - if (remove) selected_.erase(o.id); - else selected_.insert(o.id), active_ = o.id; - } -} - -// ───────────────────────────── the gizmo ───────────────────────────── - -auto App::hasSelection() -> bool { - if (mode_ == Mode::Edit) return !selectedPoints().empty(); - return std::any_of(selected_.begin(), selected_.end(), [&](uint32_t id) { const Object* o = find(id); return o && !o->locked; }); -} - -// Every point of the selection, in the world. -auto App::selectionPoints() -> std::vector { - std::vector out; - if (Object* o = editing()) { - const glm::mat4 m = objectMatrix(*o); - for (int v : selectedPoints()) out.push_back(glm::vec3(m * glm::vec4(o->mesh.verts[size_t(v)], 1.0f))); - return out; - } - for (const Object& o : scene_.objects) { - if (!selected_.count(o.id) || o.locked) continue; - const glm::mat4 m = objectMatrix(o); - for (const glm::vec3& v : shownMesh(o).verts) out.push_back(glm::vec3(m * glm::vec4(v, 1.0f))); - } - return out; -} - -auto App::pivotOfSelection() -> glm::vec3 { - glm::vec3 c(0.0f); - int n = 0; - if (Object* o = editing()) { - const glm::mat4 m = objectMatrix(*o); - for (int v : selectedPoints()) c += glm::vec3(m * glm::vec4(o->mesh.verts[size_t(v)], 1.0f)), ++n; - } else { - for (const Object& o : scene_.objects) - if (selected_.count(o.id)) c += o.position, ++n; - } - return n ? c / float(n) : glm::vec3(0.0f); -} - -// The selection's box, in a gizmo's axes. -auto App::selectionBox(const Axes& a) -> std::pair { - glm::vec3 lo(1e30f), hi(-1e30f); - for (const glm::vec3& p : selectionPoints()) { - const glm::vec3 c = inFrame(a, p); - lo = glm::min(lo, c), hi = glm::max(hi, c); - } - if (lo.x > hi.x) lo = hi = inFrame(a, pivotOfSelection()); - return {lo, hi}; -} - -auto App::selectionMiddle(const Axes& a) -> glm::vec3 { - const auto [lo, hi] = selectionBox(a); - return fromFrame(a, (lo + hi) * 0.5f); -} - -// The gizmo's axes: the object's own (turning with it), the world's, or (shape editing) the -// way the selected faces face — so "up" on a side face is out of that face. -auto App::gizmoAxes() -> Axes { - Orient o = orient(); - if (mode_ == Mode::Object && tool_ == Tool::Scale) o = Orient::Local; // (sizes are along an object's own axes) - const Object* a = find(active_); - if (!a && !selected_.empty()) a = find(*selected_.begin()); - const glm::quat q = a && o != Orient::World ? a->rotation : glm::quat(1, 0, 0, 0); - Axes local{q * glm::vec3(1, 0, 0), q * glm::vec3(0, 1, 0), q * glm::vec3(0, 0, 1)}; - if (o != Orient::Normal || mode_ != Mode::Edit) return local; - Object* e = editing(); - if (!e) return local; - const glm::mat4 m = objectMatrix(*e); - const glm::mat3 nm = glm::transpose(glm::inverse(glm::mat3(m))); - const std::set pts = selectedPoints(); - glm::vec3 n(0.0f), along(0.0f); - for (size_t f = 0; f < e->mesh.faces.size(); ++f) { - const auto& fv = e->mesh.faces[f]; - const bool whole = elem_ == Elem::Face ? selFaces_.count(int(f)) > 0 : std::any_of(fv.begin(), fv.end(), [&](int v) { return pts.count(v); }); - if (!whole) continue; - n += nm * faceNormal(e->mesh, int(f)) * std::max(faceArea(e->mesh, int(f)), 1e-6f); - if (glm::length(along) < 1e-9f) // the first face's longest edge: "across" it - for (size_t i = 0; i < fv.size(); ++i) { - const glm::vec3 d = glm::mat3(m) * (e->mesh.verts[size_t(fv[(i + 1) % fv.size()])] - e->mesh.verts[size_t(fv[i])]); - if (glm::length(d) > glm::length(along)) along = d; - } - } - if (elem_ == Elem::Edge && !selEdges_.empty()) { - const auto [a0, b0] = *selEdges_.begin(); - along = glm::mat3(m) * (e->mesh.verts[size_t(b0)] - e->mesh.verts[size_t(a0)]); - } - if (glm::length(n) < 1e-9f) return local; - const glm::vec3 z = glm::normalize(n); - glm::vec3 x = along - z * glm::dot(along, z); - if (glm::length(x) < 1e-6f) x = glm::cross(std::abs(z.z) < 0.9f ? glm::vec3(0, 0, 1) : glm::vec3(1, 0, 0), z); - x = glm::normalize(x); - return {x, glm::cross(z, x), z}; -} - -// The extrude / inset handle: the selected faces' middle, their normal. -auto App::faceHandle(glm::vec3& at, glm::vec3& normal) -> bool { - Object* o = editing(); - if (!o || elem_ != Elem::Face || selFaces_.empty()) return false; - const glm::mat4 m = objectMatrix(*o); - glm::vec3 c(0.0f), n(0.0f); - for (int f : selFaces_) { - if (f >= int(o->mesh.faces.size())) return false; - c += glm::vec3(m * glm::vec4(faceCenter(o->mesh, f), 1.0f)); - n += glm::normalize(glm::transpose(glm::inverse(glm::mat3(m))) * faceNormal(o->mesh, f)); - } - at = c / float(selFaces_.size()); - normal = glm::length(n) > 1e-6f ? glm::normalize(n) : glm::vec3(0, 0, 1); - return true; -} - -// The round-edges handle: off to the right of the selection's middle. -auto App::bevelHandle(ImVec2& centre, ImVec2& handle) -> bool { - if (!editing() || edgesToRound().empty()) return false; - if (!project(drag_.active ? drag_.pivot : pivotOfSelection(), centre)) return false; - handle = ImVec2(centre.x + 80.0f + (drag_.active ? ImGui::GetIO().MousePos.x - drag_.startMouse.x : 0.0f), centre.y); - return true; -} - -// The scale box's handles: 0-2 the far side along each axis, 4-6 the near side, 3 the middle. -// (Pushed out a little where the box is flat, so they don't sit on top of each other.) -auto App::scaleHandle(const Axes& a, glm::vec3 lo, glm::vec3 hi, int h) -> glm::vec3 { - const glm::vec3 c = (lo + hi) * 0.5f; - if (h == 3) return fromFrame(a, c); - const int k = h % 4 == 3 ? 0 : (h < 3 ? h : h - 4); - glm::vec3 q = c; - const float minHalf = pixelSize(fromFrame(a, c)) * 22.0f; - const float half = std::max((hi[k] - lo[k]) * 0.5f, minHalf); - q[k] = c[k] + (h < 3 ? half : -half); - return fromFrame(a, q); -} - -// Which handle is under the mouse (-1 none). -auto App::hoverHandle(ImVec2 mouse) -> int { - if (drag_.active) return drag_.handle; - if (tool_ == Tool::Extrude || tool_ == Tool::Inset) { - glm::vec3 at, n; - if (!faceHandle(at, n)) return -1; - ImVec2 a, b; - if (!project(at, a) || !project(at + n * pixelSize(at) * 90.0f, b)) return -1; - return distToSegment(mouse, a, b) < 10.0f ? 0 : -1; - } - if (tool_ == Tool::Bevel) { - ImVec2 c, h; - return bevelHandle(c, h) && std::hypot(mouse.x - h.x, mouse.y - h.y) < 12.0f ? 0 : -1; - } - if (tool_ != Tool::Move && tool_ != Tool::Rotate && tool_ != Tool::Scale) return -1; - if (!hasSelection()) return -1; - const Axes axes = gizmoAxes(); - if (tool_ == Tool::Scale) { - const auto [lo, hi] = selectionBox(axes); - for (int h : {3, 0, 1, 2, 4, 5, 6}) { - ImVec2 s; - if (project(scaleHandle(axes, lo, hi, h), s) && std::abs(mouse.x - s.x) < 9 && std::abs(mouse.y - s.y) < 9) return h; - } - return -1; - } - const glm::vec3 p = tool_ == Tool::Rotate ? rotatePivot(axes) : selectionMiddle(axes); - ImVec2 c; - if (!project(p, c)) return -1; - const float len = pixelSize(p) * 90.0f; - if (tool_ == Tool::Rotate) { - int best = -1; - float bestD = 9.0f; - for (int k = 0; k < 3; ++k) { - const glm::vec3 u = glm::normalize(glm::cross(axes[size_t(k)], std::abs(axes[size_t(k)].z) < 0.9f ? glm::vec3(0, 0, 1) : glm::vec3(1, 0, 0))); - const glm::vec3 v = glm::cross(axes[size_t(k)], u); - ImVec2 prev; - bool havePrev = false; - for (int i = 0; i <= 48; ++i) { - const float a = 2 * kPi * float(i) / 48.0f; - ImVec2 s; - const glm::vec3 w = p + (u * std::cos(a) + v * std::sin(a)) * len; - if (!project(w, s)) { - havePrev = false; - continue; - } - if (havePrev && glm::dot(w - p, eye_ - p) > -0.2f * len * glm::length(eye_ - p)) // (the front half only) - if (const float d = distToSegment(mouse, prev, s); d < bestD) bestD = d, best = k; - prev = s, havePrev = true; - } - } - const float r = std::hypot(mouse.x - c.x, mouse.y - c.y); - if (best < 0 && r > 95.0f && r < 110.0f) best = 3; // the outer ring: about the view - return best; - } - if (std::abs(mouse.x - c.x) < 9 && std::abs(mouse.y - c.y) < 9) return 3; - for (int k = 0; k < 3; ++k) { - ImVec2 e; - if (project(p + axes[size_t(k)] * len, e) && distToSegment(mouse, c, e) < 9.0f) return k; - } - return -1; -} - -auto App::originPoint() -> glm::vec3 { - if (Object* o = editing()) return o->position; - glm::vec3 c(0.0f); - int n = 0; - for (const Object& o : scene_.objects) - if (selected_.count(o.id)) c += o.position, ++n; - return n ? c / float(n) : c; -} - -auto App::startDrag(const ImGuiIO& io, int handle) -> void { - drag_ = {}; - drag_.active = true, drag_.handle = handle, drag_.tool = tool_; - drag_.startMouse = io.MousePos; - drag_.before = scene_; - pushUndo(); - if (softSel_ && (tool_ == Tool::Move || tool_ == Tool::Rotate || tool_ == Tool::Scale)) findSoft(); - const Ray ray = mouseRay(io.MousePos); - if (tool_ == Tool::Extrude || tool_ == Tool::Inset) { - glm::vec3 at, n; - faceHandle(at, n); - drag_.pivot = at, drag_.startT = closestOnAxis(ray, at, n); - drag_.startHit = n; - return; - } - if (tool_ == Tool::Bevel) { - drag_.pivot = pivotOfSelection(); - drag_.bevelEdges = edgesToRound(); - return; - } - drag_.axes = gizmoAxes(); - const Axes& a = drag_.axes; - if (tool_ == Tool::Scale) { - const auto [lo, hi] = selectionBox(a); - const glm::vec3 c = (lo + hi) * 0.5f; - drag_.handlePoint = scaleHandle(a, lo, hi, handle); - const int k = handle < 3 ? handle : handle - 4; - glm::vec3 q = c; - switch (scalePivot_) { - case Pivot::Opposite: - if (handle == 3) q[2] = lo[2]; - else q[k] = handle < 3 ? lo[k] : hi[k]; - drag_.anchor = fromFrame(a, q); - break; - case Pivot::Bottom: q[2] = lo[2], drag_.anchor = fromFrame(a, q); break; - case Pivot::Origin: drag_.anchor = originPoint(); break; - default: drag_.anchor = fromFrame(a, c); break; - } - drag_.pivot = fromFrame(a, c); - if (handle != 3) drag_.startDist = glm::dot(drag_.handlePoint - drag_.anchor, a[size_t(k)]), drag_.startMid = glm::dot(drag_.handlePoint - drag_.pivot, a[size_t(k)]); - return; - } - drag_.pivot = tool_ == Tool::Rotate ? rotatePivot(a) : selectionMiddle(a); - if (handle < 3) drag_.startT = closestOnAxis(ray, drag_.pivot, a[size_t(handle)]); - if (auto hit = hitPlane(ray, drag_.pivot, -viewDir())) drag_.startHit = *hit; -} - -// The points within reach of the selection (in the world: the object's size counts), each with -// how much it follows: a grid of the selected points, so a big mesh stays quick. -auto App::findSoft() -> void { - drag_.soft.clear(); - const Object* o = editing(); - if (!o) return; - const std::set pts = selectedPoints(); - if (pts.empty()) return; - const glm::mat4 m = objectMatrix(*o); - const float r = std::max(softReach_, 1e-5f); - auto cell = [&](const glm::vec3& p) { return glm::ivec3(glm::floor(p / r)); }; - auto keyOf = [](glm::ivec3 c) { return (int64_t(c.x & 0x1FFFFF) << 42) | (int64_t(c.y & 0x1FFFFF) << 21) | int64_t(c.z & 0x1FFFFF); }; - std::unordered_map> grid; - for (int v : pts) { - const glm::vec3 w(m * glm::vec4(o->mesh.verts[size_t(v)], 1.0f)); - grid[keyOf(cell(w))].push_back(w); - } - for (size_t v = 0; v < o->mesh.verts.size(); ++v) { - if (pts.count(int(v))) continue; - const glm::vec3 w(m * glm::vec4(o->mesh.verts[v], 1.0f)); - const glm::ivec3 c = cell(w); - float best = r; - 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(keyOf(c + glm::ivec3(dx, dy, dz))); it != grid.end()) - for (const glm::vec3& q : it->second) best = std::min(best, glm::distance(w, q)); - if (best < r) drag_.soft.push_back({int(v), sculptFalloff(best, r)}); - } -} - -auto App::cancelDrag() -> void { - if (!drag_.active) return; - scene_ = drag_.before; - dropUndo(); - touchAll(); - drag_.active = false; -} - -auto App::updateDrag(const ImGuiIO& io) -> void { - for (ImWchar c : io.InputQueueCharacters) { - if ((c >= '0' && c <= '9') || (c == '.' && drag_.typed.find('.') == std::string::npos)) drag_.typed += char(c); - else if (c == '-') drag_.typed = drag_.typed.starts_with('-') ? drag_.typed.substr(1) : "-" + drag_.typed; - } - if (ImGui::IsKeyPressed(ImGuiKey_Backspace) && !drag_.typed.empty()) drag_.typed.pop_back(); - dragNow(io); - // (the number by the mouse, as it goes: what you'd read in the status line, where you look) - if (!message_.empty()) { - const std::string label = drag_.typed.empty() ? message_ : message_ + " (typed)"; - const ImVec2 at(io.MousePos.x + 18, io.MousePos.y + 14), size = ImGui::CalcTextSize(label.c_str()); - ImDrawList* dl = ImGui::GetForegroundDrawList(); - dl->AddRectFilled(ImVec2(at.x - 6, at.y - 3), ImVec2(at.x + size.x + 6, at.y + size.y + 3), IM_COL32(24, 24, 28, 225), 4.0f); - dl->AddText(at, drag_.typed.empty() ? IM_COL32(235, 235, 240, 255) : kAccent, label.c_str()); - } - if (liveMirror_ && mode_ == Mode::Edit) - if (Object* o = editing()) - for (const Object& b : drag_.before.objects) - if (b.id == o->id) mirrorEdits(*o, b.mesh); -} - -// Mirror editing: each point that moved, its twin across the shape's own X (the point that was -// where it is, mirrored) moved the mirrored way. Points on the middle stay on it. -auto App::mirrorEdits(Object& o, const Mesh& before) -> void { - if (before.verts.size() != o.mesh.verts.size() || before.verts.empty()) return; // (points made or gone: nothing to match) - const float tol = std::max(glm::length(boundsMax(before) - boundsMin(before)) * 1e-5f, 1e-7f); - auto key = [&](const glm::vec3& p) { - const glm::ivec3 c(glm::round(p / tol)); - 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 at; - for (size_t i = 0; i < before.verts.size(); ++i) at.emplace(key(before.verts[i]), int(i)); - std::vector moved(before.verts.size()); - for (size_t i = 0; i < moved.size(); ++i) moved[i] = glm::distance(before.verts[i], o.mesh.verts[i]) > tol * 0.01f; - for (size_t i = 0; i < moved.size(); ++i) { - if (!moved[i]) continue; - const glm::vec3 b = before.verts[i]; - if (std::abs(b.x) <= tol) { // (on the middle) - o.mesh.verts[i].x = 0; - continue; - } - const auto it = at.find(key(glm::vec3(-b.x, b.y, b.z))); - if (it == at.end() || moved[size_t(it->second)]) continue; // (no twin, or it's moved by hand too) - const glm::vec3 n = o.mesh.verts[i]; - o.mesh.verts[size_t(it->second)] = glm::vec3(-n.x, n.y, n.z); - } -} - -// What moving snaps to near the mouse (points, edges' or faces' middles), in the world: not -// what's being moved. Marked with a ring. -auto App::snapTarget(ImVec2 mouse, float within) -> std::optional { - std::optional best; - float bestD = within; - const std::set moving = mode_ == Mode::Edit ? selectedPoints() : std::set{}; - for (const Object& o : scene_.objects) { - if (!o.visible) continue; - const bool edited = mode_ == Mode::Edit && o.id == active_; - if (mode_ != Mode::Edit && selected_.count(o.id)) continue; - const Mesh& mesh = edited ? o.mesh : shownMesh(o); - const glm::mat4 m = objectMatrix(o); - auto tryPoint = [&](const glm::vec3& local) { - const glm::vec3 w = glm::vec3(m * glm::vec4(local, 1.0f)); - ImVec2 sp; - if (!project(w, sp)) return; - if (const float d = std::hypot(sp.x - mouse.x, sp.y - mouse.y); d < bestD) bestD = d, best = w; - }; - if (snapKind_ == 1) { - for (size_t v = 0; v < mesh.verts.size(); ++v) - if (!(edited && moving.count(int(v)))) tryPoint(mesh.verts[v]); - } else if (snapKind_ == 2) { - for (const auto& e : adjacencyOf(o, mesh).edges) - if (!(edited && (moving.count(e[0]) || moving.count(e[1])))) tryPoint((mesh.verts[size_t(e[0])] + mesh.verts[size_t(e[1])]) * 0.5f); - } else if (snapKind_ == 3) { - for (const auto& f : mesh.faces) { - glm::vec3 c(0.0f); - bool mine = false; - for (int v : f) c += mesh.verts[size_t(v)], mine |= edited && moving.count(v) > 0; - if (!mine && !f.empty()) tryPoint(c / float(f.size())); - } - } - } - if (best) { - ImVec2 sp; - if (project(*best, sp)) ImGui::GetForegroundDrawList()->AddCircle(sp, 7.0f, kAccent, 0, 2.0f); - } - return best; -} - -// The number typed while dragging, if any (just "-" or "." is none yet). -auto App::typed() const -> std::optional { - if (drag_.typed.empty() || drag_.typed == "-" || drag_.typed == "." || drag_.typed == "-.") return std::nullopt; - return std::strtof(drag_.typed.c_str(), nullptr); -} - -auto App::dragNow(const ImGuiIO& io) -> void { - const Ray ray = mouseRay(io.MousePos); - const bool snapping = snap_ != io.KeyCtrl; // Ctrl flips snapping while held - scene_ = drag_.before; // every frame from where it started: no drift - const Axes& axes = drag_.axes; - const glm::vec3 p = drag_.pivot; - glm::mat4 T(1.0f); // what happens to the selection, in the world - if (drag_.tool == Tool::Extrude || drag_.tool == Tool::Inset) { - Object* o = editing(); - if (!o) return; - const glm::vec3 n = drag_.startHit; - float d = closestOnAxis(ray, p, n) - drag_.startT; - if (drag_.tool == Tool::Extrude) { - if (snapping) d = snapTo(d, snapMove_); - if (auto v = typed()) d = *v; - // (the pull in the object's own size, so it follows the mouse whatever its scale) - const float scale = std::cbrt(std::abs(glm::determinant(glm::mat3(objectMatrix(*o))))); - extrudeFaces(o->mesh, selFaces_, d / std::max(scale, 1e-6f)); - extrudeAmount_ = d; - message_ = std::format("Extrude {:.3f} m", d), messageAt_ = now(); - } else { - float t = std::clamp(-d / (pixelSize(p) * 120.0f), 0.0f, 0.99f); - if (snapping) t = snapTo(t, 0.05f); - if (auto v = typed()) t = std::clamp(*v / 100.0f, 0.0f, 0.99f); - insetFaces(o->mesh, selFaces_, t); - insetAmount_ = t; - message_ = std::format("Inset {:.0f}%", t * 100), messageAt_ = now(); - } - changedShape(*o, true); - return; - } - if (drag_.tool == Tool::Bevel) { - Object* o = editing(); - if (!o) return; - float w = std::max(0.0f, io.MousePos.x - drag_.startMouse.x) * pixelSize(p) * 0.5f; - if (snapping) w = snapTo(w, 0.005f); - if (auto v = typed()) w = std::max(0.0f, *v); - if (w > 0) bevelEdges(o->mesh, drag_.bevelEdges, w, bevelSteps_); - bevelWidth_ = w; - handMade(*o); - touched(o->id); - message_ = std::format("Round edges {:.3f} m, {} step{}", w, bevelSteps_, bevelSteps_ == 1 ? "" : "s"), messageAt_ = now(); - return; - } - glm::vec3 objectScale(1.0f); - float even = 1.0f; - if (drag_.tool == Tool::Move) { - glm::vec3 delta(0.0f); - if (drag_.handle < 3) { - float t = closestOnAxis(ray, p, axes[size_t(drag_.handle)]) - drag_.startT; - if (snapping && !snapKind_) t = snapTo(t, snapMove_); - if (snapping && snapKind_) - if (auto at = snapTarget(io.MousePos)) t = glm::dot(*at - p, axes[size_t(drag_.handle)]); // (as far along the arrow as it is) - if (auto v = typed()) t = *v; - delta = axes[size_t(drag_.handle)] * t; - } else if (auto hit = hitPlane(ray, p, -viewDir())) { - delta = *hit - drag_.startHit; - if (snapping && !snapKind_) delta = glm::vec3(snapTo(delta.x, snapMove_), snapTo(delta.y, snapMove_), snapTo(delta.z, snapMove_)); - if (snapping && snapKind_) - if (auto at = snapTarget(io.MousePos)) delta = *at - p; // (the arrows' point onto it) - } - T = glm::translate(glm::mat4(1.0f), delta); - message_ = std::format("Move {:.3f}, {:.3f}, {:.3f} m", delta.x, delta.y, delta.z), messageAt_ = now(); - } else if (drag_.tool == Tool::Rotate) { - ImVec2 c; - if (!project(p, c)) return; - const float a0 = std::atan2(drag_.startMouse.y - c.y, drag_.startMouse.x - c.x), a1 = std::atan2(io.MousePos.y - c.y, io.MousePos.x - c.x); - float deg = glm::degrees(std::remainder(a1 - a0, 2 * kPi)); - const glm::vec3 axis = drag_.handle < 3 ? axes[size_t(drag_.handle)] : -viewDir(); - if (glm::dot(axis, viewDir()) < 0) deg = -deg; // (turning the way the mouse goes round, as seen) - if (snapping) deg = snapTo(deg, snapAngle_); - if (auto v = typed()) deg = *v; - T = glm::translate(glm::mat4(1.0f), p) * glm::rotate(glm::mat4(1.0f), glm::radians(deg), axis) * glm::translate(glm::mat4(1.0f), -p); - message_ = std::format("Rotate {:.1f} degrees", deg), messageAt_ = now(); - } else if (drag_.tool == Tool::Scale) { - glm::vec3 s(1.0f); - // Shift (or Alt) held: from both sides, the middle staying put. - const bool both = (io.KeyShift || io.KeyAlt) && drag_.handle != 3; - if (drag_.handle != 3) { - const int k = drag_.handle < 3 ? drag_.handle : drag_.handle - 4; - const float from = both ? drag_.startMid : drag_.startDist; - float dist = from + closestOnAxis(ray, drag_.handlePoint, axes[size_t(k)]); - if (snapping) dist = both ? snapTo(dist * 2, snapMove_) / 2 : snapTo(dist, snapMove_); - if (auto v = typed()) dist = (from < 0 ? -1.0f : 1.0f) * std::abs(*v) / (both ? 2.0f : 1.0f); // (the size it's to be, m) - const float f = std::abs(from) > 1e-6f ? dist / from : 1.0f; - s[k] = f; - message_ = std::format("Scale {:.2f} ({:.3f} m){}", f, std::abs(dist) * (both ? 2 : 1), both ? " from both sides" : " (Shift: from both sides)"), - messageAt_ = now(); - } else { - even = std::max(0.01f, 1.0f + ((io.MousePos.x - drag_.startMouse.x) - (io.MousePos.y - drag_.startMouse.y)) / 200.0f); - if (snapping) even = std::max(snapScale_, snapTo(even, snapScale_)); - if (auto v = typed()) even = std::max(0.001f, *v); - s = glm::vec3(even); - message_ = std::format("Scale {:.2f} (drag right or up to grow)", even), messageAt_ = now(); - } - const glm::mat3 basis(axes[0], axes[1], axes[2]); - const glm::vec3 a = both ? drag_.pivot : drag_.anchor; - T = glm::translate(glm::mat4(1.0f), a) * glm::mat4(basis) * glm::scale(glm::mat4(1.0f), s) * glm::mat4(glm::transpose(basis)) * glm::translate(glm::mat4(1.0f), -a); - objectScale = s; - } - if (Object* o = editing()) { - const glm::mat4 m = objectMatrix(*o), local = glm::inverse(m) * T * m; - for (int v : selectedPoints()) o->mesh.verts[size_t(v)] = glm::vec3(local * glm::vec4(o->mesh.verts[size_t(v)], 1.0f)); - for (const auto& [v, w] : drag_.soft) { // (part of the way, by how near) - glm::vec3& p = o->mesh.verts[size_t(v)]; - p = glm::mix(p, glm::vec3(local * glm::vec4(p, 1.0f)), w); - } - handMade(*o); - touched(o->id); - return; - } - for (Object& o : scene_.objects) { - const bool along = !selected_.count(o.id) && o.cuts && selected_.count(o.cuts) && isCutter(o); // (an editable cutter goes with what it cuts) - if ((!selected_.count(o.id) && !along) || o.locked) continue; - o.position = glm::vec3(T * glm::vec4(o.position, 1.0f)); - if (drag_.tool == Tool::Rotate) o.rotation = glm::normalize(glm::quat_cast(glm::mat3(T)) * o.rotation); - if (drag_.tool == Tool::Scale) { - // On its own axes: the gizmo's where they're the same, else how much each of its - // axes lies along each of the gizmo's. - for (int j = 0; j < 3; ++j) { - const glm::vec3 own = o.rotation * glm::vec3(j == 0, j == 1, j == 2); - float f = 1.0f; - for (int k = 0; k < 3; ++k) f += (objectScale[k] - 1.0f) * glm::dot(own, axes[size_t(k)]) * glm::dot(own, axes[size_t(k)]); - o.scale[j] *= f; - } - } - touched(o.id); - } -} - -// ───────────────────────────── 3D ───────────────────────────── - -// Solid: a plain grey room and soft studio light, no shadows or effects — for seeing shapes. -// Rendered: sky, sun, shadows and effects. -// How far the shade in corners reaches (units): a share of how far away you look, so close up it -// stays in the corners instead of smearing over everything in a pattern. (24 at the first view.) -auto App::aoReach() const -> float { - return std::clamp(viewScale() * kUnits * 0.15f, 0.005f, 400.0f); -} - -auto App::applyShading() -> void { - const Look& k = scene_.look; - const std::string want = - shading() == Shading::Rendered - ? std::format("r_ssao {}; r_ssao_radius {:.2g}; r_bloom {}; r_vignette {}; r_exposure {}; r_saturation {}; r_sharpen {}; r_shadowsoft {}; r_gi {}; r_fog 0; r_skycolor \"{} {} {}\"", k.ao, aoReach(), - k.bloom, k.vignette, k.exposure, k.saturation, k.sharpen, k.shadowSoft, kRayLight, k.background.r, k.background.g, k.background.b) - : std::string("r_ssao 0; r_bloom 0; r_vignette 0; r_exposure 1; r_saturation 1; r_sharpen 0; r_gi 0; r_fog 0; r_skycolor \"0.2 0.2 0.215\""); - if (want == appliedLook_) return; - appliedLook_ = want; - keel::execute(want); -} - -auto App::modelOf(const Object& o) -> keel::ModelId { - const bool editingThis = mode_ == Mode::Edit && o.id == active_; - const Mesh& mesh = shownMesh(o); - uint64_t want = (meshKey(o, mesh) * 2 + editingThis) * 2 + painted(o); - want = (want ^ uint64_t(o.smooth) ^ uint64_t(std::bit_cast(o.smoothAngle)) << 1) * 1099511628211ull; // (how it's lit: smooth or not, and the angle) - auto it = models_.find(o.id); - if (it != models_.end() && it->second.second == want) return it->second.first; - keel::KmdlModel km; - keel::KmdlMaterial mat{}; - mat.baseColor[0] = mat.baseColor[1] = mat.baseColor[2] = mat.baseColor[3] = 1.0f; - const bool withPicture = painted(o); - if (withPicture) std::snprintf(mat.texture, sizeof mat.texture, "fry:paint:%u", o.id); - km.materials.push_back(mat); - const auto normals = cornerNormals(mesh, o.smooth, o.smoothAngle); - for (size_t f = 0; f < mesh.faces.size(); ++f) { - const auto& face = mesh.faces[f]; - for (const auto& tri : triangulate(mesh, int(f))) - for (int k = 0; k < 3; ++k) { - const size_t corner = size_t(std::find(face.begin(), face.end(), tri[size_t(k)]) - face.begin()); - const glm::vec3 p = mesh.verts[size_t(tri[size_t(k)])], n = normals[f][corner]; - km.indices.push_back(uint32_t(km.vertices.size())); - const glm::vec2 uv = withPicture ? o.uvs[f][corner] : glm::vec2(0.0f); - km.vertices.push_back({{p.x, p.y, p.z}, {n.x, n.y, n.z}, {uv.x, uv.y}, {0, 0}}); - } - } - if (km.vertices.empty()) km.vertices.push_back({}), km.indices = {0, 0, 0}; - km.submeshes.push_back({0, uint32_t(km.indices.size()), 0}); - const glm::vec3 lo = boundsMin(mesh), hi = boundsMax(mesh); - std::copy_n(&lo.x, 3, km.header.boundsMin); - std::copy_n(&hi.x, 3, km.header.boundsMax); - const keel::ModelId id = r_->updateModel(std::format("fry:object{}", o.id), km); - models_[o.id] = {id, want}; - return id; -} - -auto App::picture(const Reference& ref) -> const Picture& { - auto it = pictures_.find(ref.path); - if (it != pictures_.end()) return it->second; - Picture& p = pictures_[ref.path]; - int w = 0, h = 0, n = 0; - if (stbi_uc* px = stbi_load(ref.path.c_str(), &w, &h, &n, 4)) { - r_->createTexture("fry:picture:" + ref.path, uint32_t(w), uint32_t(h), px); - stbi_image_free(px); - p.ok = true, p.aspect = float(h) / float(std::max(w, 1)); - } else { - say("Couldn't load the picture " + ref.path); - } - return p; -} - -auto App::loadLogo() -> void { - if (logoTried_) return; - logoTried_ = true; - if (auto png = keel::fs::read("branding/logo.png")) { - int w = 0, h = 0, n = 0; - if (stbi_uc* px = stbi_load_from_memory(png->data(), int(png->size()), &w, &h, &n, 4)) { - logo_ = r_->createTexture("fry:logo", uint32_t(w), uint32_t(h), px); - stbi_image_free(px); - haveLogo_ = true; - } - } -} - -auto App::logoImage(float size) -> void { - if (!haveLogo_) return; - const ImVec2 at = ImGui::GetCursorScreenPos(); - ImGui::Dummy(ImVec2(size, size)); - uiImage(ImGui::GetWindowDrawList(), logo_, at, ImVec2(at.x + size, at.y + size)); -} - -// A picture of ours in the UI, read the way ImGui's colours are (not turned linear: too dark). -auto App::uiImage(ImDrawList* dl, keel::TextureId t, ImVec2 a, ImVec2 b) -> void { - const keel::UiRawRead raw = r_->uiRawRead(t); - if (raw.begin) dl->AddCallback(raw.begin, raw.data); - dl->AddImage(ImTextureID(r_->uiTexture(t)), a, b); - if (raw.end) dl->AddCallback(raw.end, raw.data); -} - -auto App::draw3D(keel::Renderer& r) -> void { - settleView(); - followCuts(); - keel::Camera cam; - cam.origin = eye_ * kUnits, cam.yaw = yaw_, cam.pitch = pitch_, cam.fovY = fov_, cam.shift = shift_, cam.zNear = nearFar_.x, cam.zFar = nearFar_.y; - r.setCamera(cam); - const Look& look = scene_.look; - const bool rendered = shading() == Shading::Rendered; - const float turn = glm::radians(look.sunTurn), up = glm::radians(look.sunHeight); - r.setSun(rendered && look.sunStrength > 0, glm::vec3(std::cos(up) * std::cos(turn), std::cos(up) * std::sin(turn), std::sin(up)), look.sunColor * look.sunStrength, true); - keel::Renderer::Atmosphere atm; - atm.sky = rendered && look.sky ? "skies/day.ksky" : ""; - atm.fogDensity = 0.0f; - r.setAtmosphere(atm); - // Rendered: light from the sky all round. Solid: a studio's — bright from above and the - // front-left, darker below, so every side of a shape reads differently. - const keel::ModelLighting light = - rendered ? keel::ModelLighting{{{0.22f, 0.22f, 0.24f}, {0.13f, 0.13f, 0.15f}, {0.2f, 0.2f, 0.22f}, {0.16f, 0.16f, 0.18f}, {0.3f, 0.31f, 0.35f}, {0.06f, 0.06f, 0.06f}}, 1.0f} - : keel::ModelLighting{{{0.5f, 0.49f, 0.47f}, {0.2f, 0.21f, 0.24f}, {0.72f, 0.71f, 0.69f}, {0.3f, 0.31f, 0.34f}, {0.95f, 0.95f, 0.95f}, {0.08f, 0.08f, 0.09f}}, 0.0f}; - const glm::mat4 toUnits = glm::scale(glm::mat4(1.0f), glm::vec3(kUnits)); - // Painted pictures: laid out again if the shape's faces changed, and sent to the card. - for (Object& o : scene_.objects) - if (o.picture && !o.uvsFit() && !o.hasFinish()) o.uvs = autoUnwrap(o.mesh), touched(o.id); - for (const Object& o : scene_.objects) - if (o.picture) uploadPicture(o, !(paint_.active && paint_.object == o.id)); - if (shading() != Shading::Wire) - for (const Object& o : scene_.objects) { - if (!o.visible || isCutter(o)) continue; - const glm::vec4 tint = painted(o) ? glm::vec4(1.0f) : glm::vec4(glm::pow(o.color, glm::vec3(2.2f)), 1.0f); // (colours are picked as seen: sRGB) - const keel::Surface surface{o.metal, o.rough}; - r.drawModel(modelOf(o), toUnits * objectMatrix(o), tint, nullptr, &light, rendered, nullptr, keel::LiquidStyle::None, &surface); - } - // Editable cutters: see-through, so the hole they make shows (not in a saved picture). - if (shading() != Shading::Wire && !capturing()) - for (const Object& o : scene_.objects) - if (o.visible && isCutter(o)) r.drawModel(modelOf(o), toUnits * objectMatrix(o), glm::vec4(0.9f, 0.35f, 0.3f, 0.3f), nullptr, &light, false); - // Rendered: a ground to stand on and catch the shadows (not part of what you make). - if (rendered && look.ground) { - if (!haveGround_) { - haveGround_ = true; - 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); - const float h = 500.0f; - for (glm::vec2 c : {glm::vec2(-h, -h), glm::vec2(h, -h), glm::vec2(h, h), glm::vec2(-h, h)}) km.vertices.push_back({{c.x, c.y, -0.0005f}, {0, 0, 1}, {0, 0}, {0, 0}}); - km.indices = {0, 1, 2, 0, 2, 3}; - km.submeshes.push_back({0, 6, 0}); - ground_ = r.updateModel("fry:ground", km); - } - r.drawModel(ground_, toUnits, glm::vec4(glm::pow(look.groundColor, glm::vec3(2.2f)), 1.0f), nullptr, &light, false); - } - // Reference pictures: flat, see-through, a little behind the plane through the middle. - for (const Reference& ref : scene_.references) { - if (!ref.visible || (ref.ownViewOnly && !lookingAt(ref.axis))) continue; - const Picture& pic = picture(ref); - if (!pic.ok) continue; - const auto c = referenceCorners(ref, pic.aspect); - const glm::vec3 n = glm::normalize(glm::cross(c[1] - c[0], c[3] - c[0])); - keel::KmdlModel km; - keel::KmdlMaterial mat{}; - std::snprintf(mat.texture, sizeof mat.texture, "fry:picture:%s", ref.path.c_str()); - mat.baseColor[0] = mat.baseColor[1] = mat.baseColor[2] = 1.0f, mat.baseColor[3] = 1.0f; - km.materials.push_back(mat); - const glm::vec2 uv[4] = {{0, 1}, {1, 1}, {1, 0}, {0, 0}}; - for (int side = 0; side < 2; ++side) // (both sides: seen from behind too) - for (int k : {0, 1, 2, 0, 2, 3}) { - const int i = side ? (3 - k + 1) % 4 : k; - const glm::vec3 p = c[size_t(i)] - n * 0.001f, nn = side ? -n : n; - km.indices.push_back(uint32_t(km.vertices.size())); - km.vertices.push_back({{p.x, p.y, p.z}, {nn.x, nn.y, nn.z}, {uv[i].x, uv[i].y}, {0, 0}}); - } - km.submeshes.push_back({0, uint32_t(km.indices.size()), 0}); - const keel::ModelId id = r.updateModel(std::format("fry:ref{}", ref.id), km); - 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}; - r.drawModel(id, toUnits, glm::vec4(1.0f, 1.0f, 1.0f, ref.opacity), nullptr, &flat, false); - } - // The grid on the work plane (the ground, or what a flat view looks at), and the axes. - // Four sets of lines, each ten times further apart than the one before, each drawn round - // where you look and fading out before its edge. A line's look goes only by how far apart - // it and its like are on the screen: too close together, it fades away; far apart, it's - // brighter and a little wider. So nothing jumps as the view moves or the steps change: new - // lines come in too close together to see and fade in. Each line is drawn by the widest set - // it belongs to (once), in pieces, each a pixel wide where it is. - if (tab_ == Tab::Render ? renderGrid_ : showGrid_) { - // Worked out for each pixel (as in Blender): lines every 10^n metres, each set showing by - // how far apart its lines are on the screen, the axes coloured; round the point under the - // eye, out to most of the way to the far plane. - glm::vec3 p, n; - workPlane(p, n); - const glm::vec3 u = n.z > 0.5f ? glm::vec3(1, 0, 0) : n.x > 0.5f ? glm::vec3(0, 1, 0) : glm::vec3(1, 0, 0); - const glm::vec3 v = glm::cross(n, u); - const float reach = nearFar_.y / kUnits * 0.9f; - const glm::dvec3 eye(eye_), dn(n), under = (eye - dn * glm::dot(eye, dn)) * double(kUnits); // (the grid's plane goes through the origin) - r.drawGrid(under, u, v, kUnits, rendered, reach * kUnits); // (dark lines on the sunny ground, light ones in the grey room) - } - if (tab_ == Tab::Print) drawPrintBed(r, light); -} - -// (the Print tab's: the printer's plate and overhangs) -// On top of the scene: outlines and wires, points, the gizmo, the selection shapes. -// An empty scene: how to start, in the middle of the view (gone once there's anything). -auto App::emptyHints(ImDrawList* dl) -> void { - if (!scene_.objects.empty() || capturing() || tab_ != Tab::Model) return; - const char* lines[] = {touch() ? "Nothing here yet: tap + to add a shape" : "Nothing here yet: Add (menu) for a shape, or draw one (D)", - touch() ? "Hold a finger on the view for more" : "F3 or Ctrl+Space: find any command by name", - touch() ? "" : "Space: tools round the mouse Shift+Space: views", - touch() ? "" : "File > Import: STL, OBJ, 3MF, glTF from elsewhere"}; - const float lh = ImGui::GetTextLineHeightWithSpacing() * 1.25f; - float y = (viewMin_.y + viewMax_.y) * 0.5f - lh * 2, widest = 0; - int shown = 0; - for (const char* l : lines) widest = std::max(widest, ImGui::CalcTextSize(l).x), shown += *l != 0; - const float cx = (viewMin_.x + viewMax_.x) * 0.5f; - dl->AddRectFilled(ImVec2(cx - widest * 0.5f - 18, y - 12), ImVec2(cx + widest * 0.5f + 18, y + lh * float(shown) + 6), IM_COL32(28, 28, 32, 200), 8.0f); - for (int i = 0; i < 4; ++i, y += lh) { - if (!*lines[i]) continue; - const ImVec2 size = ImGui::CalcTextSize(lines[i]); - dl->AddText(ImVec2((viewMin_.x + viewMax_.x - size.x) * 0.5f, y), i == 0 ? IM_COL32(230, 230, 235, 200) : IM_COL32(200, 200, 205, 130), lines[i]); - } -} - -auto App::overlays() -> void { - ImDrawList* dl = ImGui::GetBackgroundDrawList(); - dl->PushClipRect(viewMin_, viewMax_); - const ImGuiIO& io = ImGui::GetIO(); - emptyHints(dl); - // Every edge of a mesh (X-ray, wireframe), or just the edges the eye can see. - auto lines = [&](const Object& o, const Mesh& mesh, ImU32 col, float width, bool seenOnly) { - const glm::mat4 m = objectMatrix(o); - const bool hide = seenOnly && !xray_; - std::vector s(mesh.verts.size()); - std::vector ok(mesh.verts.size()); - for (size_t i = 0; i < s.size(); ++i) ok[i] = project(glm::vec3(m * glm::vec4(mesh.verts[i], 1.0f)), s[i]); - for (const auto& e : adjacencyOf(o, mesh).edges) { - if (!ok[size_t(e[0])] || !ok[size_t(e[1])] || (hide && !(seen(o, mesh, e[0]) && seen(o, mesh, e[1])))) continue; - dl->AddLine(s[size_t(e[0])], s[size_t(e[1])], col, width); - } - }; - // The outline round a selected object (where it turns away from you, and open edges). - auto outline = [&](const Object& o, ImU32 col, float width) { - const Mesh& mesh = shownMesh(o); - const glm::mat4 m = objectMatrix(o); - const Adjacency& adj = adjacencyOf(o, mesh); - // (which faces look at the eye: the eye taken into the object's own space, no point moved) - const glm::vec3 eye = glm::vec3(glm::inverse(m) * glm::vec4(eye_, 1.0f)); - std::vector facing(adj.plane.size()); - for (size_t f = 0; f < facing.size(); ++f) facing[f] = glm::dot(glm::vec3(adj.plane[f]), eye) > adj.plane[f].w; - for (const auto& e : adj.edges) { - const bool silhouette = e[3] >= 0 && facing[size_t(e[2])] != facing[size_t(e[3])], open = e[3] < 0 && facing[size_t(e[2])]; - if (!silhouette && !open) continue; - if (!xray_ && !(seen(o, mesh, e[0]) && seen(o, mesh, e[1]))) continue; // (not the edges of a pocket behind its rim) - ImVec2 a, b; - if (project(glm::vec3(m * glm::vec4(mesh.verts[size_t(e[0])], 1.0f)), a) && project(glm::vec3(m * glm::vec4(mesh.verts[size_t(e[1])], 1.0f)), b)) dl->AddLine(a, b, col, width); - } - }; - const bool clean = capturing(); // (a picture being saved: no outlines or gizmos) - Object* ed = editing(); - for (const Object& o : scene_.objects) { - if (!o.visible) continue; - const bool sel = selected_.count(o.id) > 0 && !clean && tab_ != Tab::Paint; - if (ed && o.id == ed->id) continue; - if (isCutter(o)) { - if (!clean) lines(o, shownMesh(o), sel ? kAccent : IM_COL32(235, 95, 85, 150), sel ? 1.6f : 1.0f, false); - continue; - } - if (shading() == Shading::Wire || xray_) lines(o, shownMesh(o), sel ? kAccent : IM_COL32(200, 200, 205, 150), sel ? 1.6f : 1.0f, false); - else if (sel) outline(o, kAccent, 2.5f); - } - if (tab_ == Tab::Paint) { - if (Object* so = find(ledit_.object); so && editLayer() && editLayer()->sticker) { // the sticker's frame and handles - std::array hw; - ImVec2 q[6]; - bool ok = stickerHandles(*so, *editLayer(), hw); - for (int i = 0; ok && i < 6; ++i) ok = project(hw[size_t(i)], q[i]); - if (ok) { - const int hot = ledit_.drag >= 0 ? ledit_.drag : stickerHandle(*so, *editLayer(), io.MousePos); - dl->AddPolyline(q, 4, hot == 4 ? kHot : kAccent, ImDrawFlags_Closed, 2.0f); - const ImVec2 top((q[0].x + q[1].x) * 0.5f, (q[0].y + q[1].y) * 0.5f); - dl->AddLine(top, q[4], kAccent, 1.5f); - dl->AddCircleFilled(q[4], 7.0f, hot == 5 ? kHot : IM_COL32(255, 255, 255, 240)); - dl->AddCircle(q[4], 7.0f, IM_COL32(20, 20, 25, 255)); - for (int i = 0; i < 4; ++i) { - dl->AddRectFilled(ImVec2(q[i].x - 6, q[i].y - 6), ImVec2(q[i].x + 6, q[i].y + 6), hot == i ? kHot : IM_COL32(255, 255, 255, 240)); - dl->AddRect(ImVec2(q[i].x - 6, q[i].y - 6), ImVec2(q[i].x + 6, q[i].y + 6), IM_COL32(20, 20, 25, 255)); - } - } - } - if (stencil_.size() >= 2) { // (the stencil's edge; where paint can't go, shaded) - dl->AddPolyline(stencil_.data(), int(stencil_.size()), stencilOn_ || drawingStencil_ ? kAccent : IM_COL32(200, 200, 205, 120), drawingStencil_ ? 0 : ImDrawFlags_Closed, 2.0f); - if (stencilOn_ && !drawingStencil_ && stencil_.size() >= 3) dl->AddConcavePolyFilled(stencil_.data(), int(stencil_.size()), IM_COL32(255, 160, 90, 28)); - } - if (sculpting_) { - if (mouseInView(io)) dl->AddCircle(io.MousePos, sculptPx_, io.KeyCtrl ? IM_COL32(120, 180, 255, 220) : IM_COL32(255, 255, 255, 200), 40, 1.5f); - } else if (mouseInView(io) && !editLayer() && paintTool_ != PaintTool::Picture) - dl->AddCircle(io.MousePos, brushPx_, paintTool_ == PaintTool::Fill || paintTool_ == PaintTool::Picker ? IM_COL32(255, 255, 255, 90) : IM_COL32(255, 255, 255, 200), 40, 1.5f); - dl->PopClipRect(); - return; - } - if (tab_ == Tab::Layout) { - layoutOverlay(dl); - dl->PopClipRect(); - return; - } - if ((tab_ == Tab::Print || printTool_ == PrintTool::Measure) && !clean) printOverlay(dl, io); - if (tab_ == Tab::Print && showLayer_ && !clean) layerOverlay(dl); - if (clean) { - dl->PopClipRect(); - return; - } - if (ed) { - if (ed->hasFinish() && ed->mirror && shading() != Shading::Wire) // (the mirrored half: faint) - lines(*ed, shownMesh(*ed), IM_COL32(20, 20, 25, 70), 1.0f, false); - lines(*ed, ed->mesh, shading() == Shading::Wire ? IM_COL32(230, 230, 235, 230) : IM_COL32(12, 12, 15, 235), 1.3f, true); - } - if (ed && !ed->creases.empty()) { // (creased edges: smoothing keeps them sharp) - const glm::mat4 m = objectMatrix(*ed); - const int n = int(ed->mesh.verts.size()); - for (auto [a, b] : ed->creases) { - ImVec2 sa, sb; - if (a < n && b < n && (xray_ || (pointSeen(*ed, a) && pointSeen(*ed, b))) && project(glm::vec3(m * glm::vec4(ed->mesh.verts[size_t(a)], 1.0f)), sa) && - project(glm::vec3(m * glm::vec4(ed->mesh.verts[size_t(b)], 1.0f)), sb)) - dl->AddLine(sa, sb, IM_COL32(230, 70, 200, 255), 2.5f); - } - } - if (Object* o = ed) { - const glm::mat4 m = objectMatrix(*o); - if (elem_ == Elem::Vertex) { - for (size_t v = 0; v < o->mesh.verts.size(); ++v) { - ImVec2 s; - const bool sel = selVerts_.count(int(v)); - if (!project(glm::vec3(m * glm::vec4(o->mesh.verts[v], 1.0f)), s) || (!sel && !pointSeen(*o, int(v)))) continue; - dl->AddRectFilled(ImVec2(s.x - 3, s.y - 3), ImVec2(s.x + 3, s.y + 3), sel ? kAccent : IM_COL32(15, 15, 18, 255)); - } - } else if (elem_ == Elem::Edge) { - for (auto [a, b] : selEdges_) { - ImVec2 sa, sb; - if (a < int(o->mesh.verts.size()) && b < int(o->mesh.verts.size()) && project(glm::vec3(m * glm::vec4(o->mesh.verts[size_t(a)], 1.0f)), sa) && - project(glm::vec3(m * glm::vec4(o->mesh.verts[size_t(b)], 1.0f)), sb)) - dl->AddLine(sa, sb, kAccent, 3.0f); - } - } else { // the selected faces the eye can see: an orange skin, a dot in the middle - const glm::mat3 nm = glm::transpose(glm::inverse(glm::mat3(m))); - for (int f : selFaces_) { - if (f >= int(o->mesh.faces.size())) continue; - const glm::vec3 c(m * glm::vec4(faceCenter(o->mesh, f), 1.0f)); - const bool front = glm::dot(nm * faceNormal(o->mesh, f), eye_ - c) > 0; - if (!xray_ && !front) continue; - const auto& fv = o->mesh.faces[size_t(f)]; - if (!xray_ && !std::all_of(fv.begin(), fv.end(), [&](int v) { return pointSeen(*o, v); }) && !visible(*o, c)) continue; - std::vector poly(fv.size()); - bool ok = true; - for (size_t k = 0; k < fv.size(); ++k) ok &= project(glm::vec3(m * glm::vec4(o->mesh.verts[size_t(fv[k])], 1.0f)), poly[k]); - if (ok) dl->AddConcavePolyFilled(poly.data(), int(poly.size()), IM_COL32(255, 120, 40, 70)); - ImVec2 s; - if (project(c, s)) dl->AddCircleFilled(s, 3.5f, kAccent); - } - } - // The loop cut to be: across each face of the ring. - if (tool_ == Tool::LoopCut && cutHover_.first >= 0) - for (const auto& q : ringQuads(o->mesh, cutHover_)) - for (int c = 1; c <= cuts_; ++c) { - const float t = float(c) / float(cuts_ + 1); - const glm::vec3 a = glm::mix(o->mesh.verts[size_t(q[0])], o->mesh.verts[size_t(q[1])], t), b = glm::mix(o->mesh.verts[size_t(q[3])], o->mesh.verts[size_t(q[2])], t); - ImVec2 sa, sb; - if (project(glm::vec3(m * glm::vec4(a, 1.0f)), sa) && project(glm::vec3(m * glm::vec4(b, 1.0f)), sb)) dl->AddLine(sa, sb, kHot, 2.5f); - } - } - // The knife's line: its points, on to the mouse; cutting through, on past the ends. - if (tool_ == Tool::Knife && (!knifePts_.empty() || knifeDown_)) { - std::vector pts = knifePts_; - if (knifePts_.empty()) pts.push_back(knifePress_); - if (mouseInView(io) || knifeDown_) pts.push_back(knifeEnd(io)); - for (size_t i = 0; i + 1 < pts.size(); ++i) dl->AddLine(pts[i], pts[i + 1], kHot, 2.0f); - if (knifeThrough_ && pts.size() >= 2) - for (int end = 0; end < 2; ++end) { - const ImVec2 a = end ? pts.back() : pts.front(), b = end ? pts[pts.size() - 2] : pts[1]; - const float dx = a.x - b.x, dy = a.y - b.y, l = std::max(std::hypot(dx, dy), 1e-3f); - for (int k = 0; k < 60; k += 2) - dl->AddLine(ImVec2(a.x + dx / l * float(k) * 12, a.y + dy / l * float(k) * 12), ImVec2(a.x + dx / l * float(k + 1) * 12, a.y + dy / l * float(k + 1) * 12), - (kHot & 0x00FFFFFF) | 0x90000000, 1.5f); - } - for (const ImVec2& p : knifePts_) dl->AddCircleFilled(p, 4.0f, kHot); - } - // The hole to be. - if (tool_ == Tool::Hole && (mouseInView(io) || hole_.active)) { - const auto ring = holePreview(io); - std::vector pts; - for (const glm::vec3& w : ring) { - ImVec2 sp; - if (project(w, sp)) pts.push_back(sp); - } - if (pts.size() >= 3) dl->AddPolyline(pts.data(), int(pts.size()), hole_.active ? kHot : IM_COL32(255, 255, 255, 200), ImDrawFlags_Closed, 2.0f); - } - if (boxing_ && !touchOrbit_ && std::hypot(io.MousePos.x - boxStart_.x, io.MousePos.y - boxStart_.y) > 4) { - dl->AddRectFilled(boxStart_, io.MousePos, IM_COL32(255, 140, 60, 30)); - dl->AddRect(boxStart_, io.MousePos, kAccent); - } - if (lassoing_ && lasso_.size() > 1) dl->AddPolyline(lasso_.data(), int(lasso_.size()), kAccent, ImDrawFlags_Closed, 1.5f); - if (selShape_ == SelShape::Brush && tool_ == Tool::Select && mode_ == Mode::Edit && mouseInView(io)) - dl->AddCircle(io.MousePos, brushRadius_, painting_ ? kAccent : IM_COL32(255, 255, 255, 160), 32, 1.5f); - if (tool_ == Tool::Draw) { - ImVec2 prev, s; - for (size_t i = 0; i < drawPts_.size(); ++i) { - if (!project(drawPts_[i], s)) continue; - if (i) dl->AddLine(prev, s, kAccent, 2.0f); - dl->AddCircleFilled(s, i == 0 ? 6.0f : 4.0f, i == 0 ? IM_COL32(255, 255, 255, 255) : kAccent); - prev = s; - } - if (mouseInView(io) && !touch()) // (a finger isn't hovering anywhere) - if (auto p = drawPoint(io); p && project(*p, s)) { - if (!drawPts_.empty()) dl->AddLine(prev, s, IM_COL32(255, 160, 90, 180), 1.5f); - dl->AddCircle(s, 5.0f, IM_COL32(255, 255, 255, 220), 0, 1.5f); - if (!drawPts_.empty()) { // (the line's length by the mouse, and what's typed) - const std::string label = std::format("{:.4g} m{}", glm::distance(drawPts_.back(), *p), sketchTyped_.empty() ? " (type a length, Enter; Shift: 15 deg steps)" : " typed " + sketchTyped_); - const ImVec2 at(io.MousePos.x + 16, io.MousePos.y + 14), size = ImGui::CalcTextSize(label.c_str()); - dl->AddRectFilled(ImVec2(at.x - 5, at.y - 3), ImVec2(at.x + size.x + 5, at.y + size.y + 3), IM_COL32(24, 24, 28, 225), 4.0f); - dl->AddText(at, sketchTyped_.empty() ? IM_COL32(235, 235, 240, 255) : kAccent, label.c_str()); - } - } - } - gizmo(dl, io); - dl->PopClipRect(); -} - -auto App::gizmo(ImDrawList* dl, const ImGuiIO& io) -> void { - const int hot = mouseInView(io) || drag_.active ? hoverHandle(io.MousePos) : -1; - if (softSel_ && mode_ == Mode::Edit && (tool_ == Tool::Move || tool_ == Tool::Rotate || tool_ == Tool::Scale) && !selectedPoints().empty()) { - const glm::vec3 at = drag_.active ? drag_.pivot : pivotOfSelection(); - ImVec2 c; - if (project(at, c)) dl->AddCircle(c, softReach_ / std::max(pixelSize(at), 1e-9f), keel::ui::accent(0.8f), 64, 1.5f); - } - if (tool_ == Tool::Extrude || tool_ == Tool::Inset) { - glm::vec3 at, n; - if (!faceHandle(at, n)) return; - ImVec2 a, b; - if (!project(at, a) || !project(at + n * pixelSize(at) * 90.0f, b)) return; - const ImU32 col = hot == 0 ? kHot : IM_COL32(255, 255, 255, 255); - dl->AddLine(a, b, col, 3.0f); - if (tool_ == Tool::Extrude) dl->AddCircleFilled(b, 7.0f, col); - else dl->AddRectFilled(ImVec2(b.x - 6, b.y - 6), ImVec2(b.x + 6, b.y + 6), col); - return; - } - if (tool_ == Tool::Bevel) { - ImVec2 c, h; - if (!bevelHandle(c, h)) return; - const ImU32 col = hot == 0 ? kHot : IM_COL32(255, 255, 255, 255); - dl->AddLine(c, h, (col & 0x00FFFFFF) | 0x90000000, 2.0f); - dl->AddCircleFilled(h, 9.0f, col); - drawIcon(dl, Icon::Bevel, ImVec2(h.x - 7, h.y - 7), 14.0f, IM_COL32(30, 30, 35, 255)); - return; - } - if (tool_ != Tool::Move && tool_ != Tool::Rotate && tool_ != Tool::Scale) return; - if (!hasSelection()) return; - const Axes axes = drag_.active ? drag_.axes : gizmoAxes(); - if (tool_ == Tool::Scale) { // the box round the selection, a handle on each side - const auto [lo, hi] = selectionBox(axes); - for (int e = 0; e < 12; ++e) { - const int k = e / 4, u = (k + 1) % 3, v = (k + 2) % 3; - glm::vec3 a, b; - a[k] = lo[k], b[k] = hi[k]; - a[u] = b[u] = e & 1 ? hi[u] : lo[u]; - a[v] = b[v] = e & 2 ? hi[v] : lo[v]; - ImVec2 sa, sb; - if (project(fromFrame(axes, a), sa) && project(fromFrame(axes, b), sb)) dl->AddLine(sa, sb, IM_COL32(255, 255, 255, 110), 1.0f); - } - for (int h : {0, 1, 2, 4, 5, 6, 3}) { - ImVec2 s; - if (!project(scaleHandle(axes, lo, hi, h), s)) continue; - const ImU32 col = hot == h ? kHot : h == 3 ? IM_COL32(255, 255, 255, 235) : kAxisCol[h < 3 ? h : h - 4]; - dl->AddRectFilled(ImVec2(s.x - 6, s.y - 6), ImVec2(s.x + 6, s.y + 6), col); - dl->AddRect(ImVec2(s.x - 6, s.y - 6), ImVec2(s.x + 6, s.y + 6), IM_COL32(20, 20, 25, 200)); - } - return; - } - const glm::vec3 p = drag_.active ? (tool_ == Tool::Rotate ? drag_.pivot : selectionMiddle(axes)) : (tool_ == Tool::Rotate ? rotatePivot(axes) : selectionMiddle(axes)); - ImVec2 c; - if (!project(p, c)) return; - const float len = pixelSize(p) * 90.0f; - if (tool_ == Tool::Rotate) { - for (int k = 0; k < 3; ++k) { - const glm::vec3 u = glm::normalize(glm::cross(axes[size_t(k)], std::abs(axes[size_t(k)].z) < 0.9f ? glm::vec3(0, 0, 1) : glm::vec3(1, 0, 0))); - const glm::vec3 v = glm::cross(axes[size_t(k)], u); - ImVec2 prev; - bool havePrev = false; - for (int i = 0; i <= 64; ++i) { - const float a = 2 * kPi * float(i) / 64.0f; - const glm::vec3 w = p + (u * std::cos(a) + v * std::sin(a)) * len; - ImVec2 s; - if (!project(w, s)) { - havePrev = false; - continue; - } - const bool front = glm::dot(w - p, eye_ - p) > -0.2f * len * glm::length(eye_ - p); - if (havePrev) dl->AddLine(prev, s, hot == k ? kHot : (front ? kAxisCol[k] : (kAxisCol[k] & 0x00FFFFFF) | 0x50000000), hot == k ? 3.5f : 2.5f); - prev = s, havePrev = true; - } - } - dl->AddCircle(c, 102.0f, hot == 3 ? kHot : IM_COL32(255, 255, 255, 160), 64, 2.0f); - return; - } - for (int k = 0; k < 3; ++k) { - ImVec2 e; - if (!project(p + axes[size_t(k)] * len, e)) continue; - const ImU32 col = hot == k ? kHot : kAxisCol[k]; - dl->AddLine(c, e, col, 3.0f); - const float dx = e.x - c.x, dy = e.y - c.y, l = std::max(std::hypot(dx, dy), 1.0f); - const ImVec2 dir(dx / l, dy / l), side(-dir.y, dir.x); - dl->AddTriangleFilled(ImVec2(e.x + dir.x * 14, e.y + dir.y * 14), ImVec2(e.x + side.x * 6, e.y + side.y * 6), ImVec2(e.x - side.x * 6, e.y - side.y * 6), col); - } - dl->AddRect(ImVec2(c.x - 8, c.y - 8), ImVec2(c.x + 8, c.y + 8), hot == 3 ? kHot : IM_COL32(255, 255, 255, 230), 0, 0, 2.0f); -} - -} // namespace fryapp diff --git a/src/boolean.cpp b/src/boolean.cpp index d9cd62b..b2e8510 100644 --- a/src/boolean.cpp +++ b/src/boolean.cpp @@ -3,14 +3,10 @@ // shared, points that land on another piece's edge set into it (so it stays closed all round). #include "mesh.h" -#include - #include #include #include -#include #include -#include #include namespace fry { @@ -334,116 +330,9 @@ Mesh combineOnce(const Mesh& am, const Mesh& b, Combine op) { 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 combineManifold(const Mesh& a, const Mesh& b, Combine op) { - auto toManifold = [](const Mesh& m, uint32_t firstFace) -> std::optional { - 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 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 parent; - std::function 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 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, 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>> 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> 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 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 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. diff --git a/src/bvh.cpp b/src/bvh.cpp deleted file mode 100644 index 3997539..0000000 --- a/src/bvh.cpp +++ /dev/null @@ -1,136 +0,0 @@ -#include "bvh.h" - -#include -#include - -namespace fry { - -std::optional 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(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::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 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 wallThickness(const Mesh& m) { - const TriangleTree tree(m, glm::mat4(1.0f)); - std::vector 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 diff --git a/src/bvh.h b/src/bvh.h deleted file mode 100644 index 664a6d8..0000000 --- a/src/bvh.h +++ /dev/null @@ -1,55 +0,0 @@ -#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 - -#include -#include -#include -#include - -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 tri{}; // (its points) - }; - // The nearest hit along the ray (dir needn't be unit length: t is in its lengths), before maxT. - std::optional 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& points() const { return points_; } // (in the world) - -private: - struct Tri { - std::array 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 points_; - std::vector tris_; - std::vector 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 wallThickness(const Mesh& m); - -// Where a ray meets a triangle (Möller–Trumbore), in the ray's lengths. -std::optional rayTriangle(const glm::vec3& origin, const glm::vec3& dir, const glm::vec3& a, const glm::vec3& b, const glm::vec3& c); - -} // namespace fry diff --git a/src/commands.cpp b/src/commands.cpp deleted file mode 100644 index 4d91501..0000000 --- a/src/commands.cpp +++ /dev/null @@ -1,179 +0,0 @@ -#include "commands.h" - -#include - -#include -#include -#include -#include - -namespace fry { -namespace { - -std::string lower(std::string s) { - for (char& c : s) c = char(std::tolower(static_cast(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 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 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(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& all, const std::vector& 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> 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& 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(n); - hot = int(std::floor((a + slice * 0.5f + 2 * std::numbers::pi_v) / 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(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 diff --git a/src/commands.h b/src/commands.h deleted file mode 100644 index 53097a1..0000000 --- a/src/commands.h +++ /dev/null @@ -1,64 +0,0 @@ -#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 - -#include -#include -#include -#include - -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 run; - std::function 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 parseChord(const std::string& text); -// The chord pressed this frame (a key that isn't only Ctrl / Shift / Alt), or none. -std::optional 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& all, const std::vector& 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& items, bool released); - -} // namespace fry diff --git a/src/fry3dx.rc b/src/fry3dx.rc new file mode 100644 index 0000000..5ae697b --- /dev/null +++ b/src/fry3dx.rc @@ -0,0 +1,25 @@ +// Windows: the program's icon and details (Explorer's Properties). +1 ICON "../branding/fry3dx.ico" +1 VERSIONINFO +FILEVERSION 0,5,0,0 +PRODUCTVERSION 0,5,0,0 +BEGIN + BLOCK "StringFileInfo" + BEGIN + BLOCK "040904b0" + BEGIN + VALUE "CompanyName", "Nexoniarz" + VALUE "FileDescription", "Fry3DX - Frying 3D Extreme" + VALUE "FileVersion", "0.5.0" + VALUE "InternalName", "fry3dx" + VALUE "LegalCopyright", "Copyright 2026 Nexoniarz, Apache License 2.0" + VALUE "OriginalFilename", "fry3dx.exe" + VALUE "ProductName", "Fry3DX" + VALUE "ProductVersion", "0.5.0" + END + END + BLOCK "VarFileInfo" + BEGIN + VALUE "Translation", 0x409, 1200 + END +END diff --git a/src/import.cpp b/src/import.cpp deleted file mode 100644 index 3e0a0c4..0000000 --- a/src/import.cpp +++ /dev/null @@ -1,454 +0,0 @@ -#include "import.h" - -#define CGLTF_IMPLEMENTATION -#include - -#include - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -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& 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 inflate(const uint8_t* data, size_t size, size_t expect) { - Bits b{data, size}; - std::vector 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(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& f) { return f.size() < 3 || std::set(f.begin(), f.end()).size() < 3; }); -} - -std::vector readStl(const std::vector& 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(d.data()), std::min(d.size(), 600)).find("facet") != std::string::npos; - if (!ascii) { - if (d.size() < 84) return *error = "That STL is too short", std::vector{}; - 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{}; - for (uint32_t i = 0; i < n; ++i) { - const uint8_t* t = d.data() + 84 + size_t(i) * 50 + 12; - std::vector 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 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 readObj(const std::vector& d, const std::string& name) { - std::vector all; - std::vector out{{name, {}}}; - std::map 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 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 zipFile(const std::vector& z, const std::function& 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(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(tag[at - 1]))) continue; - size_t q = at + a.size(); - while (q < tag.size() && std::isspace(static_cast(tag[q]))) ++q; - if (q >= tag.size() || tag[q] != '=') continue; - ++q; - while (q < tag.size() && std::isspace(static_cast(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 read3mf(const std::vector& d, const std::string& name, std::string* error) { - const std::vector 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{}; - const std::string_view s(reinterpret_cast(xml.data()), xml.size()); - float unit = 0.001f; - if (const size_t 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> parts; - }; - std::map objs; - std::vector> 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(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 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 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 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 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 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& importFormats() { - static const std::vector f = {".stl", ".obj", ".3mf", ".glb", ".gltf"}; - return f; -} - -std::vector 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{}; - const std::vector d{std::istreambuf_iterator(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 diff --git a/src/import.h b/src/import.h deleted file mode 100644 index 1a9f381..0000000 --- a/src/import.h +++ /dev/null @@ -1,24 +0,0 @@ -#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 -#include - -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 importModel(const std::string& path, std::string* error = nullptr); -// The file types it reads, for the file picker (".stl", ...). -const std::vector& importFormats(); - -// A raw DEFLATE stream unpacked (what a zip, so a 3MF, holds); empty if it's broken. -std::vector inflate(const uint8_t* data, size_t size, size_t expect = 0); - -} // namespace fry diff --git a/src/mesh.cpp b/src/mesh.cpp index e828a4e..c79cfdc 100644 --- a/src/mesh.cpp +++ b/src/mesh.cpp @@ -4,7 +4,6 @@ #include #include #include -#include namespace fry { @@ -183,10 +182,6 @@ std::vector> triangulate(const Mesh& m, int face) { const glm::vec3 v = glm::cross(n, u); std::vector 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 left(f.size()); std::iota(left.begin(), left.end(), 0); @@ -194,16 +189,12 @@ std::vector> 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()]; - 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 + if (cross2(p[size_t(a)], p[size_t(b)], p[size_t(c)]) <= 1e-12f) 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 (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) { + 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) { inside = true; break; } @@ -213,11 +204,6 @@ std::vector> 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; @@ -289,14 +275,13 @@ std::vector extrudeFaces(Mesh& m, const std::set& faces, float distanc } } // One flat piece moves as one, along its normal; a bent one, each point along its own. - // (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()); + region = glm::length(region) > 1e-12f ? glm::normalize(region) : glm::vec3(0, 0, 1); bool flat = true; for (int f : faces) flat &= glm::dot(faceNormal(m, f), region) > 0.99f; std::map moved; std::vector made; for (auto& [v, n] : along) { - const glm::vec3 dir = flat || glm::length(n) < 1e-9f ? region : glm::normalize(n); + const glm::vec3 dir = flat ? 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); @@ -378,24 +363,13 @@ bool makeFace(Mesh& m, const std::vector& verts, const glm::vec3& towards) int mergeByDistance(Mesh& m, float distance) { std::vector to(m.verts.size(), -1); std::vector 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> grid; - grid.reserve(m.verts.size()); for (size_t v = 0; v < m.verts.size(); ++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; + 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 int gone = int(m.verts.size() - kept.size()); remapFaces(m, to); diff --git a/src/mesh.h b/src/mesh.h index a0f90dc..7aa58c9 100644 --- a/src/mesh.h +++ b/src/mesh.h @@ -75,9 +75,8 @@ void bevelEdges(Mesh& m, const std::set>& edges, float width // Edges where the faces meet sharper than `angle` degrees. std::set> 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), 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>* creases = nullptr); +// than `keepAngle` degrees stay sharp (180: none do). +void subdivide(Mesh& m, bool smooth, float keepAngle = 180.0f); // 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 loopCut(Mesh& m, std::pair edge, int cuts = 1); @@ -151,22 +150,6 @@ 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 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 alongPath(const std::vector& 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 }; @@ -190,17 +173,6 @@ 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> 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 ── diff --git a/src/modify.cpp b/src/modify.cpp index a574d91..0ee9be9 100644 --- a/src/modify.cpp +++ b/src/modify.cpp @@ -1,8 +1,6 @@ // Whole-shape changes: flat faces joined up, and a surface made thick. #include "mesh.h" -#include - #include #include #include @@ -106,142 +104,4 @@ 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, 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 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& f) { return f.size() < 3; }); - removeUnused(out); - faceOutward(out); - return out; -} - -} // namespace fry - -namespace fry { - -std::vector pathOf(const Mesh& m, bool& closed) { - closed = false; - std::map, 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> 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 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> used; - std::vector best; - bool bestClosed = false; - float bestLength = -1; - auto walk = [&](int from) { - std::vector 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 alongPath(const std::vector& path, bool closed, int count) { - std::vector out; - if (path.size() < 2 || count < 1) return out; - std::vector p = path; - if (closed) p.push_back(path.front()); - std::vector 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(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 diff --git a/src/print.cpp b/src/print.cpp index 21cb933..a8dd086 100644 --- a/src/print.cpp +++ b/src/print.cpp @@ -119,76 +119,4 @@ 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& 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> same; - for (size_t i = 0; i < m.faces.size(); ++i) { - std::vector key = m.faces[i]; - std::sort(key.begin(), key.end()); - same[key].push_back(i); - } - std::vector drop(m.faces.size(), 0); - auto turn = [](const std::vector& f) { // (the face started at its lowest point: two that turn the same way match) - std::vector 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 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> sliceAt(const Mesh& m, float z) { - std::vector> 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 diff --git a/src/scene.cpp b/src/scene.cpp index ad60cb4..b08e342 100644 --- a/src/scene.cpp +++ b/src/scene.cpp @@ -113,9 +113,6 @@ void applyMaterial(Object& o, const MaterialPreset& m) { Mesh finishedMesh(const Object& o, bool mirrorOnly) { Mesh m = o.mesh; - std::set> 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); @@ -130,10 +127,6 @@ 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& f) { return std::set(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, int> seen; @@ -147,23 +140,11 @@ 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 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> 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), 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); + 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); return m; } @@ -224,7 +205,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 << " " << r.ownViewOnly << "\n"; + << " " << r.visible << "\n"; // Folders by their place in the file (1 up; 0 the top), as ids change on loading. std::map folderAt; for (size_t i = 0; i < scene.folders.size(); ++i) folderAt[scene.folders[i].id] = i + 1; @@ -233,7 +214,6 @@ 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"; @@ -249,11 +229,6 @@ 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; @@ -340,7 +315,6 @@ std::optional 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") { @@ -374,7 +348,7 @@ std::optional 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 == "cuts") l >> o->cuts; // (a place in the file for now) else if (word == "visible") l >> o->visible; + } else if (word == "visible") l >> o->visible; else if (word == "locked") l >> o->locked; else if (word == "recipe") { l >> o->recipe; @@ -382,8 +356,6 @@ std::optional 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; @@ -423,58 +395,9 @@ std::optional 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>& pngs, bool binary, std::string* error) { diff --git a/src/scene.h b/src/scene.h index 8e939b7..b059615 100644 --- a/src/scene.h +++ b/src/scene.h @@ -10,7 +10,6 @@ #include #include #include -#include #include #include #include @@ -65,10 +64,6 @@ struct Object { int roundSteps = 3; int smoothLevels = 0; bool smoothKeepSharp = false; - std::set> 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. @@ -115,7 +110,6 @@ 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. @@ -141,14 +135,6 @@ 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 { diff --git a/src/script.cpp b/src/script.cpp index 110ed71..ab93023 100644 --- a/src/script.cpp +++ b/src/script.cpp @@ -745,20 +745,6 @@ 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 { @@ -817,7 +803,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}, {"tool", fryTool}, {nullptr, nullptr}, + {"command", fryCommand}, {nullptr, nullptr}, }; // Stops a script that's run too long (an endless loop). @@ -959,22 +945,6 @@ ScriptResult Plugin::run(ScriptHost& host, size_t command, const std::vectorL || 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. @@ -1006,7 +976,6 @@ 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. diff --git a/src/script.h b/src/script.h index 0e27409..96d61d9 100644 --- a/src/script.h +++ b/src/script.h @@ -44,12 +44,6 @@ 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 @@ -60,12 +54,9 @@ 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& 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 commands; - std::vector tools; private: struct State; diff --git a/src/text.cpp b/src/text.cpp deleted file mode 100644 index da4f4ed..0000000 --- a/src/text.cpp +++ /dev/null @@ -1,202 +0,0 @@ -#include "text.h" - -#define STBTT_STATIC -#define STB_TRUETYPE_IMPLEMENTATION -#include - -#include - -#include -#include - -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; - -// A glyph's outlines (font units), its curves cut into `detail` straight pieces each. -std::vector glyphContours(const stbtt_fontinfo& f, int glyph, int detail) { - stbtt_vertex* v = nullptr; - const int n = stbtt_GetGlyphShape(&f, glyph, &v); - std::vector 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& 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 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(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 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 group(cs.size(), -1); - int groups = 0; - for (size_t i : body) { - if (group[i] >= 0) continue; - std::vector 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& 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 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 diff --git a/src/text.h b/src/text.h deleted file mode 100644 index cb4de83..0000000 --- a/src/text.h +++ /dev/null @@ -1,24 +0,0 @@ -#pragma once -// Text as a 3D shape: the letters' outlines from a font (TrueType / OpenType), made solid. -#include "mesh.h" - -#include -#include -#include - -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& font, const std::string& text, const TextStyle& style, std::string* error = nullptr); - -} // namespace fry diff --git a/src/tools.cpp b/src/tools.cpp index 549ad75..97d84d9 100644 --- a/src/tools.cpp +++ b/src/tools.cpp @@ -267,11 +267,11 @@ void bevelEdges(Mesh& m, const std::set>& want, float width, // ── Subdivide (Catmull–Clark) ── -void subdivide(Mesh& m, bool smooth, float keepAngle, std::set>* creases) { +void subdivide(Mesh& m, bool smooth, float keepAngle) { if (m.faces.empty()) return; const auto d = directed(m); const std::set sharp = keepAngle < 179.9f ? sharpEdges(m, keepAngle) : std::set{}; - 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))); }; + auto crease = [&](int a, int b) { return !d.count({a, b}) || !d.count({b, a}) || sharp.count(key(a, b)); }; Mesh out; out.verts = m.verts; std::vector facePt(m.faces.size()); @@ -317,23 +317,6 @@ void subdivide(Mesh& m, bool smooth, float keepAngle, std::set to(out.verts.size(), -1); - int next = 0; - std::vector 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> 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); } diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 4576576..6d12a6e 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -1,12 +1,7 @@ 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) diff --git a/tests/fuzz_test.cpp b/tests/fuzz_test.cpp deleted file mode 100644 index e6e07c1..0000000 --- a/tests/fuzz_test.cpp +++ /dev/null @@ -1,259 +0,0 @@ -// 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 - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -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(lo, hi)(rng); } -int pick(int lo, int hi) { return std::uniform_int_distribution(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 someFaces(const Mesh& m) { - std::set 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 somePoints(const Mesh& m) { - std::set 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 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> 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 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(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; -} diff --git a/tests/mesh_test.cpp b/tests/mesh_test.cpp index f89c60a..e02f371 100644 --- a/tests/mesh_test.cpp +++ b/tests/mesh_test.cpp @@ -1,8 +1,5 @@ // 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" @@ -14,7 +11,6 @@ #include #include -#include #include #include #include @@ -366,146 +362,6 @@ 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(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 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 font{std::istreambuf_iterator(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 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. @@ -573,23 +429,6 @@ 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 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. { @@ -709,72 +548,6 @@ 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(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; } diff --git a/tools/package.py b/tools/package.py index 4fcb9e6..2ba8401 100644 --- a/tools/package.py +++ b/tools/package.py @@ -12,7 +12,6 @@ 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 @@ -37,8 +36,11 @@ Open source under the Apache License 2.0 (LICENSE). Sky pictures: Poly Haven, CC def version(): - with open(os.path.join(ROOT, "project.json")) as f: # (the version and the rest live there) - return json.load(f)["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" PC_NEEDS = "a graphics card with Vulkan (or OpenGL 4.6)"