From Blender and Maya: soft selection in shape editing (Shift+O; near points follow, the wheel while dragging sets how far), push out / pull in along normals (Alt+S), smooth points, bridge two faces with a tunnel, show only the selected (/ or Shift+H); fry_shapeop for scripts and tests; tests

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Nexoniarz 2026-10-20 19:08:23 +02:00
commit 3f23371eac
4 changed files with 268 additions and 6 deletions

View file

@ -347,8 +347,11 @@ public:
self->enterEdit(); self->enterEdit();
if (a.size() > 1) self->elem_ = a[1] == "vertex" ? Elem::Vertex : a[1] == "edge" ? Elem::Edge : Elem::Face; if (a.size() > 1) self->elem_ = a[1] == "vertex" ? Elem::Vertex : a[1] == "edge" ? Elem::Edge : Elem::Face;
if (a.size() > 2 && a[2] == "all") self->selectAllElements(); if (a.size() > 2 && a[2] == "all") self->selectAllElements();
if (a.size() > 2 && a[2] != "all") self->selFaces_ = {std::stoi(a[2])}; if (a.size() > 2 && a[2] != "all") {
}, "Fry3DX: fry_edit [vertex|edge|face] [all|face number]"); self->selFaces_.clear();
for (size_t i = 2; i < a.size(); ++i) self->selFaces_.insert(std::stoi(a[i]));
}
}, "Fry3DX: fry_edit [vertex|edge|face] [all|face numbers...]");
static keel::Cmd tool("fry_tool", [](const keel::Args& a) { static keel::Cmd tool("fry_tool", [](const keel::Args& a) {
for (const ToolInfo& t : kTools) for (const ToolInfo& t : kTools)
if (a.size() > 1 && SDL_strcasecmp(a[1].c_str(), t.name) == 0) self->setTool(t.tool); if (a.size() > 1 && SDL_strcasecmp(a[1].c_str(), t.name) == 0) self->setTool(t.tool);
@ -549,6 +552,32 @@ public:
static keel::Cmd closeAsk("fry_close", [](const keel::Args&) { if (self->allowQuit()) keel::execute("quit"); }, "Fry3DX: as the window's close button"); static keel::Cmd closeAsk("fry_close", [](const keel::Args&) { if (self->allowQuit()) keel::execute("quit"); }, "Fry3DX: as the window's close button");
static keel::Cmd recover("fry_recover", [](const keel::Args&) { self->findAutosaves(), self->showRecover_ = !self->recover_.empty(); }, "Fry3DX: show the autosaves to recover"); static keel::Cmd recover("fry_recover", [](const keel::Args&) { self->findAutosaves(), self->showRecover_ = !self->recover_.empty(); }, "Fry3DX: show the autosaves to recover");
static keel::Cmd scriptPanel("fry_scriptpanel", [](const keel::Args& a) { self->showScript_ = a.size() < 2 || a[1] != "0"; }, "Fry3DX: show (1) or hide (0) the Script panel"); static keel::Cmd scriptPanel("fry_scriptpanel", [](const keel::Args& a) { self->showScript_ = a.size() < 2 || a[1] != "0"; }, "Fry3DX: show (1) or hide (0) the Script panel");
static keel::Cmd shapeOp("fry_shapeop", [](const keel::Args& a) {
if (a.size() < 2) return;
if (a[1] == "smooth") self->smoothPointsNow();
else if (a[1] == "bridge") self->bridgeNow();
else if (a[1] == "push" && a.size() > 2) {
self->startPush();
if (Object* o = self->find(self->pushObject_); o && self->showPush_) {
self->pushBy_ = std::stof(a[2]);
pushPoints(o->mesh, self->pushPts_, self->pushBy_);
self->changedShape(*o, true);
self->showPush_ = false;
}
} else if (a[1] == "move" && a.size() > 4) { // the selection (and with soft selection, what's near) moved, world metres
Object* o = self->editing();
if (!o) return;
self->pushUndo();
self->drag_.soft.clear();
if (self->softSel_) self->findSoft();
const glm::mat4 m = objectMatrix(*o);
const glm::mat4 local = glm::inverse(m) * glm::translate(glm::mat4(1.0f), glm::vec3(std::stof(a[2]), std::stof(a[3]), std::stof(a[4]))) * m;
for (int v : self->selectedPoints()) o->mesh.verts[size_t(v)] = glm::vec3(local * glm::vec4(o->mesh.verts[size_t(v)], 1.0f));
for (const auto& [v, w] : self->drag_.soft) o->mesh.verts[size_t(v)] = glm::mix(o->mesh.verts[size_t(v)], glm::vec3(local * glm::vec4(o->mesh.verts[size_t(v)], 1.0f)), w);
self->drag_.soft.clear();
self->changedShape(*o, true);
} else if (a[1] == "soft") self->softSel_ = a.size() < 3 || a[2] != "0", self->softReach_ = a.size() > 3 ? std::stof(a[3]) : self->softReach_;
}, "Fry3DX: shape editing: fry_shapeop smooth | bridge | push metres | soft 1 [reach] | move x y z");
static keel::Cmd about("fry_about", [](const keel::Args&) { self->showAbout_ = true; }, "Fry3DX: show the About window"); static keel::Cmd about("fry_about", [](const keel::Args&) { self->showAbout_ = true; }, "Fry3DX: show the About window");
static keel::Cmd plugins("fry_plugins", [](const keel::Args& a) { static keel::Cmd plugins("fry_plugins", [](const keel::Args& a) {
if (a.size() > 1 && a[1] == "reload") self->loadPlugins(); if (a.size() > 1 && a[1] == "reload") self->loadPlugins();
@ -629,6 +658,7 @@ public:
commandForm(); commandForm();
copiesWindow(); copiesWindow();
thickenWindow(); thickenWindow();
pushWindow();
saveQuestion(); saveQuestion();
recoverWindow(); recoverWindow();
if (!askSave_ && !showRecover_) shortcuts(io); if (!askSave_ && !showRecover_) shortcuts(io);
@ -708,7 +738,18 @@ private:
glm::vec3 startHit{0.0f}; glm::vec3 startHit{0.0f};
Scene before; Scene before;
std::set<std::pair<int, int>> bevelEdges; std::set<std::pair<int, int>> bevelEdges;
std::vector<std::pair<int, float>> soft; // points near the selection that follow it, and how much
} drag_; } drag_;
// Soft selection (shape editing): points within `softReach_` of the selection follow it, less
// the further they are (Blender's proportional editing, Maya's soft select).
bool softSel_ = false;
float softReach_ = 0.3f;
// Push out / pull in (Alt+S): the points and the shape as it was when it started.
bool showPush_ = false;
float pushBy_ = 0.0f;
std::set<int> pushPts_;
Mesh pushFrom_;
uint32_t pushObject_ = 0;
bool boxing_ = false, painting_ = false, lassoing_ = false, tapDraw_ = false; bool boxing_ = false, painting_ = false, lassoing_ = false, tapDraw_ = false;
ImVec2 boxStart_{0, 0}; ImVec2 boxStart_{0, 0};
std::vector<ImVec2> lasso_; std::vector<ImVec2> lasso_;
@ -1682,6 +1723,59 @@ private:
mergeToCenter(o->mesh, pts); mergeToCenter(o->mesh, pts);
changedShape(*o); changedShape(*o);
} }
void smoothPointsNow() {
Object* o = editing();
const std::set<int> pts = selectedPoints();
if (!o || pts.empty()) return say("Select points (or faces, edges) to smooth");
pushUndo();
smoothPoints(o->mesh, pts, 0.5f, 2);
changedShape(*o, true);
say("Smoothed (again for more)");
}
void startPush() {
Object* o = editing();
if (!o || selectedPoints().empty()) return say("Select points (or faces) to push out or pull in");
pushUndo();
pushPts_ = selectedPoints(), pushFrom_ = o->mesh, pushObject_ = o->id, pushBy_ = 0.0f, showPush_ = true;
}
void pushWindow() {
if (!showPush_) return;
Object* o = find(pushObject_);
if (!o || mode_ != Mode::Edit) return void(showPush_ = false);
ImGui::SetNextWindowPos(ImGui::GetMainViewport()->GetCenter(), ImGuiCond_Appearing, ImVec2(0.5f, 0.5f));
bool open = true;
if (!ImGui::Begin("Push out, pull in", &open, ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoDocking)) return ImGui::End();
const float w = 260;
float mm = pushBy_ * 1000;
ImGui::SetNextItemWidth(w);
if (ImGui::IsWindowAppearing()) ImGui::SetKeyboardFocusHere();
if (ImGui::DragFloat("##by", &mm, 0.5f, -1e5f, 1e5f, "%.1f mm (- pulls in)")) {
pushBy_ = mm / 1000;
o->mesh = pushFrom_;
pushPoints(o->mesh, pushPts_, pushBy_);
changedShape(*o, true);
}
ImGui::TextWrapped("Each point along the surface's normal there: fatter or thinner, the same shape.");
pushAccent();
const bool done = ImGui::Button("Done", ImVec2(w, 0)) || ImGui::IsKeyPressed(ImGuiKey_Enter, false);
popAccent();
ImGui::End();
if (done) showPush_ = false;
if (!open) o->mesh = pushFrom_, changedShape(*o, true), showPush_ = false; // (closed: as it was)
}
void bridgeNow() {
Object* o = editing();
if (!o || selFaces_.size() != 2) return say("Select two faces to bridge (Faces, 3; Join two objects first if they're apart)");
const int a = *selFaces_.begin(), b = *selFaces_.rbegin();
if (o->mesh.faces[size_t(a)].size() != o->mesh.faces[size_t(b)].size())
return say(std::format("Those faces have {} and {} corners: bridging needs as many on each", o->mesh.faces[size_t(a)].size(), o->mesh.faces[size_t(b)].size()));
pushUndo();
const std::vector<int> made = bridgeFaces(o->mesh, a, b);
if (made.empty()) return undo(), redo_.clear(), say("Couldn't bridge those (they share a point)");
changedShape(*o);
selFaces_.insert(made.begin(), made.end());
say(std::format("Bridged: {} faces between them", made.size()));
}
void flipNow() { void flipNow() {
Object* o = editing(); Object* o = editing();
if (!o || selFaces_.empty()) return say("Select faces to flip"); if (!o || selFaces_.empty()) return say("Select faces to flip");
@ -3662,7 +3756,11 @@ private:
const ImVec2 d = io.MouseDelta; const ImVec2 d = io.MouseDelta;
if (orbiting_ && (d.x != 0 || d.y != 0)) orbitBy(d); if (orbiting_ && (d.x != 0 || d.y != 0)) orbitBy(d);
if (panning_ && (d.x != 0 || d.y != 0)) panBy(d); if (panning_ && (d.x != 0 || d.y != 0)) panBy(d);
if (inView && io.MouseWheel != 0) { if (inView && io.MouseWheel != 0 && drag_.active && softSel_ && mode_ == Mode::Edit) { // the soft selection's reach, not zoom
softReach_ = std::clamp(softReach_ * std::pow(1.15f, io.MouseWheel), 0.001f, 1000.0f);
findSoft();
message_ = std::format("Soft selection reaches {:.3f} m (wheel)", softReach_), messageAt_ = now();
} else if (inView && io.MouseWheel != 0) {
// Towards what's under the mouse: the target slides that way a little. // Towards what's under the mouse: the target slides that way a little.
const float k = std::pow(0.85f, io.MouseWheel); const float k = std::pow(0.85f, io.MouseWheel);
const Ray ray = mouseRay(io.MousePos); const Ray ray = mouseRay(io.MousePos);
@ -4110,6 +4208,7 @@ private:
drag_.startMouse = io.MousePos; drag_.startMouse = io.MousePos;
drag_.before = scene_; drag_.before = scene_;
pushUndo(); pushUndo();
if (softSel_ && (tool_ == Tool::Move || tool_ == Tool::Rotate || tool_ == Tool::Scale)) findSoft();
const Ray ray = mouseRay(io.MousePos); const Ray ray = mouseRay(io.MousePos);
if (tool_ == Tool::Extrude || tool_ == Tool::Inset) { if (tool_ == Tool::Extrude || tool_ == Tool::Inset) {
glm::vec3 at, n; glm::vec3 at, n;
@ -4149,6 +4248,36 @@ private:
if (handle < 3) drag_.startT = closestOnAxis(ray, drag_.pivot, a[size_t(handle)]); if (handle < 3) drag_.startT = closestOnAxis(ray, drag_.pivot, a[size_t(handle)]);
if (auto hit = hitPlane(ray, drag_.pivot, -viewDir())) drag_.startHit = *hit; if (auto hit = hitPlane(ray, drag_.pivot, -viewDir())) drag_.startHit = *hit;
} }
// The points within reach of the selection (in the world: the object's size counts), each with
// how much it follows: a grid of the selected points, so a big mesh stays quick.
void findSoft() {
drag_.soft.clear();
const Object* o = editing();
if (!o) return;
const std::set<int> pts = selectedPoints();
if (pts.empty()) return;
const glm::mat4 m = objectMatrix(*o);
const float r = std::max(softReach_, 1e-5f);
auto cell = [&](const glm::vec3& p) { return glm::ivec3(glm::floor(p / r)); };
auto keyOf = [](glm::ivec3 c) { return (int64_t(c.x & 0x1FFFFF) << 42) | (int64_t(c.y & 0x1FFFFF) << 21) | int64_t(c.z & 0x1FFFFF); };
std::unordered_map<int64_t, std::vector<glm::vec3>> grid;
for (int v : pts) {
const glm::vec3 w(m * glm::vec4(o->mesh.verts[size_t(v)], 1.0f));
grid[keyOf(cell(w))].push_back(w);
}
for (size_t v = 0; v < o->mesh.verts.size(); ++v) {
if (pts.count(int(v))) continue;
const glm::vec3 w(m * glm::vec4(o->mesh.verts[v], 1.0f));
const glm::ivec3 c = cell(w);
float best = r;
for (int dx = -1; dx <= 1; ++dx)
for (int dy = -1; dy <= 1; ++dy)
for (int dz = -1; dz <= 1; ++dz)
if (auto it = grid.find(keyOf(c + glm::ivec3(dx, dy, dz))); it != grid.end())
for (const glm::vec3& q : it->second) best = std::min(best, glm::distance(w, q));
if (best < r) drag_.soft.push_back({int(v), sculptFalloff(best, r)});
}
}
void cancelDrag() { void cancelDrag() {
if (!drag_.active) return; if (!drag_.active) return;
scene_ = drag_.before; scene_ = drag_.before;
@ -4249,6 +4378,10 @@ private:
if (Object* o = editing()) { if (Object* o = editing()) {
const glm::mat4 m = objectMatrix(*o), local = glm::inverse(m) * T * m; const glm::mat4 m = objectMatrix(*o), local = glm::inverse(m) * T * m;
for (int v : selectedPoints()) o->mesh.verts[size_t(v)] = glm::vec3(local * glm::vec4(o->mesh.verts[size_t(v)], 1.0f)); for (int v : selectedPoints()) o->mesh.verts[size_t(v)] = glm::vec3(local * glm::vec4(o->mesh.verts[size_t(v)], 1.0f));
for (const auto& [v, w] : drag_.soft) { // (part of the way, by how near)
glm::vec3& p = o->mesh.verts[size_t(v)];
p = glm::mix(p, glm::vec3(local * glm::vec4(p, 1.0f)), w);
}
handMade(*o); handMade(*o);
touched(o->id); touched(o->id);
return; return;
@ -4628,6 +4761,7 @@ private:
return; return;
} }
if (alt) { if (alt) {
if (key(ImGuiKey_S) && mode_ == Mode::Edit) startPush();
if (key(ImGuiKey_A)) selectNone(); if (key(ImGuiKey_A)) selectNone();
if (key(ImGuiKey_Z)) xray_ = !xray_; if (key(ImGuiKey_Z)) xray_ = !xray_;
if (key(ImGuiKey_H)) if (key(ImGuiKey_H))
@ -4646,7 +4780,7 @@ private:
if (key(ImGuiKey_B)) setTool(Tool::Bevel); if (key(ImGuiKey_B)) setTool(Tool::Bevel);
if (key(ImGuiKey_C)) setTool(Tool::LoopCut); if (key(ImGuiKey_C)) setTool(Tool::LoopCut);
if (key(ImGuiKey_K)) setTool(Tool::Knife); if (key(ImGuiKey_K)) setTool(Tool::Knife);
if (key(ImGuiKey_O)) setTool(Tool::Hole); if (key(ImGuiKey_O) && !shift) setTool(Tool::Hole);
if (key(ImGuiKey_Tab)) mode_ == Mode::Edit ? leaveEdit() : enterEdit(); if (key(ImGuiKey_Tab)) mode_ == Mode::Edit ? leaveEdit() : enterEdit();
if (key(ImGuiKey_Delete) || key(ImGuiKey_Backspace)) deleteSelected(); if (key(ImGuiKey_Delete) || key(ImGuiKey_Backspace)) deleteSelected();
if (key(ImGuiKey_F)) frameSelected(); if (key(ImGuiKey_F)) frameSelected();
@ -4668,9 +4802,12 @@ private:
if (key(ImGuiKey_2)) elem_ = Elem::Edge, clearElements(); if (key(ImGuiKey_2)) elem_ = Elem::Edge, clearElements();
if (key(ImGuiKey_3)) elem_ = Elem::Face, clearElements(); if (key(ImGuiKey_3)) elem_ = Elem::Face, clearElements();
} }
if (key(ImGuiKey_H)) if (key(ImGuiKey_H) && shift && !selected_.empty()) showOnly(selected_); // (the rest hidden)
else if (key(ImGuiKey_H))
for (Object& o : scene_.objects) for (Object& o : scene_.objects)
if (selected_.count(o.id) && mode_ == Mode::Object) o.visible = false; if (selected_.count(o.id) && mode_ == Mode::Object) o.visible = false;
if ((key(ImGuiKey_Slash) || key(ImGuiKey_KeypadDivide)) && mode_ == Mode::Object && !selected_.empty()) showOnly(selected_);
if (key(ImGuiKey_O) && shift) softSel_ = !softSel_, say(softSel_ ? "Soft selection on: points near follow (the wheel while dragging: how far)" : "Soft selection off");
if (key(ImGuiKey_Keypad1)) setView(shift ? View::Back : View::Front); if (key(ImGuiKey_Keypad1)) setView(shift ? View::Back : View::Front);
if (key(ImGuiKey_Keypad3)) setView(shift ? View::Right : View::Left); if (key(ImGuiKey_Keypad3)) setView(shift ? View::Right : View::Left);
if (key(ImGuiKey_Keypad7)) setView(shift ? View::Bottom : View::Top); if (key(ImGuiKey_Keypad7)) setView(shift ? View::Bottom : View::Top);
@ -5062,6 +5199,11 @@ private:
void gizmo(ImDrawList* dl, const ImGuiIO& io) { void gizmo(ImDrawList* dl, const ImGuiIO& io) {
const int hot = mouseInView(io) || drag_.active ? hoverHandle(io.MousePos) : -1; const int hot = mouseInView(io) || drag_.active ? hoverHandle(io.MousePos) : -1;
if (softSel_ && mode_ == Mode::Edit && (tool_ == Tool::Move || tool_ == Tool::Rotate || tool_ == Tool::Scale) && !selectedPoints().empty()) {
const glm::vec3 at = drag_.active ? drag_.pivot : pivotOfSelection();
ImVec2 c;
if (project(at, c)) dl->AddCircle(c, softReach_ / std::max(pixelSize(at), 1e-9f), keel::ui::accent(0.8f), 64, 1.5f);
}
if (tool_ == Tool::Extrude || tool_ == Tool::Inset) { if (tool_ == Tool::Extrude || tool_ == Tool::Inset) {
glm::vec3 at, n; glm::vec3 at, n;
if (!faceHandle(at, n)) return; if (!faceHandle(at, n)) return;
@ -5290,6 +5432,11 @@ private:
if (ImGui::MenuItem("Merge points", "M")) mergeNow(); if (ImGui::MenuItem("Merge points", "M")) mergeNow();
if (ImGui::MenuItem("Make a face")) makeFaceNow(); if (ImGui::MenuItem("Make a face")) makeFaceNow();
if (ImGui::MenuItem("Flip faces")) flipNow(); if (ImGui::MenuItem("Flip faces")) flipNow();
ImGui::Separator();
if (ImGui::MenuItem("Smooth points")) smoothPointsNow();
if (ImGui::MenuItem("Push out / pull in...", "Alt+S")) startPush();
if (ImGui::MenuItem("Bridge two faces")) bridgeNow();
ImGui::MenuItem("Soft selection", "Shift+O", &softSel_);
if (ImGui::MenuItem("Make into its own object", "P")) separateSelected(); if (ImGui::MenuItem("Make into its own object", "P")) separateSelected();
if (ImGui::MenuItem("Delete", "Del")) deleteElements(); if (ImGui::MenuItem("Delete", "Del")) deleteElements();
ImGui::EndDisabled(); ImGui::EndDisabled();
@ -5319,6 +5466,7 @@ private:
ImGui::Separator(); ImGui::Separator();
if (ImGui::MenuItem("Frame selected", "F")) frameSelected(); if (ImGui::MenuItem("Frame selected", "F")) frameSelected();
if (ImGui::MenuItem("Frame everything", "Home")) frameAll(); if (ImGui::MenuItem("Frame everything", "Home")) frameAll();
if (ImGui::MenuItem("Show only the selected (again: all)", "/", false, !selected_.empty())) showOnly(selected_);
ImGui::EndMenu(); ImGui::EndMenu();
} }
if (ImGui::BeginMenu("Script")) { if (ImGui::BeginMenu("Script")) {
@ -5652,6 +5800,15 @@ private:
} }
} }
void softChoice(float w) {
if (mode_ != Mode::Edit) return;
ImGui::Checkbox("Soft selection (Shift+O)", &softSel_);
if (ImGui::IsItemHovered()) ImGui::SetTooltip("Points near the selection follow it, less the further they are");
if (!softSel_) return;
ImGui::SetNextItemWidth(w);
ImGui::DragFloat("##reach", &softReach_, 0.002f, 0.001f, 1000.0f, "reaches %.3f m");
ImGui::TextDisabled("(the wheel while dragging too)");
}
void toolOptions() { void toolOptions() {
ImGui::Begin("Tool options", nullptr, ImGuiWindowFlags_NoCollapse); ImGui::Begin("Tool options", nullptr, ImGuiWindowFlags_NoCollapse);
const float w = ImGui::GetContentRegionAvail().x; const float w = ImGui::GetContentRegionAvail().x;
@ -5708,6 +5865,7 @@ private:
case Tool::Move: case Tool::Move:
orientChoice(); orientChoice();
snapChoice(false); snapChoice(false);
softChoice(w);
break; break;
case Tool::Rotate: { case Tool::Rotate: {
orientChoice(); orientChoice();
@ -5716,6 +5874,7 @@ private:
ImGui::SetNextItemWidth(w); ImGui::SetNextItemWidth(w);
if (ImGui::Combo("##rpivot", &p, "The middle\0The origin\0")) rotatePivot_ = p ? Pivot::Origin : Pivot::Middle; if (ImGui::Combo("##rpivot", &p, "The middle\0The origin\0")) rotatePivot_ = p ? Pivot::Origin : Pivot::Middle;
snapChoice(true); snapChoice(true);
softChoice(w);
break; break;
} }
case Tool::Scale: { case Tool::Scale: {
@ -5728,6 +5887,7 @@ private:
if (ImGui::IsItemHovered()) 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."); 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); snapChoice(false);
softChoice(w);
break; break;
} }
case Tool::Draw: case Tool::Draw:
@ -6394,6 +6554,8 @@ private:
if (ImGui::Button("Flip", ImVec2((w - 6) / 2, 0))) flipNow(); if (ImGui::Button("Flip", ImVec2((w - 6) / 2, 0))) flipNow();
ImGui::SameLine(0, 6); ImGui::SameLine(0, 6);
if (ImGui::Button("Make face", ImVec2(-1, 0))) makeFaceNow(); 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(); if (ImGui::Button("Make into its own object (P)", ImVec2(w, 0))) separateSelected();
ImGui::SeparatorText("Edges"); ImGui::SeparatorText("Edges");
ImGui::SetNextItemWidth(w * 0.45f); ImGui::SetNextItemWidth(w * 0.45f);
@ -6406,6 +6568,10 @@ private:
if (ImGui::IsItemHovered()) ImGui::SetTooltip("Round the selected edges (or the edges round the selected faces): width, steps"); if (ImGui::IsItemHovered()) ImGui::SetTooltip("Round the selected edges (or the edges round the selected faces): width, steps");
ImGui::SeparatorText("Points"); ImGui::SeparatorText("Points");
if (ImGui::Button("Merge to middle (M)", ImVec2(w, 0))) mergeNow(); 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::SetNextItemWidth(w * 0.45f);
ImGui::DragFloat("##md", &mergeDistance_, 0.0001f, 0.0f, 1.0f, "%.4f m"); ImGui::DragFloat("##md", &mergeDistance_, 0.0001f, 0.0f, 1.0f, "%.4f m");
ImGui::SameLine(); ImGui::SameLine();
@ -6917,7 +7083,8 @@ private:
rows("Objects and shape", {{"Tab / double-click", "Edit an object's shape"}, {"1 / 2 / 3", "Points / edges / faces (shape editing)"}, {"M", "Merge points"}, rows("Objects and shape", {{"Tab / double-click", "Edit an object's shape"}, {"1 / 2 / 3", "Points / edges / faces (shape editing)"}, {"M", "Merge points"},
{"P", "Selected faces into their own object"}, {"Shift + Scale drag", "Scale both sides (from the middle)"}, {"Ctrl+G", "Group into a folder"}, {"P", "Selected faces into their own object"}, {"Shift + Scale drag", "Scale both sides (from the middle)"}, {"Ctrl+G", "Group into a folder"},
{"Ctrl+Shift + / -", "Add together / take the others out (last picked kept)"}, {"Del", "Delete"}, {"Ctrl+D", "Duplicate"}, {"Ctrl+J", "Join objects into one"}, {"Ctrl+Shift + / -", "Add together / take the others out (last picked kept)"}, {"Del", "Delete"}, {"Ctrl+D", "Duplicate"}, {"Ctrl+J", "Join objects into one"},
{"H / Alt+H", "Hide / show all"}}); {"H / Alt+H", "Hide / show all"}, {"/ or Shift+H", "Show only the selected (again: all)"},
{"Shift+O", "Soft selection: near points follow (wheel while dragging: how far)"}, {"Alt+S", "Push points out / pull in (shape)"}});
rows("Files", {{"Ctrl+N / O / S", "New / open / save"}, {"Ctrl+Shift+S", "Save as"}, {"Ctrl+E", "Export (GLB, glTF, FBX, OBJ, 3MF, STL, PLY)"}, {"F12", "Save a picture of the view"}, rows("Files", {{"Ctrl+N / O / S", "New / open / save"}, {"Ctrl+Shift+S", "Save as"}, {"Ctrl+E", "Export (GLB, glTF, FBX, OBJ, 3MF, STL, PLY)"}, {"F12", "Save a picture of the view"},
{"Ctrl+Z / Ctrl+Y", "Undo / redo"}}); {"Ctrl+Z / Ctrl+Y", "Undo / redo"}});
ImGui::End(); ImGui::End();

View file

@ -131,6 +131,17 @@ std::set<int> linkedVerts(const Mesh& m, const std::set<int>& verts);
// The points next to these (by an edge), and these. // The points next to these (by an edge), and these.
std::set<int> growVerts(const Mesh& m, const std::set<int>& verts); std::set<int> growVerts(const Mesh& m, const std::set<int>& verts);
// Points moved towards their neighbours' middle (`amount` 0..1, a few times for more): bumps
// evened out (Blender's Smooth Vertices, Maya's Average Vertices).
void smoothPoints(Mesh& m, const std::set<int>& verts, float amount = 0.5f, int times = 1);
// Points pushed out along the surface's normal there (negative: pulled in): fatter or thinner,
// keeping the shape (Blender's Shrink/Fatten).
void pushPoints(Mesh& m, const std::set<int>& verts, float distance);
// Two faces with as many corners each taken out and joined by a tunnel of faces between their
// edges, each corner to the nearest one round (Bridge, as in Blender and Maya). The new faces;
// empty (and nothing changed) if they aren't two faces with as many corners.
std::vector<int> bridgeFaces(Mesh& m, int a, int b);
// ── Whole shapes ── // ── Whole shapes ──
// Faces next to each other lying flat together (normals within `angle` degrees) joined into // Faces next to each other lying flat together (normals within `angle` degrees) joined into

View file

@ -488,4 +488,57 @@ std::set<std::pair<int, int>> edgeRing(const Mesh& m, std::pair<int, int> edge)
return out; return out;
} }
void smoothPoints(Mesh& m, const std::set<int>& verts, float amount, int times) {
std::vector<std::vector<int>> next(m.verts.size());
for (const auto& [a, b] : edges(m)) next[size_t(a)].push_back(b), next[size_t(b)].push_back(a);
for (int t = 0; t < times; ++t) {
std::vector<std::pair<int, glm::vec3>> moved; // (from where they all were before this pass)
for (int v : verts) {
if (v < 0 || size_t(v) >= m.verts.size() || next[size_t(v)].empty()) continue;
glm::vec3 mid(0.0f);
for (int n : next[size_t(v)]) mid += m.verts[size_t(n)];
mid /= float(next[size_t(v)].size());
moved.push_back({v, glm::mix(m.verts[size_t(v)], mid, std::clamp(amount, 0.0f, 1.0f))});
}
for (const auto& [v, p] : moved) m.verts[size_t(v)] = p;
}
}
void pushPoints(Mesh& m, const std::set<int>& verts, float distance) {
std::vector<glm::vec3> normal(m.verts.size(), glm::vec3(0.0f));
for (size_t f = 0; f < m.faces.size(); ++f) {
const glm::vec3 n = faceNormal(m, int(f)) * faceArea(m, int(f));
for (int v : m.faces[f]) normal[size_t(v)] += n;
}
for (int v : verts)
if (v >= 0 && size_t(v) < m.verts.size() && glm::length(normal[size_t(v)]) > 1e-20f) m.verts[size_t(v)] += glm::normalize(normal[size_t(v)]) * distance;
}
std::vector<int> bridgeFaces(Mesh& m, int a, int b) {
if (a == b || a < 0 || b < 0 || size_t(a) >= m.faces.size() || size_t(b) >= m.faces.size()) return {};
const std::vector<int> A = m.faces[size_t(a)];
std::vector<int> B = m.faces[size_t(b)];
const size_t n = A.size();
if (B.size() != n || n < 3) return {};
for (int v : A)
if (std::find(B.begin(), B.end(), v) != B.end()) return {}; // (sharing a point: nothing to bridge)
// B the other way round (seen down the tunnel, the same way as A), turned to fit A best.
std::reverse(B.begin(), B.end());
size_t shift = 0;
float best = INFINITY;
for (size_t s = 0; s < n; ++s) {
float d = 0.0f;
for (size_t i = 0; i < n; ++i) d += glm::distance(m.verts[size_t(A[i])], m.verts[size_t(B[(i + s) % n])]);
if (d < best) best = d, shift = s;
}
m.faces.erase(m.faces.begin() + std::max(a, b)), m.faces.erase(m.faces.begin() + std::min(a, b));
std::vector<int> made;
for (size_t i = 0; i < n; ++i) {
// Along A's edge the way A went (it takes A's place there; A's neighbour runs it the other way).
made.push_back(int(m.faces.size()));
m.faces.push_back({A[i], A[(i + 1) % n], B[(i + 1 + shift) % n], B[(i + shift) % n]});
}
return made;
}
} // namespace fry } // namespace fry

View file

@ -433,6 +433,37 @@ int main() {
const std::string text((std::istreambuf_iterator<char>(j)), {}); const std::string text((std::istreambuf_iterator<char>(j)), {});
CHECK(text.find("\"translation\":[2,3,1]") != std::string::npos, "in glTF's axes (Y up): the position is [2,3,1]"); CHECK(text.find("\"translation\":[2,3,1]") != std::string::npos, "in glTF's axes (Y up): the position is [2,3,1]");
{ // Bridge: two boxes' facing sides joined by a tunnel; one closed shape, every face outwards.
Mesh two = makeCube();
append(two, makeCube(), glm::translate(glm::mat4(1.0f), glm::vec3(3, 0.2f, 0)));
int fa = -1, fb = -1;
for (size_t f = 0; f < two.faces.size(); ++f) {
const glm::vec3 n = faceNormal(two, int(f)), c = faceCenter(two, int(f));
if (n.x > 0.9f && c.x < 1.0f) fa = int(f);
if (n.x < -0.9f && c.x > 2.0f) fb = int(f);
}
const std::vector<int> made = bridgeFaces(two, fa, fb);
const PrintCheck c = checkForPrint(two);
CHECK(made.size() == 4 && c.closed() && c.volume > 2.0f + 2.0f * 0.95f && c.pieces == 1, "bridge: one closed shape (volume %.2f, pieces %d, open %d crowded %d flipped %d)", c.volume, c.pieces, c.openEdges, c.crowdedEdges, c.flippedEdges);
Mesh tri = makeCube();
append(tri, makeCylinder(3, 0.5f, 1.0f), glm::translate(glm::mat4(1.0f), glm::vec3(3, 0, 0)));
CHECK(bridgeFaces(tri, 0, int(tri.faces.size()) - 1).empty() && tri.faces.size() == 6 + 5, "bridge: not between a 4 and a 3 cornered face");
}
{ // Smoothing points evens a bump; pushing a cube's points out makes it bigger all round.
Mesh p = makePlane(2.0f);
for (int i = 0; i < 3; ++i) subdivide(p, false);
int mid = 0;
for (size_t v = 0; v < p.verts.size(); ++v)
if (glm::length(glm::vec2(p.verts[v])) < glm::length(glm::vec2(p.verts[size_t(mid)]))) mid = int(v);
p.verts[size_t(mid)].z = 1.0f;
smoothPoints(p, {mid}, 1.0f);
CHECK(std::abs(p.verts[size_t(mid)].z) < 1e-5f, "smooth: the bump's point back to its neighbours' middle");
Mesh c = makeCube();
std::set<int> all;
for (size_t v = 0; v < c.verts.size(); ++v) all.insert(int(v));
pushPoints(c, all, 0.1f);
CHECK(std::abs(boundsMax(c).x - (0.5f + 0.1f / std::sqrt(3.0f))) < 1e-4f && checkForPrint(c).volume > 1.0f, "push: corners out along their normals");
}
{ // A gear with a screw hole (scripts/gear.lua): cutting leaves points a hair apart and { // A gear with a screw hole (scripts/gear.lua): cutting leaves points a hair apart and
// needles along edges; what comes out must still print (closed, nothing without size). // needles along edges; what comes out must still print (closed, nothing without size).
for (int teeth : {10, 16}) for (int teeth : {10, 16})