// Fry3DX: the panels: layers, properties, menus, the commands and their keys. #include "app.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"); if (ImGui::MenuItem("About Fry3DX")) 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