Sculpting: the Paint tab's other side (Tab) — Raise, Puff out, Smooth, Flatten, Pinch, Grab, Ctrl the other way, Shift to smooth, the same on both sides (X); shapes are made finer first (the same shape); o:sculpt() in scripts, fry_sculpt for pictures; tests

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Nexoniarz 2026-10-20 17:24:13 +02:00
commit 5c28e3da4e
9 changed files with 472 additions and 9 deletions

View file

@ -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)

View file

@ -7,6 +7,7 @@
#include "mesh.h"
#include "scene.h"
#include "script.h"
#include "sculpt.h"
#include "shapes.h"
#include <SDL3/SDL.h>
@ -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<SculptMesh> 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<std::pair<int, float>> held[2];
std::vector<glm::vec3> 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<SculptMesh>(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) {

View file

@ -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);

View file

@ -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);

View file

@ -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<const char*, std::pair<SculptBrush, bool>> 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()
)";
}

100
src/sculpt.cpp Normal file
View file

@ -0,0 +1,100 @@
#include "sculpt.h"
#include <algorithm>
#include <cmath>
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<std::pair<int, float>> 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<glm::vec3> 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<std::pair<int, float>> grabbed(const Mesh& m, const glm::vec3& at, float radius) {
std::vector<std::pair<int, float>> 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<std::pair<int, float>>& points, const std::vector<glm::vec3>& 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

51
src/sculpt.h Normal file
View file

@ -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 <utility>
#include <vector>
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<std::vector<int>> next; // each point's neighbours along edges
std::vector<std::vector<int>> 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<std::pair<int, float>> grabbed(const Mesh& m, const glm::vec3& at, float radius);
void moveGrabbed(Mesh& m, const std::vector<std::pair<int, float>>& points, const std::vector<glm::vec3>& 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

View file

@ -4,6 +4,7 @@
#include "image.h"
#include "scene.h"
#include "shapes.h"
#include "sculpt.h"
#include <cmath>
#include <cstdio>
@ -425,6 +426,36 @@ int main() {
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]");
{ // 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<glm::vec3> 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;
}

View file

@ -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()");