From c1991d9f733b99dc264908c83c553f2dcad89832 Mon Sep 17 00:00:00 2001 From: Nexoniarz Date: Wed, 21 Oct 2026 00:07:10 +0200 Subject: [PATCH] 0.7: cutting inside a cut no longer crashes (the boolean's tree kept splitting a sliver by its own plane, forever) and cuts come out closed (points near an edge's end set into it, small gaps filled, a nudged retry); hidden edges stay hidden on big or cut-up shapes (visibility through a screen grid, no "all seen" past a size; the selection outline too); every panel has a least size, kept when docked, and the first layout fits small windows; eye and padlock buttons in Layers; the tabs fit narrow windows; a failed tool keeps your redo; view commands at start-up no longer crash; tests Co-Authored-By: Claude Opus 5.5 --- CMakeLists.txt | 2 +- docs/content.js | 1 + src/app.cpp | 208 +++++++++++++++++++++++++++++++++----------- src/boolean.cpp | 133 +++++++++++++++++++++++++--- src/icons.cpp | 18 ++++ src/icons.h | 2 +- tests/mesh_test.cpp | 36 ++++++++ 7 files changed, 336 insertions(+), 64 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 9953af9..a8d67bb 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,5 +1,5 @@ cmake_minimum_required(VERSION 3.25) -project(Fry3DX VERSION 0.6.0 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 diff --git a/docs/content.js b/docs/content.js index db20fe7..51350d1 100644 --- a/docs/content.js +++ b/docs/content.js @@ -124,6 +124,7 @@ fry.frame()`, }, versions: [ + { v: "0.7", date: "2026-10-21", items: ["Steadier and tidier", "Cutting a shape inside an earlier cut no longer crashes, and cuts come out closed", "Edges hidden behind the surface stay hidden on big or cut-up shapes", "Every panel has a least size, so it stays readable; the first layout fits small windows", "Eye and padlock buttons in Layers; the tabs fit narrow windows", "A failed tool keeps your redo"] }, { v: "0.6", date: "2026-10-20", items: ["Sculpting, scripts and a Mac app", "Sculpting: raise, puff out, smooth, flatten, pinch and grab", "Materials shown in the view, sixteen to start from", "Scripts: Lua and a Script panel, with examples, kept in the .fry", "Plug-ins: menu commands and materials, three to start with", "Your own materials", "Soft selection, bridge, push / pull, smooth points, show only the selected", "macOS: one app for Apple silicon and Intel", "A new look (Niveus): light and dark, following the desktop", "A new logo", "Drop a file on the window to open it"] }, { v: "0.5", date: "2026-10-19", items: ["Builds for Windows, Linux (AppImage) and Android", "A phone layout: big buttons, finger gestures, the phone's file picker", "Apache License 2.0"] }, { v: "0.4", date: "2026-10-19", items: ["Combine objects: add, subtract, overlap", "Draw shapes onto other objects", "Copies in a row or circle, thicken and hollow", "FBX export", "Folders and groups in Layers, go-to buttons", "Shift scales both sides, Shift+click loops and rings", "A crash screen of its own"] }, diff --git a/src/app.cpp b/src/app.cpp index 25d5054..e972808 100644 --- a/src/app.cpp +++ b/src/app.cpp @@ -316,6 +316,7 @@ public: }, "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; @@ -390,6 +391,7 @@ public: }; 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]); @@ -432,6 +434,7 @@ public: 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]); @@ -469,6 +472,7 @@ public: 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); @@ -488,6 +492,7 @@ public: 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; @@ -527,6 +532,7 @@ public: 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) @@ -638,6 +644,7 @@ public: 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(); @@ -681,6 +688,7 @@ private: 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; @@ -966,9 +974,20 @@ private: void pushUndo() { undo_.push_back({scene_, selected_, active_, mode_, selVerts_, selFaces_, selEdges_}); if (undo_.size() > 200) undo_.erase(undo_.begin()); - redo_.clear(); + 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); @@ -984,12 +1003,14 @@ private: 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() { @@ -1689,7 +1710,7 @@ private: pushUndo(); const auto made = loopCut(o.mesh, edge, cuts_); if (made.empty()) { - undo_.pop_back(); + dropUndo(); return say("Nothing to cut there: loop cuts go across four-sided faces"); } changedShape(o); @@ -1707,7 +1728,7 @@ private: pushUndo(); const glm::vec3 towards = glm::inverse(glm::mat3(objectMatrix(*o))) * (eye_ - pivotOfSelection()); if (!makeFace(o->mesh, std::vector(pts.begin(), pts.end()), towards)) { - undo_.pop_back(); + dropUndo(); return say("Pick 3 or more points (not in a line) to make a face"); } changedShape(*o); @@ -1771,7 +1792,7 @@ private: 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 undo(), redo_.clear(), say("Couldn't bridge those (they share a point)"); + 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())); @@ -1889,7 +1910,7 @@ private: pushUndo(); const auto made = knifeCut(o->mesh, eye, path, knifeThrough_); if (made.empty()) { - undo_.pop_back(); + 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(); @@ -1948,7 +1969,7 @@ private: 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 undo_.pop_back(), say("The outline has to fit inside the face"); + 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(); @@ -1956,7 +1977,7 @@ private: 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)) { - undo_.pop_back(); + 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); @@ -3270,6 +3291,16 @@ private: 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(); @@ -3300,11 +3331,9 @@ private: ImGui::PushID(int(id) + 1000000); ImGui::TableNextRow(); ImGui::TableNextColumn(); - if (ImGui::SmallButton(f->visible ? "o" : "-")) showFolder(*f, !f->visible); - if (ImGui::IsItemHovered()) ImGui::SetTooltip(f->visible ? "Hide all in it" : "Show all in it"); + 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 (ImGui::SmallButton(f->locked ? "L" : " ")) lockFolder(*f, !f->locked); - if (ImGui::IsItemHovered()) ImGui::SetTooltip(f->locked ? "Unlock all in it" : "Lock all in it"); + 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; @@ -3380,11 +3409,9 @@ private: ImGui::PushID(int(o.id)); ImGui::TableNextRow(); ImGui::TableNextColumn(); - if (ImGui::SmallButton(o.visible ? "o" : "-")) o.visible = !o.visible, dirty_ = true; - if (ImGui::IsItemHovered()) ImGui::SetTooltip(o.visible ? "Shown (click to hide)" : "Hidden (click to show)"); + 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 (ImGui::SmallButton(o.locked ? "L" : " ")) o.locked = !o.locked, dirty_ = true; - if (ImGui::IsItemHovered()) ImGui::SetTooltip(o.locked ? "Locked: can't be moved or changed" : "Click to lock"); + 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) { @@ -3441,8 +3468,7 @@ private: Mesh m = b.hasFinish() ? finishedMesh(b) : b.mesh; transform(m, into * objectMatrix(b)); if (!combine(result, m, op)) { - undo(); - redo_.clear(); + takeBack(); return say(op == Combine::Overlap ? "They don't overlap: nothing would be left" : "That didn't leave anything"); } ++used; @@ -3774,6 +3800,8 @@ private: } // 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; @@ -3839,38 +3867,64 @@ private: 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. (Very big meshes: all, rather than slow.) + // 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_; - const std::vector& visiblePoints(const Object& o) { - Visibility& v = visibility_[o.id]; + 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.point.size() == o.mesh.verts.size()) return v.point; - v.version = version, v.eye = eye_; - v.point.assign(o.mesh.verts.size(), 1); + 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(o.mesh.verts.size()); - for (size_t i = 0; i < w.size(); ++i) w[i] = glm::vec3(m * glm::vec4(o.mesh.verts[i], 1.0f)); + 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 < o.mesh.faces.size(); ++f) - for (const auto& t : triangulate(o.mesh, int(f))) tris.push_back(t); - if (w.size() * tris.size() > 4'000'000) return v.point; + 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); - for (const auto& t : tris) { - if (t[0] == int(i) || t[1] == int(i) || t[2] == int(i)) continue; - if (auto hit = hitTriangle(ray, w[size_t(t[0])], w[size_t(t[1])], w[size_t(t[2])]); hit && *hit < limit) { - v.point[i] = 0; - break; - } - } + 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; } @@ -4281,7 +4335,7 @@ private: void cancelDrag() { if (!drag_.active) return; scene_ = drag_.before; - undo_.pop_back(); + dropUndo(); touchAll(); drag_.active = false; } @@ -4461,16 +4515,16 @@ private: 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->locked) return say("That object is locked"); 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)) { - undo(), redo_.clear(); + takeBack(); return say("That would leave nothing"); } o = find(onto); @@ -5047,8 +5101,10 @@ private: 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); } @@ -5301,6 +5357,44 @@ private: 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); @@ -5328,9 +5422,12 @@ private: ImGui::DockBuilderDockWindow("Picture", right); return ImGui::DockBuilderFinish(dock); } - ImGuiID left = ImGui::DockBuilderSplitNode(center, ImGuiDir_Left, 0.14f, nullptr, ¢er); + // (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, 0.22f, nullptr, ¢er); + 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) @@ -5517,22 +5614,27 @@ private: menus(); ImGui::EndMenu(); } - // The tabs: what you're doing. + 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 tw = touch() ? 72.0f : 92.0f; - ImGui::SetCursorPosX(std::max(ImGui::GetCursorPosX() + 20.0f, (ImGui::GetWindowWidth() - tw * 5 - 16) * 0.5f)); + 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], names[i], int(tab_) == i, tw, tips[i], ImGui::GetFrameHeight())) setTab(Tab(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(); } } - const std::string file = (path_.empty() ? "Untitled" : fileName(path_)) + (dirty_ ? " *" : ""); if (touch()) return ImGui::EndMainMenuBar(); // (no room on a phone) ImGui::SameLine(ImGui::GetWindowWidth() - ImGui::CalcTextSize(file.c_str()).x - 16); ImGui::TextDisabled("%s", file.c_str()); @@ -5599,6 +5701,7 @@ private: 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) { @@ -5810,6 +5913,7 @@ private: 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) { @@ -6017,6 +6121,7 @@ private: } 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")) { @@ -6042,7 +6147,7 @@ private: ImGui::PushID(int(ref.id)); ImGui::TableNextRow(); ImGui::TableNextColumn(); - if (ImGui::SmallButton(ref.visible ? "o" : "-")) ref.visible = !ref.visible; + 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(); @@ -6111,8 +6216,7 @@ private: PictureLayer& l = o->layers[size_t(i)]; const bool on = ledit_.object == o->id && ledit_.index == i; ImGui::PushID(i); - if (ImGui::SmallButton(l.visible ? "o" : "-")) pushUndo(), l.visible = !l.visible; - if (ImGui::IsItemHovered()) ImGui::SetTooltip(l.visible ? "Hide it" : "Show it"); + 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); @@ -6197,6 +6301,7 @@ private: // 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) { @@ -6314,6 +6419,7 @@ private: } 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) { @@ -6707,8 +6813,7 @@ private: touchAll(); if (r.ok && frame) frameAll(); if (!r.ok) { - undo(); - redo_.clear(); + takeBack(); scriptLine(r.error, true); showScript_ = true; return say(name + " stopped (nothing it did was kept): " + r.error.substr(0, r.error.find('\n'))); @@ -6955,6 +7060,7 @@ private: 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). diff --git a/src/boolean.cpp b/src/boolean.cpp index 49f19f6..b2e8510 100644 --- a/src/boolean.cpp +++ b/src/boolean.cpp @@ -7,6 +7,7 @@ #include #include #include +#include namespace fry { namespace { @@ -96,18 +97,28 @@ struct Node { if (front) front->all(out); if (back) back->all(out); } - void build(const std::vector& in) { + void build(const std::vector& in, int depth = 0) { if (in.empty()) return; - if (!hasPlane) hasPlane = true, n = in[0].n, w = in[0].w; + size_t first = 0; + if (!hasPlane) { // (the piece the plane is taken from stays here: a thin sliver's own corners can + // be off its rounded plane, and split by it again and again, forever) + hasPlane = true, n = in[0].n, w = in[0].w; + polys.push_back(in[0]); + first = 1; + } + if (depth > 3000) { // (a tree this deep is gone wrong: the rest kept here, not a stack overflow) + polys.insert(polys.end(), in.begin() + long(first), in.end()); + return; + } std::vector f, b; - for (const Poly& p : in) split(n, w, p, polys, polys, f, b); + for (size_t i = first; i < in.size(); ++i) split(n, w, in[i], polys, polys, f, b); if (!f.empty()) { if (!front) front = std::make_unique(); - front->build(f); + front->build(f, depth + 1); } if (!b.empty()) { if (!back) back = std::make_unique(); - back->build(b); + back->build(b, depth + 1); } } }; @@ -171,7 +182,7 @@ Mesh meshOf(const std::vector& polys) { const int v = *it; if (v == a || v == b) continue; const float t = glm::dot(m.verts[size_t(v)] - pa, d) / len2; - if (t <= 1e-4f || t >= 1 - 1e-4f) continue; + if (const float in = 2e-6f / std::sqrt(len2); t <= in || t >= 1 - in) continue; // (a µm or two from its ends: on a long edge, a part of it) if (const glm::vec3 off = pa + d * t - m.verts[size_t(v)]; glm::dot(off, off) < 1e-10f) on.push_back({t, v}); } std::sort(on.begin(), on.end()); @@ -206,13 +217,91 @@ void removeSpikes(Mesh& m) { std::erase_if(m.faces, [](const std::vector& f) { return f.size() < 3; }); } -} // namespace +// Gaps the cutting leaves (a tenth of a mm, where one side's rounding cut a piece the other +// didn't): an edge with no face back along it. A small loop of them, or a sliver of next to no +// area, gets a face of its own; the shape is closed again. +void closeSmallGaps(Mesh& m) { + // Two faces back to back on the same corners (a wall of no thickness): both go. + std::map, std::vector> byCorners; + auto key = [](std::vector f) { + std::rotate(f.begin(), std::min_element(f.begin(), f.end()), f.end()); + return f; + }; + for (size_t i = 0; i < m.faces.size(); ++i) byCorners[key(m.faces[i])].push_back(i); + std::vector drop(m.faces.size(), false); + for (size_t i = 0; i < m.faces.size(); ++i) { + if (drop[i]) continue; + std::vector r(m.faces[i].rbegin(), m.faces[i].rend()); + if (auto it = byCorners.find(key(r)); it != byCorners.end()) + for (size_t j : it->second) + if (j != i && !drop[j]) { + drop[i] = drop[j] = true; + break; + } + } + for (size_t i = m.faces.size(); i-- > 0;) + if (drop[i]) m.faces.erase(m.faces.begin() + long(i)); -bool combine(Mesh& a, const Mesh& b, Combine op) { + std::set> have; + for (const auto& f : m.faces) + for (size_t i = 0; i < f.size(); ++i) have.insert({f[i], f[(i + 1) % f.size()]}); + std::map> next; // (the open edges, a to b) + for (auto [a, b] : have) + if (!have.contains({b, a})) next[a].push_back(b); + auto fill = [&](std::vector loop) { + if (loop.size() < 3) return; + float around = 0.0f; + glm::vec3 area(0.0f); + for (size_t i = 0; i < loop.size(); ++i) { + const glm::vec3 p = m.verts[size_t(loop[i])], q = m.verts[size_t(loop[(i + 1) % loop.size()])]; + around += glm::distance(p, q), area += glm::cross(p, q); + } + // A small one, or one next to flat: a piece the cutting lost. Else a real opening: left. + const glm::vec3 n = glm::length(area) > 0.0f ? glm::normalize(area) : glm::vec3(0.0f); + float bulge = 0.0f; + for (int v : loop) bulge = std::max(bulge, std::abs(glm::dot(m.verts[size_t(v)] - m.verts[size_t(loop[0])], n))); + if (around > 2e-3f && glm::length(area) > 2e-9f && bulge > 1e-4f) return; + std::reverse(loop.begin(), loop.end()); + m.faces.push_back(loop); + }; + while (!next.empty()) { + std::vector loop{next.begin()->first}; + while (!loop.empty() && loop.size() <= 64) { + auto it = next.find(loop.back()); + if (it == next.end()) break; // (an open run, not a loop) + const int b = it->second.back(); + it->second.pop_back(); + if (it->second.empty()) next.erase(it); + // Back at a point already passed: the loop from there closes (two gaps meeting at a corner, a figure 8). + if (auto at = std::find(loop.begin(), loop.end(), b); at != loop.end()) { + fill(std::vector(at, loop.end())); + loop.erase(at + 1, loop.end()); + if (loop.size() == 1 && !next.contains(loop[0])) break; + } else + loop.push_back(b); + } + } +} + +// Every edge has as many faces along it one way as the other (closed; edges where two parts +// only touch count too). +bool sealed(const Mesh& m) { + std::map, int> e; + for (const auto& f : m.faces) + for (size_t i = 0; i < f.size(); ++i) { + const int a = f[i], b = f[(i + 1) % f.size()]; + a < b ? ++e[{a, b}] : --e[{b, a}]; + } + for (auto [k, n] : e) + if (n) return false; + return true; +} + +Mesh combineOnce(const Mesh& am, const Mesh& b, Combine op) { Node A, B; - A.build(polysOf(a)); + A.build(polysOf(am)); B.build(polysOf(b)); - if (!A.hasPlane || !B.hasPlane) return false; + if (!A.hasPlane || !B.hasPlane) return {}; std::vector bp; switch (op) { case Combine::Add: @@ -230,13 +319,35 @@ bool combine(Mesh& a, const Mesh& b, Combine op) { } std::vector out; A.all(out); - a = meshOf(out); + Mesh a = meshOf(out); // Points the cutting left a hair apart (closer than 0.03 mm: nothing a printer or a model // could show) are one; else they fold tiny faces back over their neighbours. mergeByDistance(a, 3e-5f); removeSpikes(a); mergeFlatFaces(a); removeSpikes(a); + closeSmallGaps(a); + return a; +} + +} // namespace + +bool combine(Mesh& a, const Mesh& b, Combine op) { + Mesh out = combineOnce(a, b, op); + // Closed going in but not coming out: the rounding went wrong somewhere. Again with the + // other shape moved a few µm (nothing that shows), and the first that comes out closed kept. + if (!out.faces.empty() && !sealed(out) && sealed(a) && sealed(b)) { + static const glm::vec3 nudge[] = {{3e-6f, 5e-6f, 7e-6f}, {-6e-6f, 4e-6f, -3e-6f}, {5e-6f, -7e-6f, 4e-6f}}; + for (const glm::vec3& d : nudge) { + Mesh moved = b; + for (glm::vec3& v : moved.verts) v += d; + if (Mesh again = combineOnce(a, moved, op); !again.faces.empty() && sealed(again)) { + out = std::move(again); + break; + } + } + } + a = std::move(out); return !a.faces.empty(); } diff --git a/src/icons.cpp b/src/icons.cpp index bf0804e..c8fbb4e 100644 --- a/src/icons.cpp +++ b/src/icons.cpp @@ -213,6 +213,24 @@ void drawIcon(ImDrawList* dl, Icon icon, ImVec2 o, float s, ImU32 c) { line(0.62f, 0.34f, 0.84f, 0.16f, t * 0.8f), tri(0.92f, 0.08f, 0.78f, 0.12f, 0.88f, 0.22f); break; } + case Icon::Eye: case Icon::EyeShut: // an eye: open, or shut with its lashes + if (icon == Icon::Eye) { + dl->AddBezierQuadratic(P(0.04f, 0.5f), P(0.5f, 0.02f), P(0.96f, 0.5f), c, t); + dl->AddBezierQuadratic(P(0.04f, 0.5f), P(0.5f, 0.98f), P(0.96f, 0.5f), c, t); + dot(0.5f, 0.5f, 0.15f); + } else { + dl->AddBezierQuadratic(P(0.04f, 0.42f), P(0.5f, 0.86f), P(0.96f, 0.42f), c, t); + line(0.26f, 0.60f, 0.18f, 0.76f), line(0.5f, 0.66f, 0.5f, 0.84f), line(0.74f, 0.60f, 0.82f, 0.76f); + } + break; + case Icon::Lock: case Icon::Unlock: { // a padlock: the shackle down in it, or up and open + dl->AddRectFilled(P(0.24f, 0.46f), P(0.76f, 0.88f), c, s * 0.08f); + const float lift = icon == Icon::Unlock ? 0.12f : 0.0f; + dl->PathArcTo(P(0.5f, 0.38f - lift), 0.17f * s, 3.14159f, 3.14159f * 2.0f, 12); + dl->PathStroke(c, 0, t); + line(0.33f, 0.38f - lift, 0.33f, 0.47f - (lift > 0 ? lift : 0)), line(0.67f, 0.38f - lift, 0.67f, 0.47f); + break; + } case Icon::LayFlat: quad(0.22f, 0.40f, 0.62f, 0.22f, 0.86f, 0.50f, 0.46f, 0.68f, false, c); line(0.10f, 0.88f, 0.90f, 0.88f); diff --git a/src/icons.h b/src/icons.h index 4b89852..cb3f9ff 100644 --- a/src/icons.h +++ b/src/icons.h @@ -4,7 +4,7 @@ namespace fry { -enum class Icon { Select, Move, Rotate, Scale, Draw, Extrude, Inset, Bevel, LoopCut, Objects, Shape, Points, Edges, Faces, Box, Brush, Lasso, Add, Knife, Hole, PaintBrush, Eraser, Fill, Picker, Picture, Render, Layout, Print, Ruler, LayFlat, Sculpt, Inflate, Smooth, Flatten, Pinch, Grab }; +enum class Icon { Select, Move, Rotate, Scale, Draw, Extrude, Inset, Bevel, LoopCut, Objects, Shape, Points, Edges, Faces, Box, Brush, Lasso, Add, Knife, Hole, PaintBrush, Eraser, Fill, Picker, Picture, Render, Layout, Print, Ruler, LayFlat, Sculpt, Inflate, Smooth, Flatten, Pinch, Grab, Eye, EyeShut, Lock, Unlock }; void drawIcon(ImDrawList* dl, Icon icon, ImVec2 topLeft, float size, ImU32 color); diff --git a/tests/mesh_test.cpp b/tests/mesh_test.cpp index 6bf0045..e02f371 100644 --- a/tests/mesh_test.cpp +++ b/tests/mesh_test.cpp @@ -362,6 +362,42 @@ 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); } + // Shapes drawn and cut in, again and again inside the last cut, on a turned object (the shape + // carried into its own space, as the Draw tool does). This once built its tree forever and + // crashed; and the cutting's rounding left small gaps. + { + uint32_t seed = 12345; + auto rnd = [&] { return (seed = seed * 1664525u + 1013904223u) / 4294967296.0f * 2 - 1; }; // (the same everywhere: -1..1) + int cuts = 0, sealedCuts = 0; + for (int run = 0; run < 12; ++run) { + Mesh m = makeCube(1.0f); + const glm::vec3 axis = glm::normalize(glm::vec3(rnd(), rnd(), rnd() + 2)); + const glm::mat4 M = glm::translate(glm::mat4(1), glm::vec3(rnd(), rnd(), 0.5f)) * glm::rotate(glm::mat4(1), rnd() * 3, axis); + float top = 0.5f; // (the floor of the last cut, the object's own space) + for (int k = 0; k < 5; ++k) { + const glm::vec3 P = glm::vec3(M * glm::vec4(rnd() * 0.03f, rnd() * 0.03f, top, 1)), N = glm::normalize(glm::mat3(M) * glm::vec3(0, 0, 1)); + const glm::vec3 X = glm::normalize(glm::cross(N, glm::vec3(0.3f, 0.2f, 0.9f))), Y = glm::cross(N, X); + const int corners = 3 + k % 4; + const float r = 0.25f - 0.04f * float(k); + std::vector pts; + glm::vec3 c(0.0f); + for (int i = 0; i < corners; ++i) { + const float t = 6.2831f * float(i) / float(corners) + rnd() * 0.3f; + pts.push_back(P + X * std::cos(t) * r + Y * std::sin(t) * r), c += pts.back(); + } + c /= float(corners); + for (glm::vec3& q : pts) q -= c; + const float thick = 0.05f + 0.0002f; + Mesh shape = extrudeOutline(pts, N, thick); + transform(shape, glm::inverse(M) * glm::translate(glm::mat4(1), c + N * (0.0001f - thick / 2))); + Mesh was = m; + if (!combine(m, shape, Combine::Subtract)) m = was; + ++cuts, sealedCuts += checkForPrint(m).closed(); + top -= 0.05f; + } + } + CHECK(sealedCuts >= cuts - 2, "cuts drawn inside cuts: no crash, %d of %d closed after", sealedCuts, cuts); + } // An edge's ring: across a cube's side, the four edges going round it. { const Mesh c = makeCube(1.0f);