diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 697af9d..91121b5 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -1,6 +1,6 @@ # Fry3DX: the app (app.cpp) on the engine, and its modelling core (mesh, scene) as a library the # tests use too. -add_library(fry_core STATIC mesh.cpp tools.cpp cut.cpp uv.cpp image.cpp sticker.cpp shapes.cpp scene.cpp package.cpp export.cpp print.cpp boolean.cpp modify.cpp) +add_library(fry_core STATIC mesh.cpp tools.cpp cut.cpp uv.cpp image.cpp sticker.cpp shapes.cpp scene.cpp package.cpp export.cpp print.cpp boolean.cpp modify.cpp sculpt.cpp) target_include_directories(fry_core PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}) target_link_libraries(fry_core PUBLIC glm::glm libzstd_static) target_compile_definitions(fry_core PUBLIC GLM_ENABLE_EXPERIMENTAL) diff --git a/src/app.cpp b/src/app.cpp index 40a06ce..0e3936c 100644 --- a/src/app.cpp +++ b/src/app.cpp @@ -7,6 +7,7 @@ #include "mesh.h" #include "scene.h" #include "script.h" +#include "sculpt.h" #include "shapes.h" #include @@ -140,6 +141,22 @@ fry.frame() )"; const MaterialInfo kMaterials[] = {{"PLA", 1.24f}, {"PETG", 1.27f}, {"ABS", 1.04f}, {"ASA", 1.07f}, {"TPU", 1.21f}, {"Nylon", 1.14f}, {"Resin", 1.12f}}; +struct SculptToolInfo { + SculptBrush brush; + Icon icon; + const char* name; + const char* key; + const char* hint; +}; +const SculptToolInfo kSculptTools[] = { + {SculptBrush::Draw, Icon::Sculpt, "Raise", "1", "Builds the surface up where you brush. Ctrl digs in instead."}, + {SculptBrush::Inflate, Icon::Inflate, "Puff out", "2", "Swells it out all round, like blowing it up. Ctrl shrinks it."}, + {SculptBrush::Smooth, Icon::Smooth, "Smooth", "3", "Evens out bumps. Hold Shift with any brush to smooth."}, + {SculptBrush::Flatten, Icon::Flatten, "Flatten", "4", "Presses it flat, like a palette knife."}, + {SculptBrush::Pinch, Icon::Pinch, "Pinch", "5", "Pulls it in towards the middle of the brush: sharp creases and ridges. Ctrl pushes apart."}, + {SculptBrush::Grab, Icon::Grab, "Grab", "6", "Take hold of a patch and drag it where it should be."}, +}; + const PaintToolInfo kPaintTools[] = { {PaintTool::Brush, Icon::PaintBrush, "Brush", "B", "Paint on the model or on the flat picture. Alt+click picks a colour; [ and ] change the size."}, {PaintTool::Eraser, Icon::Eraser, "Eraser", "E", "Paints its plain colour back."}, @@ -290,6 +307,20 @@ public: if (a.size() > 2) self->view_ = View::Perspective, self->fov_ = 50, self->yaw_ = std::stof(a[1]), self->pitch_ = std::stof(a[2]); if (a.size() > 3) self->dist_ = std::stof(a[3]); }, "Fry3DX: fry_orbit yaw pitch [distance]"); + // A sculpt stroke across the view (0..1 of it), for pictures of it: brush 1-6, then from and to. + static keel::Cmd sculptStroke("fry_sculpt", [](const keel::Args& a) { + if (a.size() < 6) return; + const int brush = std::clamp(std::stoi(a[1]) - 1, 0, int(std::size(kSculptTools)) - 1); + self->sculptBrush_ = kSculptTools[brush].brush; + const bool other = a.size() > 6 && a[6] == "invert"; + const auto at = [&](float t) { + const ImVec2 lo = self->viewMin_, hi = self->viewMax_; + return ImVec2(lo.x + (hi.x - lo.x) * glm::mix(std::stof(a[2]), std::stof(a[4]), t), lo.y + (hi.y - lo.y) * glm::mix(std::stof(a[3]), std::stof(a[5]), t)); + }; + self->sculptAt(at(0), true, other, false); + for (int i = 1; i <= 60; ++i) self->sculptAt(at(float(i) / 60.0f), false, other, false); + self->endSculpt(); + }, "Fry3DX: fry_sculpt brush x0 y0 x1 y1 [invert] (0..1 of the view)"); static keel::Cmd sel("fry_select", [](const keel::Args& a) { self->selected_.clear(); for (const Object& o : self->scene_.objects) @@ -340,8 +371,9 @@ public: static keel::Cmd frame("fry_frame", [](const keel::Args&) { self->frameAll(); }, "Fry3DX: frame everything"); static keel::Cmd tab("fry_tab", [](const keel::Args& a) { if (a.size() > 1) - self->setTab(a[1] == "paint" ? Tab::Paint : a[1] == "layout" ? Tab::Layout : a[1] == "render" ? Tab::Render : a[1] == "print" ? Tab::Print : Tab::Model); - }, "Fry3DX: fry_tab model|paint|render"); + self->setTab(a[1] == "paint" || a[1] == "sculpt" ? Tab::Paint : a[1] == "layout" ? Tab::Layout : a[1] == "render" ? Tab::Render : a[1] == "print" ? Tab::Print : Tab::Model); + if (a.size() > 1) self->sculpting_ = a[1] == "sculpt"; + }, "Fry3DX: fry_tab model|paint|sculpt|layout|render|print"); // (points as parts of the view: 0..1 across, 0..1 down) auto viewPoint = [](const std::string& x, const std::string& y) { return ImVec2(self->viewMin_.x + std::stof(x) * (self->viewMax_.x - self->viewMin_.x), self->viewMin_.y + std::stof(y) * (self->viewMax_.y - self->viewMin_.y)); @@ -688,6 +720,21 @@ private: glm::vec3 last{0.0f}; glm::vec2 last2D{0.0f}; } paint_; + // Sculpting (the Paint tab's Shape side): brushes that push the surface about like clay. + bool sculpting_ = false; + SculptBrush sculptBrush_ = SculptBrush::Draw; + float sculptStrength_ = 0.5f, sculptPx_ = 60.0f; // (its own brush size: sculpting wants a bigger one than painting) + bool sculptMirror_ = false; // the same on the other side (left and right: the object's Y) + struct SculptStroke { + bool active = false, haveLast = false, waiting = false; + uint32_t object = 0; + std::unique_ptr mesh; + glm::vec3 last{0.0f}; + // Grab: where it was taken hold of (the world), and what (the object's own space). + glm::vec3 start{0.0f}; + std::vector> held[2]; + std::vector from[2]; + } sculpt_; // A picture layer unlocked to be moved: which, and what's being dragged (a corner 0-3, 4 the // whole of it, 5 the turning handle) from where. struct LayerEdit { @@ -1626,7 +1673,7 @@ private: if (t == tab_) return; if (drag_.active) cancelDrag(); lockLayer(); - endStroke(); + endStroke(), endSculpt(); knifePts_.clear(), drawPts_.clear(); if (t != Tab::Model) leaveEdit(); if ((t == Tab::Render || t == Tab::Print) && tool_ != Tool::Select && tool_ != Tool::Move && tool_ != Tool::Rotate && tool_ != Tool::Scale) tool_ = Tool::Move; @@ -1951,6 +1998,107 @@ private: paint_.image.reset(); } + // ── Sculpting ── + + // The object ready to sculpt: its finishing touches made part of it, and made finer (the same + // shape) if it has too few points to push about. + Object* readyToSculpt(uint32_t id) { + Object* o = find(id); + if (!o) return nullptr; + if (o->locked) return say("That object is locked"), nullptr; + pushUndo(); + if (o->hasFinish()) { + o->mesh = finishedMesh(*o); + o->mirror = 0, o->round = 0, o->smoothLevels = 0; + } + handMade(*o); + if (finerForSculpting(o->mesh)) { + o->smooth = true; + say(std::format("{} was made finer to sculpt: {} points now, the same shape", o->name, o->mesh.verts.size())); + } + touched(o->id); + return o; + } + void endSculpt() { + if (!sculpt_.active) return; + sculpt_ = {}; + } + // The brush at a point of the view: starts a stroke on what's there, or carries one on. + void sculptAt(ImVec2 mouse, bool start, bool ctrl, bool shift) { + const Ray ray = mouseRay(mouse); + const auto toLocal = [](const Object& o, float radius) { return radius * 3.0f / glm::max(glm::abs(o.scale.x) + glm::abs(o.scale.y) + glm::abs(o.scale.z), 1e-6f); }; + const glm::vec3 other(1, -1, 1); // (the other side: the object's Y turned round) + if (start) { + const Hit h = pickObject(ray, 0, true); + if (!h.object) return; + selected_ = {h.object}, active_ = h.object; + Object* o = readyToSculpt(h.object); + if (!o) return; + sculpt_.active = true, sculpt_.object = o->id; + sculpt_.mesh = std::make_unique(o->mesh); + if (sculptBrush_ == SculptBrush::Grab && !shift) { // (made finer: the same shape, so the same spot) + sculpt_.start = ray.origin + ray.dir * h.t; + const glm::vec3 at = glm::vec3(glm::inverse(objectMatrix(*o)) * glm::vec4(sculpt_.start, 1.0f)); + const float radius = toLocal(*o, sculptPx_ * pixelSize(sculpt_.start)); + for (int side = 0; side < (sculptMirror_ ? 2 : 1); ++side) { + sculpt_.held[side] = grabbed(o->mesh, side ? at * other : at, radius); + for (const auto& [i, w] : sculpt_.held[side]) sculpt_.from[side].push_back(o->mesh.verts[size_t(i)]); + } + } + } + if (!sculpt_.active) return; + Object* o = find(sculpt_.object); + if (!o) return endSculpt(); + const glm::mat4 inv = glm::inverse(objectMatrix(*o)); + if (!sculpt_.from[0].empty()) { // grabbing: it follows the mouse across the view + const auto p = hitPlane(ray, sculpt_.start, forwardOf(yaw_, pitch_)); + if (!p) return; + const glm::vec3 d = glm::vec3(inv * glm::vec4(*p - sculpt_.start, 0.0f)); + moveGrabbed(o->mesh, sculpt_.held[0], sculpt_.from[0], d); + if (sculptMirror_) moveGrabbed(o->mesh, sculpt_.held[1], sculpt_.from[1], d * other); + return touched(o->id); + } + const Hit h = pickObject(ray, o->id, true); + if (!h.object) { + sculpt_.haveLast = false; + return; + } + const glm::vec3 at = ray.origin + ray.dir * h.t; + const float radius = sculptPx_ * pixelSize(at); + const SculptBrush brush = shift ? SculptBrush::Smooth : sculptBrush_; + const auto dab = [&](const glm::vec3& world) { + Dab d{glm::vec3(inv * glm::vec4(world, 1.0f)), toLocal(*o, radius), sculptStrength_, ctrl}; + sculptDab(o->mesh, *sculpt_.mesh, brush, d); + if (sculptMirror_) d.at *= other, sculptDab(o->mesh, *sculpt_.mesh, brush, d); + }; + // A dab every quarter of the brush along the way, not one a frame (fast strokes stay even). + const float gap = glm::distance(at, sculpt_.last); + if (!sculpt_.haveLast) dab(at); + else if (gap >= radius * 0.25f && gap < radius * 8) { + const int steps = int(gap / (radius * 0.25f)); + for (int i = 1; i <= steps; ++i) dab(glm::mix(sculpt_.last, at, float(i) / float(steps))); + } else if (gap < radius * 0.25f) + return; // (not far enough for the next dab yet) + else + dab(at); + sculpt_.last = at, sculpt_.haveLast = true; + touched(o->id); + } + void sculptViewport(const ImGuiIO& io) { + if (sculpt_.waiting) { + if (!ImGui::IsMouseDown(ImGuiMouseButton_Left)) sculpt_.waiting = false; + else if (pickObject(mouseRay(io.MousePos), 0, true).object) sculpt_.waiting = false, sculptAt(io.MousePos, true, io.KeyCtrl, io.KeyShift); + return; + } + if (sculpt_.active) { + if (!ImGui::IsMouseDown(ImGuiMouseButton_Left)) return endSculpt(); + return sculptAt(io.MousePos, false, io.KeyCtrl, io.KeyShift); + } + if (!mouseInView(io) || !ImGui::IsMouseClicked(ImGuiMouseButton_Left)) return; + if (pickObject(mouseRay(io.MousePos), 0, true).object) sculptAt(io.MousePos, true, io.KeyCtrl, io.KeyShift); + else sculpt_.waiting = sculptBrush_ != SculptBrush::Grab; + } + // ── Picture layers ── PictureLayer* editLayer() { @@ -4274,6 +4422,7 @@ private: } // Painting: on the model under the mouse. void paintViewport(ImGuiIO& io) { + if (sculpting_) return sculptViewport(io); if (paint_.waiting) { if (!ImGui::IsMouseDown(ImGuiMouseButton_Left)) paint_.waiting = false; else if (pickObject(mouseRay(io.MousePos), 0, true).object) paint_.waiting = false, paintAt(io.MousePos, true); @@ -4340,13 +4489,19 @@ private: if (ctrl && key(ImGuiKey_1)) setTab(Tab::Model); if (ctrl && key(ImGuiKey_2)) setTab(Tab::Paint); if (ctrl && key(ImGuiKey_3)) setTab(Tab::Layout); + if (tab_ == Tab::Paint && !ctrl && !alt && key(ImGuiKey_Tab)) return (void)(sculpting_ = !sculpting_, endStroke(), endSculpt()); + if (tab_ == Tab::Paint && sculpting_ && !ctrl && !alt) + for (int i = 0; i < int(std::size(kSculptTools)); ++i) + if (key(ImGuiKey(int(ImGuiKey_1) + i))) sculptBrush_ = kSculptTools[i].brush; + if (tab_ == Tab::Paint && sculpting_ && !ctrl && !alt && key(ImGuiKey_X)) sculptMirror_ = !sculptMirror_; if (tab_ == Tab::Paint && !ctrl && !alt) { if (key(ImGuiKey_B)) paintTool_ = PaintTool::Brush; if (key(ImGuiKey_E)) paintTool_ = PaintTool::Eraser; if (key(ImGuiKey_G)) paintTool_ = PaintTool::Fill; if (key(ImGuiKey_I)) paintTool_ = PaintTool::Picker; - if (ImGui::IsKeyPressed(ImGuiKey_LeftBracket)) brushPx_ = std::max(1.0f, brushPx_ / 1.2f); - if (ImGui::IsKeyPressed(ImGuiKey_RightBracket)) brushPx_ = std::min(500.0f, brushPx_ * 1.2f); + float& size = sculpting_ ? sculptPx_ : brushPx_; + if (ImGui::IsKeyPressed(ImGuiKey_LeftBracket)) size = std::max(1.0f, size / 1.2f); + if (ImGui::IsKeyPressed(ImGuiKey_RightBracket)) size = std::min(500.0f, size * 1.2f); if (key(ImGuiKey_Enter) || key(ImGuiKey_KeypadEnter)) lockLayer(); if (key(ImGuiKey_Delete) && editLayer()) deleteLayer(*find(ledit_.object), ledit_.index); if (key(ImGuiKey_F1)) showHelp_ = !showHelp_; @@ -4716,7 +4871,9 @@ private: } } } - if (mouseInView(io) && !editLayer() && paintTool_ != PaintTool::Picture) + if (sculpting_) { + if (mouseInView(io)) dl->AddCircle(io.MousePos, sculptPx_, io.KeyCtrl ? IM_COL32(120, 180, 255, 220) : IM_COL32(255, 255, 255, 200), 40, 1.5f); + } else if (mouseInView(io) && !editLayer() && paintTool_ != PaintTool::Picture) dl->AddCircle(io.MousePos, brushPx_, paintTool_ == PaintTool::Fill || paintTool_ == PaintTool::Picker ? IM_COL32(255, 255, 255, 90) : IM_COL32(255, 255, 255, 200), 40, 1.5f); dl->PopClipRect(); return; @@ -5264,6 +5421,21 @@ private: } void paintToolbox(float w) { + const float half = (w - ImGui::GetStyle().ItemSpacing.x) * 0.5f; + if (wideButton("##colour-side", Icon::PaintBrush, "Colour", !sculpting_, half, "Paint colours on it (Tab)")) sculpting_ = false, endSculpt(); + ImGui::SameLine(); + if (wideButton("##shape-side", Icon::Sculpt, "Sculpt", sculpting_, half, "Push its surface about like clay (Tab)")) sculpting_ = true, endStroke(); + ImGui::Spacing(); + if (sculpting_) { + grid(int(std::size(kSculptTools)), 38.0f, [&](int i) { + const SculptToolInfo& t = kSculptTools[i]; + const std::string id = std::format("##{}", t.name), tip = std::format("{} ({})\n{}", t.name, t.key, t.hint); + if (iconButton(id.c_str(), t.icon, sculptBrush_ == t.brush, tip.c_str())) sculptBrush_ = t.brush; + }); + for (const SculptToolInfo& t : kSculptTools) + if (t.brush == sculptBrush_) ImGui::TextColored(accentText(), "%s", t.name); + return; + } grid(int(std::size(kPaintTools)), 38.0f, [&](int i) { const PaintToolInfo& t = kPaintTools[i]; const std::string id = std::format("##{}", t.name), tip = std::format("{} ({})\n{}", t.name, t.key, t.hint); @@ -5283,7 +5455,48 @@ private: if (t.tool == paintTool_) ImGui::TextColored(accentText(), "%s", t.name); } + void sculptOptions(float w) { + for (const SculptToolInfo& t : kSculptTools) + if (t.brush == sculptBrush_) ImGui::SeparatorText(t.name), ImGui::TextWrapped("%s", t.hint); + ImGui::Spacing(); + ImGui::SetNextItemWidth(w); + ImGui::SliderFloat("##size", &sculptPx_, 4.0f, 400.0f, "size %.0f px", ImGuiSliderFlags_Logarithmic); + if (sculptBrush_ != SculptBrush::Grab) { + ImGui::SetNextItemWidth(w); + ImGui::SliderFloat("##strength", &sculptStrength_, 0.02f, 1.0f, "strength %.2f"); + } + ImGui::Checkbox("Same on both sides (X)", &sculptMirror_); + if (ImGui::IsItemHovered()) ImGui::SetTooltip("What you do on its left is done on its right too"); + if (Object* o = selected_.size() == 1 ? find(active_) : nullptr) { + ImGui::SeparatorText(o->name.c_str()); + ImGui::TextDisabled("%zu points", o->mesh.verts.size()); + size_t after = 0; + for (const auto& f : o->mesh.faces) after += f.size(); + ImGui::BeginDisabled(after > 400000 || o->locked); + if (ImGui::Button("Finer, for small details", ImVec2(w, 0))) { + pushUndo(); + if (o->hasFinish()) o->mesh = finishedMesh(*o), o->mirror = 0, o->round = 0, o->smoothLevels = 0; + handMade(*o); + subdivide(o->mesh, false); + touched(o->id); + } + ImGui::EndDisabled(); + if (ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled)) ImGui::SetTooltip("Each face split in four: the same shape, more points to push about"); + if (ImGui::Button("Smooth all of it", ImVec2(w, 0)) && !o->locked) { + pushUndo(); + handMade(*o); + const SculptMesh sm(o->mesh); + const glm::vec3 mid = (boundsMin(o->mesh) + boundsMax(o->mesh)) * 0.5f; + sculptDab(o->mesh, sm, SculptBrush::Smooth, {mid, glm::distance(boundsMin(o->mesh), boundsMax(o->mesh)) * 2.0f, 0.5f}); + touched(o->id); + } + } + ImGui::Spacing(); + ImGui::TextDisabled("Ctrl: the other way Shift: smooth\n[ and ]: brush size 1-6: brushes"); + } + void paintOptions(float w) { + if (sculpting_) return sculptOptions(w); for (const PaintToolInfo& t : kPaintTools) if (t.tool == paintTool_) ImGui::SeparatorText(t.name); if (paintTool_ == PaintTool::Brush || paintTool_ == PaintTool::Eraser) { diff --git a/src/icons.cpp b/src/icons.cpp index 2b596ab..bf0804e 100644 --- a/src/icons.cpp +++ b/src/icons.cpp @@ -174,6 +174,45 @@ void drawIcon(ImDrawList* dl, Icon icon, ImVec2 o, float s, ImU32 c) { } break; } + // Sculpting: a line of surface, and what the brush does to it. + case Icon::Sculpt: { // a bump raised, an arrow up out of it + const ImVec2 pts[] = {P(0.08f, 0.80f), P(0.30f, 0.78f), P(0.42f, 0.58f), P(0.58f, 0.58f), P(0.70f, 0.78f), P(0.92f, 0.80f)}; + dl->AddPolyline(pts, 6, c, 0, t * 1.2f); + line(0.5f, 0.48f, 0.5f, 0.24f), tri(0.5f, 0.08f, 0.38f, 0.26f, 0.62f, 0.26f); + break; + } + case Icon::Inflate: + dl->AddCircle(P(0.5f, 0.5f), 0.2f * s, c, 24, t); + for (int i = 0; i < 4; ++i) { + const float a = 0.785f + 1.5708f * float(i), x = std::cos(a), y = std::sin(a); + line(0.5f + x * 0.28f, 0.5f + y * 0.28f, 0.5f + x * 0.42f, 0.5f + y * 0.42f); + } + break; + case Icon::Smooth: { // bumps above, evened out below + ImVec2 pts[9]; + for (int i = 0; i < 9; ++i) pts[i] = P(0.1f + 0.1f * float(i), 0.3f + (i % 2 ? -0.1f : 0.08f)); + dl->AddPolyline(pts, 9, dim, 0, t); + dl->PathArcTo(P(0.5f, 1.25f), 0.62f * s, 3.14159f * 1.2f, 3.14159f * 1.8f, 20); + dl->PathStroke(c, 0, t * 1.2f); + break; + } + case Icon::Flatten: // a bump pressed flat by a bar + dl->AddRectFilled(P(0.2f, 0.18f), P(0.8f, 0.32f), c, 0.04f * s); + line(0.5f, 0.36f, 0.5f, 0.5f), tri(0.5f, 0.62f, 0.4f, 0.48f, 0.6f, 0.48f); + line(0.08f, 0.78f, 0.92f, 0.78f, t * 1.2f); + break; + case Icon::Pinch: // two sides pulled in to a crease + line(0.08f, 0.5f, 0.34f, 0.5f), tri(0.46f, 0.5f, 0.32f, 0.4f, 0.32f, 0.6f); + line(0.92f, 0.5f, 0.66f, 0.5f), tri(0.54f, 0.5f, 0.68f, 0.4f, 0.68f, 0.6f); + line(0.5f, 0.2f, 0.5f, 0.8f, t * 0.7f); + break; + case Icon::Grab: { // a patch of surface pulled up and over + const ImVec2 pts[] = {P(0.08f, 0.82f), P(0.32f, 0.80f), P(0.46f, 0.50f), P(0.62f, 0.34f), P(0.72f, 0.80f), P(0.92f, 0.82f)}; + dl->AddPolyline(pts, 6, c, 0, t * 1.2f); + dot(0.62f, 0.34f, 0.08f); + line(0.62f, 0.34f, 0.84f, 0.16f, t * 0.8f), tri(0.92f, 0.08f, 0.78f, 0.12f, 0.88f, 0.22f); + break; + } case Icon::LayFlat: quad(0.22f, 0.40f, 0.62f, 0.22f, 0.86f, 0.50f, 0.46f, 0.68f, false, c); line(0.10f, 0.88f, 0.90f, 0.88f); diff --git a/src/icons.h b/src/icons.h index 1888e67..4b89852 100644 --- a/src/icons.h +++ b/src/icons.h @@ -4,7 +4,7 @@ namespace fry { -enum class Icon { Select, Move, Rotate, Scale, Draw, Extrude, Inset, Bevel, LoopCut, Objects, Shape, Points, Edges, Faces, Box, Brush, Lasso, Add, Knife, Hole, PaintBrush, Eraser, Fill, Picker, Picture, Render, Layout, Print, Ruler, LayFlat }; +enum class Icon { Select, Move, Rotate, Scale, Draw, Extrude, Inset, Bevel, LoopCut, Objects, Shape, Points, Edges, Faces, Box, Brush, Lasso, Add, Knife, Hole, PaintBrush, Eraser, Fill, Picker, Picture, Render, Layout, Print, Ruler, LayFlat, Sculpt, Inflate, Smooth, Flatten, Pinch, Grab }; void drawIcon(ImDrawList* dl, Icon icon, ImVec2 topLeft, float size, ImU32 color); diff --git a/src/script.cpp b/src/script.cpp index 7cab9f9..9cd8992 100644 --- a/src/script.cpp +++ b/src/script.cpp @@ -1,4 +1,5 @@ #include "script.h" +#include "sculpt.h" #include "shapes.h" // (Lua is built as C++ here — cmake/Dependencies.cmake — so its errors are exceptions and unwind @@ -332,6 +333,30 @@ int objSubdivide(lua_State* L) { lua_settop(L, 1); return 1; } +// o:sculpt("raise", at, radius, strength, times): a brush pressed at a point (the scene's space), +// like the Sculpt brushes. Made finer first if it has too few points, as in the app. +int objSculpt(lua_State* L) { + Object& o = object(L, 1); + static const std::pair> names[] = { + {"raise", {SculptBrush::Draw, false}}, {"dig", {SculptBrush::Draw, true}}, {"puff", {SculptBrush::Inflate, false}}, + {"shrink", {SculptBrush::Inflate, true}}, {"smooth", {SculptBrush::Smooth, false}}, {"flatten", {SculptBrush::Flatten, false}}, + {"pinch", {SculptBrush::Pinch, false}}, {"spread", {SculptBrush::Pinch, true}}, + }; + const std::string name = lower(luaL_checkstring(L, 2)); + const auto* it = std::find_if(std::begin(names), std::end(names), [&](const auto& n) { return name == n.first; }); + if (it == std::end(names)) return luaL_error(L, "there's no brush called %s (raise, dig, puff, shrink, smooth, flatten, pinch, spread)", name.c_str()); + const glm::mat4 inv = glm::inverse(objectMatrix(o)); + const float scale = (std::abs(o.scale.x) + std::abs(o.scale.y) + std::abs(o.scale.z)) / 3.0f; + Dab dab{glm::vec3(inv * glm::vec4(toVec(L, 3), 1.0f)), float(luaL_optnumber(L, 4, 0.2)) / std::max(scale, 1e-6f), float(std::clamp(luaL_optnumber(L, 5, 0.5), 0.0, 1.0)), it->second.second}; + const int times = std::clamp(int(luaL_optinteger(L, 6, 1)), 1, 1000); + if (o.hasFinish()) o.mesh = finishedMesh(o), o.mirror = 0, o.round = 0, o.smoothLevels = 0; + if (finerForSculpting(o.mesh)) o.smooth = true; + const SculptMesh sm(o.mesh); + for (int i = 0; i < times; ++i) sculptDab(o.mesh, sm, it->second.first, dab); + handMade(o); + lua_settop(L, 1); + return 1; +} int objBevel(lua_State* L) { Object& o = object(L, 1); const float width = float(luaL_checknumber(L, 2)); @@ -480,7 +505,7 @@ int objDrop(lua_State* L) { // stands it on the ground (z = 0) const luaL_Reg kMethods[] = { {"move", objMove}, {"rotate", objRotate}, {"scale", objScale}, {"delete", objDelete}, {"copy", objCopy}, {"add", objAdd}, {"subtract", objSubtract}, {"overlap", objOverlap}, {"join", objJoin}, {"thicken", objThicken}, - {"subdivide", objSubdivide}, {"bevel", objBevel}, {"bake", objBake}, {"points", objPoints}, {"faces", objFaces}, + {"subdivide", objSubdivide}, {"sculpt", objSculpt}, {"bevel", objBevel}, {"bake", objBake}, {"points", objPoints}, {"faces", objFaces}, {"setpoints", objSetPoints}, {"setshape", objSetShape}, {"set", objSet}, {"settings", objSettings}, {"bounds", objBounds}, {"select", objSelect}, {"drop", objDrop}, {nullptr, nullptr}, }; @@ -811,6 +836,8 @@ An object (o): o:copy() o:delete() o:select() o:bounds() -> lowest, highest corner o:add(others...) o:subtract(others...) o:overlap(others...) o:join(others...) (the others go) o:thicken(t) o:subdivide(smooth, times) o:bevel(width, steps, angle) o:bake() + o:sculpt(brush, at, radius, strength, times) brush: raise, dig, puff, shrink, smooth, + flatten, pinch, spread; at: a point on it (the scene's space). Made finer first if needed o:points() o:faces() o:setpoints(points) o:setshape(points, faces) o:set(name, value) o:settings() )"; } diff --git a/src/sculpt.cpp b/src/sculpt.cpp new file mode 100644 index 0000000..697a78d --- /dev/null +++ b/src/sculpt.cpp @@ -0,0 +1,100 @@ +#include "sculpt.h" + +#include +#include + +namespace fry { + +SculptMesh::SculptMesh(const Mesh& m) : next(m.verts.size()), faces(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 (size_t f = 0; f < m.faces.size(); ++f) + for (int v : m.faces[f]) faces[size_t(v)].push_back(int(f)); +} + +float sculptFalloff(float d, float radius) { + if (d >= radius) return 0.0f; + const float s = 1.0f - d / radius; + return s * s * (3.0f - 2.0f * s); +} + +glm::vec3 pointNormal(const Mesh& m, const SculptMesh& s, int point) { + glm::vec3 n(0.0f); + for (int f : s.faces[size_t(point)]) n += faceNormal(m, f) * std::max(faceArea(m, f), 1e-12f); + const float len = glm::length(n); + return len > 1e-20f ? n / len : glm::vec3(0, 0, 1); +} + +void sculptDab(Mesh& m, const SculptMesh& s, SculptBrush brush, const Dab& dab) { + if (brush == SculptBrush::Grab || dab.radius <= 0.0f) return; + // The points it reaches, how much, and the surface under it: its normal and middle. + std::vector> under; + glm::vec3 normal(0.0f), middle(0.0f); + float total = 0.0f; + for (size_t i = 0; i < m.verts.size(); ++i) { + const float w = sculptFalloff(glm::distance(m.verts[i], dab.at), dab.radius); + if (w <= 0.0f || s.faces[i].empty()) continue; + under.push_back({int(i), w}); + normal += pointNormal(m, s, int(i)) * w; + middle += m.verts[i] * w, total += w; + } + if (under.empty()) return; + middle /= total; + normal = glm::length(normal) > 1e-12f ? glm::normalize(normal) : glm::vec3(0, 0, 1); + const float sign = dab.invert ? -1.0f : 1.0f; + // A sixth of the brush per dab at full strength (dabs come every quarter brush, so a stroke + // over a point raises it about half the brush's size). + const float step = dab.radius * 0.16f * std::clamp(dab.strength, 0.0f, 1.0f); + + // (Worked out from where the points were before this dab, so it doesn't depend on order.) + std::vector moved(under.size()); + for (size_t k = 0; k < under.size(); ++k) { + const auto [i, w] = under[k]; + const glm::vec3 p = m.verts[size_t(i)]; + switch (brush) { + case SculptBrush::Draw: moved[k] = p + normal * (sign * step * w); break; + case SculptBrush::Inflate: moved[k] = p + pointNormal(m, s, i) * (sign * step * w); break; + case SculptBrush::Smooth: { + glm::vec3 avg(0.0f); + for (int n : s.next[size_t(i)]) avg += m.verts[size_t(n)]; + if (s.next[size_t(i)].empty()) avg = p; + else avg /= float(s.next[size_t(i)].size()); + moved[k] = glm::mix(p, avg, std::min(1.0f, w * dab.strength)); + break; + } + case SculptBrush::Flatten: moved[k] = p - normal * (glm::dot(p - middle, normal) * std::min(1.0f, w * dab.strength * 0.5f)); break; + case SculptBrush::Pinch: { + glm::vec3 in = dab.at - p; + in -= normal * glm::dot(in, normal); // along the surface, not into it + moved[k] = p + in * (sign * std::min(0.5f, w * dab.strength * 0.15f)); + break; + } + case SculptBrush::Grab: break; + } + } + for (size_t k = 0; k < under.size(); ++k) m.verts[size_t(under[k].first)] = moved[k]; +} + +std::vector> grabbed(const Mesh& m, const glm::vec3& at, float radius) { + std::vector> out; + for (size_t i = 0; i < m.verts.size(); ++i) + if (const float w = sculptFalloff(glm::distance(m.verts[i], at), radius); w > 0.0f) out.push_back({int(i), w}); + return out; +} + +void moveGrabbed(Mesh& m, const std::vector>& points, const std::vector& from, const glm::vec3& offset) { + for (size_t k = 0; k < points.size() && k < from.size(); ++k) m.verts[size_t(points[k].first)] = from[k] + offset * points[k].second; +} + +int finerForSculpting(Mesh& m, size_t points, size_t limit) { + int times = 0; + while (m.verts.size() < points) { + size_t after = 0; // (each face becomes as many as it has corners) + for (const auto& f : m.faces) after += f.size(); + if (after > limit || m.faces.empty()) break; + subdivide(m, false); + ++times; + } + return times; +} + +} // namespace fry diff --git a/src/sculpt.h b/src/sculpt.h new file mode 100644 index 0000000..e9b6bef --- /dev/null +++ b/src/sculpt.h @@ -0,0 +1,51 @@ +#pragma once +// Sculpting: brushes that push a mesh's points about like clay — the points under the brush +// move most in its middle, less towards its edge. It only moves points (no new ones): a shape +// with few points is made finer first (finerForSculpting). +#include "mesh.h" + +#include +#include + +namespace fry { + +enum class SculptBrush { + Draw, // raise (or, inverted, dig into) the surface under it + Inflate, // puff out along each point's own normal (inverted: shrink) + Smooth, // even out bumps + Flatten, // press onto the plane the surface under it is closest to + Pinch, // pull points in to the middle (inverted: push apart) — sharpens creases + Grab, // take hold of a patch and drag it (grabbed / moveGrabbed) +}; + +// What a stroke needs to know about the mesh, made once when it starts (the faces don't change +// while sculpting, only where the points are). +struct SculptMesh { + explicit SculptMesh(const Mesh& m); + std::vector> next; // each point's neighbours along edges + std::vector> faces; // the faces round each point +}; + +// One dab, in the mesh's own space. strength: 0..1; invert: the other way. +struct Dab { + glm::vec3 at{0.0f}; + float radius = 0.1f, strength = 0.5f; + bool invert = false; +}; + +// How much of a dab reaches a point `d` away: 1 in the middle, 0 at the edge, smoothly. +float sculptFalloff(float d, float radius); +// A point's normal: its faces', averaged. +glm::vec3 pointNormal(const Mesh& m, const SculptMesh& s, int point); +void sculptDab(Mesh& m, const SculptMesh& s, SculptBrush brush, const Dab& dab); + +// Grab: the points under the brush and how much each follows it; then, as it's dragged, each +// put `offset` × its share away from where it was when grabbed (`from`). +std::vector> grabbed(const Mesh& m, const glm::vec3& at, float radius); +void moveGrabbed(Mesh& m, const std::vector>& points, const std::vector& from, const glm::vec3& offset); + +// Made finer without changing its shape (each face split into four, flat) until it has at least +// `points` points, or would have more than `limit` faces. Returns how many times it was split. +int finerForSculpting(Mesh& m, size_t points = 6000, size_t limit = 400000); + +} // namespace fry diff --git a/tests/mesh_test.cpp b/tests/mesh_test.cpp index 5209aa7..df11c09 100644 --- a/tests/mesh_test.cpp +++ b/tests/mesh_test.cpp @@ -4,6 +4,7 @@ #include "image.h" #include "scene.h" #include "shapes.h" +#include "sculpt.h" #include #include @@ -425,6 +426,36 @@ int main() { const std::string text((std::istreambuf_iterator(j)), {}); CHECK(text.find("\"translation\":[2,3,1]") != std::string::npos, "in glTF's axes (Y up): the position is [2,3,1]"); + { // Sculpting: a cube made finer keeps its shape; brushes move only what's under them. + Mesh m = makeCube(1.0f); + const int times = finerForSculpting(m, 2000); + CHECK(times == 5 && m.verts.size() >= 2000 && closed(m) && boundsMax(m) == glm::vec3(0.5f), "a cube made finer for sculpting: %d times, %zu points, same box", times, m.verts.size()); + const SculptMesh sm(m); + const Mesh before = m; + const glm::vec3 top(0, 0, 0.5f); + for (int i = 0; i < 20; ++i) sculptDab(m, sm, SculptBrush::Draw, {top, 0.25f, 1.0f}); + CHECK(boundsMax(m).z > 0.55f && boundsMin(m) == boundsMin(before), "draw raises the top (%.3f) and leaves the rest", boundsMax(m).z); + CHECK(closed(m), "still closed after sculpting"); + Mesh dug = before; + for (int i = 0; i < 20; ++i) sculptDab(dug, sm, SculptBrush::Draw, {top, 0.25f, 1.0f, true}); + CHECK(boundsMax(dug).z == 0.5f, "inverted, it digs in: nothing above the top"); + float rough = 0.0f, smooth = 0.0f; // how far the top's points are from flat + Mesh bumpy = m; + for (int i = 0; i < 10; ++i) sculptDab(bumpy, sm, SculptBrush::Smooth, {top, 0.3f, 1.0f}); + for (size_t i = 0; i < m.verts.size(); ++i) + if (glm::length(glm::vec2(m.verts[i])) < 0.15f && m.verts[i].z > 0.4f) rough += m.verts[i].z - 0.5f, smooth += bumpy.verts[i].z - 0.5f; + CHECK(smooth < rough, "smoothing lowers the bump (%.3f to %.3f)", rough, smooth); + Mesh flat = m; + for (int i = 0; i < 30; ++i) sculptDab(flat, sm, SculptBrush::Flatten, {top, 0.3f, 1.0f}); + CHECK(boundsMax(flat).z < boundsMax(m).z, "flatten presses it down"); + Mesh g = before; + const auto held = grabbed(g, top, 0.2f); + std::vector from; + for (const auto& [i, w] : held) from.push_back(g.verts[size_t(i)]); + moveGrabbed(g, held, from, glm::vec3(0, 0, 0.3f)); + CHECK(!held.empty() && std::abs(boundsMax(g).z - 0.8f) < 0.01f && closed(g), "grab pulls the middle up with it (%.3f)", boundsMax(g).z); + } + std::printf("%s (%d failure%s)\n", g_failures ? "FAILED" : "passed", g_failures, g_failures == 1 ? "" : "s"); return g_failures ? 1 : 0; } diff --git a/tests/script_test.cpp b/tests/script_test.cpp index 3635b6b..69a34d3 100644 --- a/tests/script_test.cpp +++ b/tests/script_test.cpp @@ -60,6 +60,8 @@ int main() { r = runScript(host, "local o = fry.add('cube')\no.material = 'Gold'\nlocal m = fry.add('cube', {color = '#102030', material = 'glossy'})\nprint(#fry.materials())"); CHECK(r.ok && scene.objects.back().metal == 0.0f && scene.objects.back().rough < 0.2f && scene.objects.back().color.r < 0.1f, "a material keeping its colour (%s)", r.error.c_str()); CHECK(scene.objects[scene.objects.size() - 2].metal == 1.0f && scene.objects[scene.objects.size() - 2].color.g > 0.7f, "gold"); + r = runScript(host, "local b = fry.add('sphere', {radius = 1})\nb:sculpt('raise', {0, 0, 1}, 0.4, 1, 20)\nlocal lo, hi = b:bounds()\nprint(hi.z > 1.05)"); + CHECK(r.ok && out.find("true\n") != std::string::npos, "o:sculpt raises the top (%s)", r.error.c_str()); r = runScript(host, "fry.add('cube').material = 'cheese'"); CHECK(!r.ok && r.error.find("no material called cheese") != std::string::npos, "an unknown material is an error"); r = runScript(host, "fry.clear()");