Editable cuts: Object > Cut out, editable: the cutters stay objects (see-through red), moved or changed and the hole follows; they go with what they cut when it's moved; Apply makes one or all for good; saved; exported and printed with the cut made; tests

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Nexoniarz 2026-10-21 08:21:28 +02:00
commit 5394eeb3d8
4 changed files with 202 additions and 14 deletions

View file

@ -29,6 +29,7 @@
#include <glm/gtc/matrix_transform.hpp> #include <glm/gtc/matrix_transform.hpp>
#include <glm/gtc/type_ptr.hpp> #include <glm/gtc/type_ptr.hpp>
#include <glm/gtx/matrix_decompose.hpp>
#include <glm/gtx/quaternion.hpp> #include <glm/gtx/quaternion.hpp>
#include <imgui.h> #include <imgui.h>
#include <imgui_internal.h> #include <imgui_internal.h>
@ -1117,7 +1118,7 @@ private:
void restore(Snapshot& s) { void restore(Snapshot& s) {
// (Not the scripts: they're edited like text, not undone with the model.) // (Not the scripts: they're edited like text, not undone with the model.)
std::vector<fry::SceneScript> scripts = std::move(scene_.scripts); std::vector<fry::SceneScript> scripts = std::move(scene_.scripts);
scene_ = s.scene, selected_ = s.selected, active_ = s.active, mode_ = s.mode; scene_ = s.scene, selected_ = s.selected, active_ = s.active, mode_ = s.mode, followAt_.clear();
scene_.scripts = std::move(scripts); scene_.scripts = std::move(scripts);
selVerts_ = s.verts, selFaces_ = s.faces, selEdges_ = s.edges; selVerts_ = s.verts, selFaces_ = s.faces, selEdges_ = s.edges;
if (mode_ == Mode::Edit && !find(active_)) mode_ = Mode::Object; if (mode_ == Mode::Edit && !find(active_)) mode_ = Mode::Object;
@ -1140,7 +1141,7 @@ private:
} }
void newScene() { void newScene() {
scene_ = {}; scene_ = {}, followAt_.clear();
script_ = 0, scriptPath_.clear(); script_ = 0, scriptPath_.clear();
path_.clear(); path_.clear();
undo_.clear(), redo_.clear(), selected_.clear(); undo_.clear(), redo_.clear(), selected_.clear();
@ -1299,7 +1300,7 @@ private:
auto s = loadPackage(path, &err); auto s = loadPackage(path, &err);
if (!s) return say("Couldn't open: " + err); if (!s) return say("Couldn't open: " + err);
dropAutosave(); dropAutosave();
scene_ = std::move(*s); scene_ = std::move(*s), followAt_.clear();
// (an old plain-text .fry: its pictures are PNGs beside it) // (an old plain-text .fry: its pictures are PNGs beside it)
const stdfs::path here = stdfs::path(path).parent_path(); const stdfs::path here = stdfs::path(path).parent_path();
for (Object& o : scene_.objects) { for (Object& o : scene_.objects) {
@ -1362,7 +1363,7 @@ private:
for (size_t i = 0; i < scene_.objects.size(); ++i) for (size_t i = 0; i < scene_.objects.size(); ++i)
if (const Object& o = scene_.objects[i]; o.picture && o.uvsFit()) pngs[i] = pngBytes(o.layers.empty() ? *o.picture : composePicture(o)); if (const Object& o = scene_.objects[i]; o.picture && o.uvsFit()) pngs[i] = pngBytes(o.layers.empty() ? *o.picture : composePicture(o));
std::string err; std::string err;
if (!exportModel(path, scene_, pngs, &err)) return say("Couldn't export: " + err); if (!exportModel(path, fry::withCutsMade(scene_), pngs, &err)) return say("Couldn't export: " + err);
say("Exported " + stdfs::path(path).filename().string()); say("Exported " + stdfs::path(path).filename().string());
} }
@ -3070,7 +3071,7 @@ private:
} }
if (p.key == key) return p; if (p.key == key) return p;
p.key = key; p.key = key;
p.world = o.hasFinish() ? finishedMesh(o) : o.mesh; p.world = cutKeys_.count(o.id) ? fry::cutMesh(scene_, o) : o.hasFinish() ? finishedMesh(o) : o.mesh;
transform(p.world, objectMatrix(o)); transform(p.world, objectMatrix(o));
p.lo = boundsMin(p.world), p.hi = boundsMax(p.world); p.lo = boundsMin(p.world), p.hi = boundsMax(p.world);
p.check = checkForPrint(p.world, overhangDeg_, 0.0f); p.check = checkForPrint(p.world, overhangDeg_, 0.0f);
@ -3126,7 +3127,7 @@ private:
std::vector<Object*> printTargets() { // (what's picked, or everything shown) std::vector<Object*> printTargets() { // (what's picked, or everything shown)
std::vector<Object*> out; std::vector<Object*> out;
for (Object& o : scene_.objects) for (Object& o : scene_.objects)
if (o.visible && (selected_.empty() || selected_.count(o.id))) out.push_back(&o); if (o.visible && !isCutter(o) && (selected_.empty() || selected_.count(o.id))) out.push_back(&o);
return out; return out;
} }
void drawPrintBed(keel::Renderer& r, const keel::ModelLighting& light) { void drawPrintBed(keel::Renderer& r, const keel::ModelLighting& light) {
@ -3160,14 +3161,14 @@ private:
if (showOverhangs_) { if (showOverhangs_) {
const keel::ModelLighting flat{{glm::vec3(1.0f), glm::vec3(1.0f), glm::vec3(1.0f), glm::vec3(1.0f), glm::vec3(1.0f), glm::vec3(1.0f)}, 0.0f}; const keel::ModelLighting flat{{glm::vec3(1.0f), glm::vec3(1.0f), glm::vec3(1.0f), glm::vec3(1.0f), glm::vec3(1.0f), glm::vec3(1.0f)}, 0.0f};
for (const Object& o : scene_.objects) for (const Object& o : scene_.objects)
if (o.visible) if (o.visible && !isCutter(o))
if (const PrintInfo& p = printInfo(o); p.hasOverhang) if (const PrintInfo& p = printInfo(o); p.hasOverhang)
r.drawModel(p.overhang, glm::scale(glm::mat4(1.0f), glm::vec3(kUnits)), glm::vec4(0.95f, 0.15f, 0.1f, 0.55f), nullptr, &flat, false); r.drawModel(p.overhang, glm::scale(glm::mat4(1.0f), glm::vec3(kUnits)), glm::vec4(0.95f, 0.15f, 0.1f, 0.55f), nullptr, &flat, false);
} }
if (showThin_) { if (showThin_) {
const keel::ModelLighting flat{{glm::vec3(1.0f), glm::vec3(1.0f), glm::vec3(1.0f), glm::vec3(1.0f), glm::vec3(1.0f), glm::vec3(1.0f)}, 0.0f}; const keel::ModelLighting flat{{glm::vec3(1.0f), glm::vec3(1.0f), glm::vec3(1.0f), glm::vec3(1.0f), glm::vec3(1.0f), glm::vec3(1.0f)}, 0.0f};
for (const Object& o : scene_.objects) for (const Object& o : scene_.objects)
if (o.visible) if (o.visible && !isCutter(o))
if (const PrintInfo& p = printInfo(o); p.thinFaces) if (const PrintInfo& p = printInfo(o); p.thinFaces)
r.drawModel(p.thin, glm::scale(glm::mat4(1.0f), glm::vec3(kUnits)), glm::vec4(1.0f, 0.8f, 0.1f, 0.7f), nullptr, &flat, false); r.drawModel(p.thin, glm::scale(glm::mat4(1.0f), glm::vec3(kUnits)), glm::vec4(1.0f, 0.8f, 0.1f, 0.7f), nullptr, &flat, false);
} }
@ -4388,14 +4389,96 @@ private:
// The mesh as seen: with its rounding, smoothing and mirror — but while its shape is being // The mesh as seen: with its rounding, smoothing and mirror — but while its shape is being
// edited, only the mirror (the rest would hide the points you're moving). // edited, only the mirror (the rest would hide the points you're moving).
// Editable cuts show (but not while its own shape's being edited: the points you move).
const Mesh& shownMesh(const Object& o) { const Mesh& shownMesh(const Object& o) {
const bool editingThis = mode_ == Mode::Edit && o.id == active_; const bool editingThis = mode_ == Mode::Edit && o.id == active_;
if (!o.hasFinish() || (editingThis && !o.mirror)) return o.mesh; const bool cut = !editingThis && cutKeys_.count(o.id);
if ((!o.hasFinish() && !cut) || (editingThis && !o.mirror)) return o.mesh;
Shown& s = shown_[o.id]; Shown& s = shown_[o.id];
const uint64_t v = versions_[o.id]; const uint64_t v = versions_[o.id];
if (!(s.version == v && v && s.mirrorOnly == editingThis)) s.mesh = finishedMesh(o, editingThis), s.version = v, s.mirrorOnly = editingThis; if (!(s.version == v && v && s.mirrorOnly == editingThis))
s.mesh = cut ? fry::cutMesh(scene_, o) : finishedMesh(o, editingThis), s.version = v, s.mirrorOnly = editingThis;
return s.mesh; return s.mesh;
} }
// Each object something's cut out of, and what its cutters are (their shapes, where they are):
// a change, and it's made again (the caches follow its version).
std::map<uint32_t, uint64_t> cutKeys_;
std::map<uint32_t, glm::mat4> followAt_; // (where each cut object and cutter was last frame)
void followCuts() {
// What's cut moved, turned or sized (not by a drag: that takes them itself), and its cutter
// didn't: the cutter goes the same way, so the hole stays where it was on it.
std::map<uint32_t, glm::mat4> at;
for (Object& c : scene_.objects) {
const Object* t = fry::cutTarget(scene_, c);
if (!t) continue;
const glm::mat4 mt = objectMatrix(*t);
const auto was = followAt_.find(t->id), cWas = followAt_.find(c.id);
if (!drag_.active && was != followAt_.end() && cWas != followAt_.end() && was->second != mt && cWas->second == objectMatrix(c) && !c.locked) {
const glm::mat4 m = mt * glm::inverse(was->second) * objectMatrix(c);
glm::vec3 skew;
glm::vec4 persp;
glm::vec3 scale, pos;
glm::quat rot;
if (glm::decompose(m, scale, rot, pos, skew, persp)) c.position = pos, c.rotation = glm::normalize(rot), c.scale = scale, touched(c.id);
}
at[t->id] = mt, at[c.id] = objectMatrix(c);
}
followAt_ = std::move(at);
std::map<uint32_t, uint64_t> now;
for (const Object& c : scene_.objects) {
const Object* t = fry::cutTarget(scene_, c);
if (!t || c.mesh.faces.empty()) continue;
uint64_t& key = now.emplace(t->id, 1469598103934665603ull).first->second;
key = (key ^ versions_[c.id] ^ (uint64_t(c.id) << 40)) * 1099511628211ull;
const glm::mat4 m = objectMatrix(c);
for (int i = 0; i < 16; ++i) {
uint32_t b;
std::memcpy(&b, &m[i / 4][i % 4], 4);
key = (key ^ b) * 1099511628211ull;
}
}
for (const auto& [id, key] : now)
if (auto it = cutKeys_.find(id); it == cutKeys_.end() || it->second != key) versions_[id] = ++versionClock_;
for (const auto& [id, key] : cutKeys_)
if (!now.count(id)) versions_[id] = ++versionClock_;
cutKeys_ = std::move(now);
}
bool isCutter(const Object& o) const { return fry::cutTarget(scene_, o) != nullptr; }
// Editable cut: the last picked has the others cut out of it, and they stay objects to move.
void cutEditable() {
if (mode_ == Mode::Edit) leaveEdit();
Object* a = find(active_);
if (!a || selected_.size() < 2) return say("Pick the cutters, then (Shift) what they cut last: they stay, to move and change, and the hole follows");
pushUndo();
int n = 0;
for (Object& o : scene_.objects)
if (selected_.count(o.id) && o.id != a->id) o.cuts = a->id, ++n;
for (Object& o : scene_.objects) // (one that was cutting the cutters: not a loop of cuts)
if (o.id == a->id && o.cuts && selected_.count(o.cuts)) o.cuts = 0;
selected_.erase(a->id), active_ = *selected_.rbegin();
dirty_ = true;
say(std::format("{} cut out of {} (editable: move or change {}; Apply makes it for good)", n == 1 ? "1 object" : std::format("{} objects", n), find(a->id)->name, n == 1 ? "it" : "them"));
}
// The cut made for good: one cutter (or all of what's cut out of it) into the shape, gone.
void applyCuts(uint32_t target, uint32_t only = 0) {
Object* t = find(target);
if (!t) return;
pushUndo();
Scene part = scene_; // (just the cutters wanted)
for (Object& o : part.objects)
if (o.cuts == target && only && o.id != only) o.cuts = 0;
Object* pt = &*std::find_if(part.objects.begin(), part.objects.end(), [&](const Object& o) { return o.id == target; });
t->mesh = fry::cutMesh(part, *pt);
t->mirror = 0, t->round = 0, t->smoothLevels = 0, t->creases.clear();
std::set<uint32_t> gone;
for (const Object& o : scene_.objects)
if (o.cuts == target && (!only || o.id == only)) gone.insert(o.id);
std::erase_if(scene_.objects, [&](const Object& o) { return gone.count(o.id) > 0; });
for (uint32_t id : gone) selected_.erase(id);
selected_.insert(target), active_ = target;
changedShape(*find(target));
say(std::format("Cut made for good in {} ({} faces)", find(target)->name, find(target)->mesh.faces.size()));
}
// ───────────────────────────── picking ───────────────────────────── // ───────────────────────────── picking ─────────────────────────────
@ -5110,7 +5193,8 @@ private:
return; return;
} }
for (Object& o : scene_.objects) { for (Object& o : scene_.objects) {
if (!selected_.count(o.id) || o.locked) continue; const bool along = !selected_.count(o.id) && o.cuts && selected_.count(o.cuts) && isCutter(o); // (an editable cutter goes with what it cuts)
if ((!selected_.count(o.id) && !along) || o.locked) continue;
o.position = glm::vec3(T * glm::vec4(o.position, 1.0f)); o.position = glm::vec3(T * glm::vec4(o.position, 1.0f));
if (drag_.tool == Tool::Rotate) o.rotation = glm::normalize(glm::quat_cast(glm::mat3(T)) * o.rotation); if (drag_.tool == Tool::Rotate) o.rotation = glm::normalize(glm::quat_cast(glm::mat3(T)) * o.rotation);
if (drag_.tool == Tool::Scale) { if (drag_.tool == Tool::Scale) {
@ -5607,7 +5691,7 @@ private:
keel::execute(want); keel::execute(want);
} }
// Painted, and shown as painted (with rounding or smoothing added since, it isn't). // Painted, and shown as painted (with rounding or smoothing added since, it isn't).
bool painted(const Object& o) const { return o.picture && o.uvsFit() && !o.hasFinish(); } bool painted(const Object& o) const { return o.picture && o.uvsFit() && !o.hasFinish() && !cutKeys_.count(o.id); }
keel::ModelId modelOf(const Object& o) { keel::ModelId modelOf(const Object& o) {
const bool editingThis = mode_ == Mode::Edit && o.id == active_; const bool editingThis = mode_ == Mode::Edit && o.id == active_;
@ -5688,6 +5772,7 @@ private:
void draw3D(keel::Renderer& r) { void draw3D(keel::Renderer& r) {
settleView(); settleView();
followCuts();
keel::Camera cam; keel::Camera cam;
cam.origin = eye_ * kUnits, cam.yaw = yaw_, cam.pitch = pitch_, cam.fovY = fov_, cam.shift = shift_, cam.zNear = nearFar_.x, cam.zFar = nearFar_.y; cam.origin = eye_ * kUnits, cam.yaw = yaw_, cam.pitch = pitch_, cam.fovY = fov_, cam.shift = shift_, cam.zNear = nearFar_.x, cam.zFar = nearFar_.y;
r.setCamera(cam); r.setCamera(cam);
@ -5712,11 +5797,15 @@ private:
if (o.picture) uploadPicture(o, !(paint_.active && paint_.object == o.id)); if (o.picture) uploadPicture(o, !(paint_.active && paint_.object == o.id));
if (shading() != Shading::Wire) if (shading() != Shading::Wire)
for (const Object& o : scene_.objects) { for (const Object& o : scene_.objects) {
if (!o.visible) continue; if (!o.visible || isCutter(o)) continue;
const glm::vec4 tint = painted(o) ? glm::vec4(1.0f) : glm::vec4(glm::pow(o.color, glm::vec3(2.2f)), 1.0f); // (colours are picked as seen: sRGB) const glm::vec4 tint = painted(o) ? glm::vec4(1.0f) : glm::vec4(glm::pow(o.color, glm::vec3(2.2f)), 1.0f); // (colours are picked as seen: sRGB)
const keel::Surface surface{o.metal, o.rough}; const keel::Surface surface{o.metal, o.rough};
r.drawModel(modelOf(o), toUnits * objectMatrix(o), tint, nullptr, &light, rendered, nullptr, keel::LiquidStyle::None, &surface); r.drawModel(modelOf(o), toUnits * objectMatrix(o), tint, nullptr, &light, rendered, nullptr, keel::LiquidStyle::None, &surface);
} }
// Editable cutters: see-through, so the hole they make shows (not in a saved picture).
if (shading() != Shading::Wire && !capturing())
for (const Object& o : scene_.objects)
if (o.visible && isCutter(o)) r.drawModel(modelOf(o), toUnits * objectMatrix(o), glm::vec4(0.9f, 0.35f, 0.3f, 0.3f), nullptr, &light, false);
// Rendered: a ground to stand on and catch the shadows (not part of what you make). // Rendered: a ground to stand on and catch the shadows (not part of what you make).
if (rendered && look.ground) { if (rendered && look.ground) {
if (!haveGround_) { if (!haveGround_) {
@ -5858,6 +5947,10 @@ private:
if (!o.visible) continue; if (!o.visible) continue;
const bool sel = selected_.count(o.id) > 0 && !clean && tab_ != Tab::Paint; const bool sel = selected_.count(o.id) > 0 && !clean && tab_ != Tab::Paint;
if (ed && o.id == ed->id) continue; if (ed && o.id == ed->id) continue;
if (isCutter(o)) {
if (!clean) lines(o, shownMesh(o), sel ? kAccent : IM_COL32(235, 95, 85, 150), sel ? 1.6f : 1.0f, false);
continue;
}
if (shading() == Shading::Wire || xray_) lines(o, shownMesh(o), sel ? kAccent : IM_COL32(200, 200, 205, 150), sel ? 1.6f : 1.0f, false); if (shading() == Shading::Wire || xray_) lines(o, shownMesh(o), sel ? kAccent : IM_COL32(200, 200, 205, 150), sel ? 1.6f : 1.0f, false);
else if (sel) outline(o, kAccent, 2.5f); else if (sel) outline(o, kAccent, 2.5f);
} }
@ -6286,6 +6379,7 @@ private:
add("shape.separate", "Make into its own object", "Shape", "P", [this] { separateSelected(); }, editing, "separate split"); add("shape.separate", "Make into its own object", "Shape", "P", [this] { separateSelected(); }, editing, "separate split");
add("paint.mirror", "Paint both sides (mirrored)", "Paint", "", [this] { paintMirror_ = !paintMirror_, say(paintMirror_ ? "Painting both sides" : "Painting one side"); }, {}, "symmetry symmetric"); add("paint.mirror", "Paint both sides (mirrored)", "Paint", "", [this] { paintMirror_ = !paintMirror_, say(paintMirror_ ? "Painting both sides" : "Painting one side"); }, {}, "symmetry symmetric");
add("paint.stencil", "Draw a paint stencil (paint only inside it)", "Paint", "", [this] { setTab(Tab::Paint), drawingStencil_ = true, stencil_.clear(); }, {}, "mask masking tape area"); add("paint.stencil", "Draw a paint stencil (paint only inside it)", "Paint", "", [this] { setTab(Tab::Paint), drawingStencil_ = true, stencil_.clear(); }, {}, "mask masking tape area");
add("object.cutEditable", "Cut out, editable (the cutters stay, the hole follows them)", "Object", "", [this] { cutEditable(); }, many, "boolean subtract difference hole non-destructive live");
add("shape.crease", "Crease the picked edges (stay sharp when smoothed)", "Shape", "Shift+E", [this] { creaseSelected(); }, editing, "sharp hard edge subdivision smooth"); add("shape.crease", "Crease the picked edges (stay sharp when smoothed)", "Shape", "Shift+E", [this] { creaseSelected(); }, editing, "sharp hard edge subdivision smooth");
add("shape.slide", "Slide the points along their edges...", "Shape", "Alt+G", [this] { startSlide(); }, editing, "edge slide vertex slide"); add("shape.slide", "Slide the points along their edges...", "Shape", "Alt+G", [this] { startSlide(); }, editing, "edge slide vertex slide");
add("shape.mirror", "Mirror editing (both sides change together)", "Shape", "", [this] { liveMirror_ = !liveMirror_, say(liveMirror_ ? "Mirror editing on: both sides change together" : "Mirror editing off"); }, {}, "symmetry symmetric x"); add("shape.mirror", "Mirror editing (both sides change together)", "Shape", "", [this] { liveMirror_ = !liveMirror_, say(liveMirror_ ? "Mirror editing on: both sides change together" : "Mirror editing off"); }, {}, "symmetry symmetric x");
@ -6455,6 +6549,8 @@ private:
ImGui::TextDisabled("Two or more: the last picked is kept"); ImGui::TextDisabled("Two or more: the last picked is kept");
if (ImGui::MenuItem("Add them together", "Ctrl+Shift++", false, many)) combineSelected(Combine::Add); if (ImGui::MenuItem("Add them together", "Ctrl+Shift++", false, many)) combineSelected(Combine::Add);
if (ImGui::MenuItem("Take the others out of it", "Ctrl+Shift+-", false, many)) combineSelected(Combine::Subtract); if (ImGui::MenuItem("Take the others out of it", "Ctrl+Shift+-", false, many)) combineSelected(Combine::Subtract);
if (ImGui::MenuItem("Cut out, editable", nullptr, false, many)) cutEditable();
if (ImGui::IsItemHovered()) ImGui::SetTooltip("The others cut into the last picked but stay objects: move or change them and the hole follows (Apply makes it for good)");
if (ImGui::MenuItem("Keep only where they overlap", nullptr, false, many)) combineSelected(Combine::Overlap); if (ImGui::MenuItem("Keep only where they overlap", nullptr, false, many)) combineSelected(Combine::Overlap);
if (ImGui::MenuItem("Join (just as they are)", "Ctrl+J", false, many)) joinSelected(); if (ImGui::MenuItem("Join (just as they are)", "Ctrl+J", false, many)) joinSelected();
ImGui::Separator(); ImGui::Separator();
@ -7567,6 +7663,25 @@ private:
} }
if (o->hasFinish() && ImGui::Button("Make it part of the shape", ImVec2(w, 0))) applyFinish(*o); if (o->hasFinish() && ImGui::Button("Make it part of the shape", ImVec2(w, 0))) applyFinish(*o);
if (o->hasFinish() && ImGui::IsItemHovered()) ImGui::SetTooltip("Bake the rounding, smoothing and mirror into the points (so you can edit them)"); if (o->hasFinish() && ImGui::IsItemHovered()) ImGui::SetTooltip("Bake the rounding, smoothing and mirror into the points (so you can edit them)");
if (const Object* t = fry::cutTarget(scene_, *o)) { // (an editable cutter)
ImGui::SeparatorText("Cut");
ImGui::TextColored(accentText(), "Cuts into %s", t->name.c_str());
ImGui::TextDisabled("Move it, turn it, change its shape: the hole follows.");
const uint32_t target = t->id, me = o->id;
if (ImGui::Button("Apply: make the cut for good", ImVec2(w, 0))) return applyCuts(target, me);
if (ImGui::Button("Stop cutting (keep it as an object)", ImVec2(w, 0))) pushUndo(), o->cuts = 0, dirty_ = true;
} else if (cutKeys_.count(o->id)) {
int n = 0;
for (const Object& c : scene_.objects) n += c.cuts == o->id && isCutter(c);
ImGui::SeparatorText("Cut");
ImGui::TextDisabled("%d editable cut%s in it", n, n == 1 ? "" : "s");
if (ImGui::Button(n == 1 ? "Apply: make the cut for good" : "Apply: make them all for good", ImVec2(w, 0))) return applyCuts(o->id);
if (ImGui::Button("Pick the cutters", ImVec2(w, 0))) {
selected_.clear();
for (const Object& c : scene_.objects)
if (c.cuts == o->id) selected_.insert(c.id), active_ = c.id;
}
}
ImGui::SeparatorText("Do"); ImGui::SeparatorText("Do");
if (ImGui::Button("Edit shape (Tab)", ImVec2(w, 0))) enterEdit(); if (ImGui::Button("Edit shape (Tab)", ImVec2(w, 0))) enterEdit();
if (ImGui::Button("Apply turn and size", ImVec2(w, 0))) applyTransform(*o); if (ImGui::Button("Apply turn and size", ImVec2(w, 0))) applyTransform(*o);

View file

@ -233,6 +233,7 @@ std::string sceneText(const Scene& scene) {
for (const Object& o : scene.objects) { for (const Object& o : scene.objects) {
out << "object " << quoted(o.name) << "\n"; out << "object " << quoted(o.name) << "\n";
if (folderAt.count(o.folder)) out << " folder " << folderAt[o.folder] << "\n"; if (folderAt.count(o.folder)) out << " folder " << folderAt[o.folder] << "\n";
if (const Object* t = cutTarget(scene, o)) out << " cuts " << (t - scene.objects.data()) + 1 << "\n"; // (its place in the file)
out << " position " << o.position.x << " " << o.position.y << " " << o.position.z << "\n"; out << " position " << o.position.x << " " << o.position.y << " " << o.position.z << "\n";
out << " rotation " << o.rotation.w << " " << o.rotation.x << " " << o.rotation.y << " " << o.rotation.z << "\n"; // (a quaternion: w x y z) out << " rotation " << o.rotation.w << " " << o.rotation.x << " " << o.rotation.y << " " << o.rotation.z << "\n"; // (a quaternion: w x y z)
out << " scale " << o.scale.x << " " << o.scale.y << " " << o.scale.z << "\n"; out << " scale " << o.scale.x << " " << o.scale.y << " " << o.scale.z << "\n";
@ -373,7 +374,7 @@ std::optional<Scene> parseScene(std::istream& in, const std::string& path, std::
size_t at = 0; size_t at = 0;
l >> at; l >> at;
o->folder = uint32_t(at); // (a place in the file for now) o->folder = uint32_t(at); // (a place in the file for now)
} else if (word == "visible") l >> o->visible; } else if (word == "cuts") l >> o->cuts; // (a place in the file for now) else if (word == "visible") l >> o->visible;
else if (word == "locked") l >> o->locked; else if (word == "locked") l >> o->locked;
else if (word == "recipe") { else if (word == "recipe") {
l >> o->recipe; l >> o->recipe;
@ -422,9 +423,46 @@ std::optional<Scene> parseScene(std::istream& in, const std::string& path, std::
p = std::find_if(s.folders.begin(), s.folders.end(), [&](const Folder& g) { return g.id == p; })->parent; p = std::find_if(s.folders.begin(), s.folders.end(), [&](const Folder& g) { return g.id == p; })->parent;
} }
for (Object& o : s.objects) o.folder = idAt(o.folder); for (Object& o : s.objects) o.folder = idAt(o.folder);
for (Object& o : s.objects) o.cuts = o.cuts >= 1 && o.cuts <= s.objects.size() && &s.objects[o.cuts - 1] != &o ? s.objects[o.cuts - 1].id : 0u;
return s; return s;
} }
const Object* cutTarget(const Scene& s, const Object& cutter) {
if (!cutter.cuts || cutter.cuts == cutter.id) return nullptr;
for (const Object& o : s.objects)
if (o.id == cutter.cuts) return &o;
return nullptr;
}
bool hasCuts(const Scene& s, const Object& o) {
for (const Object& c : s.objects)
if (c.cuts == o.id && c.id != o.id && !c.mesh.faces.empty()) return true;
return false;
}
Mesh cutMesh(const Scene& s, const Object& o, bool mirrorOnly) {
Mesh m = o.hasFinish() ? finishedMesh(o, mirrorOnly) : o.mesh;
const glm::mat4 into = glm::inverse(objectMatrix(o));
for (const Object& c : s.objects) {
if (c.cuts != o.id || c.id == o.id || c.mesh.faces.empty()) continue;
Mesh cutter = c.hasFinish() ? finishedMesh(c) : c.mesh;
transform(cutter, into * objectMatrix(c));
if (Mesh cut = m; combine(cut, cutter, Combine::Subtract)) m = std::move(cut); // (one that takes everything, or fails: left out)
}
return m;
}
Scene withCutsMade(const Scene& s) {
Scene out = s;
for (size_t i = 0; i < out.objects.size(); ++i) {
Object& o = out.objects[i];
if (hasCuts(s, s.objects[i])) {
o.mesh = cutMesh(s, s.objects[i]);
o.mirror = 0, o.round = 0, o.smoothLevels = 0, o.creases.clear();
o.uvs.clear(); // (its painting doesn't fit the new faces)
}
if (cutTarget(s, s.objects[i])) o.visible = false;
}
return out;
}
// ── glTF ── // ── glTF ──
bool exportGltf(const std::string& path, const Scene& scene, const std::vector<std::vector<uint8_t>>& pngs, bool binary, std::string* error) { bool exportGltf(const std::string& path, const Scene& scene, const std::vector<std::vector<uint8_t>>& pngs, bool binary, std::string* error) {

View file

@ -66,6 +66,9 @@ struct Object {
int smoothLevels = 0; int smoothLevels = 0;
bool smoothKeepSharp = false; bool smoothKeepSharp = false;
std::set<std::pair<int, int>> creases; // edges (points, the lower first) smoothing keeps sharp std::set<std::pair<int, int>> creases; // edges (points, the lower first) smoothing keeps sharp
// An editable cut: this object is taken out of that one (its id), and stays an object you can
// move and change (the hole follows); shown see-through, not exported or printed itself.
uint32_t cuts = 0;
bool hasFinish() const { return mirror || round > 0.0f || smoothLevels > 0; } bool hasFinish() const { return mirror || round > 0.0f || smoothLevels > 0; }
// A painted picture on it: where each face corner sits on it (0..1), and the picture // A painted picture on it: where each face corner sits on it (0..1), and the picture
// (shared with the undo steps until it's painted on again). Saved beside the .fry as a PNG. // (shared with the undo steps until it's painted on again). Saved beside the .fry as a PNG.
@ -138,6 +141,14 @@ struct Scene {
uint32_t nextId = 1; uint32_t nextId = 1;
}; };
// Editable cuts: whether something's cut out of it, and it with them cut out (and its finishing
// touches; only the mirror with mirrorOnly) in its own space. The scene with each cut made for
// good and its cutters hidden, for exporting.
const Object* cutTarget(const Scene& s, const Object& cutter); // (what it cuts, if it's there)
bool hasCuts(const Scene& s, const Object& o);
Mesh cutMesh(const Scene& s, const Object& o, bool mirrorOnly = false);
Scene withCutsMade(const Scene& s);
// Materials to start from: a colour (unless `keepColor`: only how metal and rough it is) and // Materials to start from: a colour (unless `keepColor`: only how metal and rough it is) and
// how metal and rough. Shown in the view (Solid and Rendered) and exported with the object. // how metal and rough. Shown in the view (Solid and Rendered) and exported with the object.
struct MaterialPreset { struct MaterialPreset {

View file

@ -709,6 +709,30 @@ int main() {
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); 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);
} }
{ // editable cuts: a box with a bar through it, the bar still an object
Scene sc;
Object box, bar;
box.id = 1, box.name = "Box", box.mesh = makeCube(2.0f);
bar.id = 2, bar.name = "Bar", bar.mesh = makeCube(1.0f), bar.scale = {3, 1, 1}, bar.cuts = 1;
sc.objects = {box, bar};
sc.nextId = 3;
CHECK(cutTarget(sc, sc.objects[1]) == &sc.objects[0] && hasCuts(sc, sc.objects[0]) && !hasCuts(sc, sc.objects[1]), "the bar cuts the box");
const float v = checkForPrint(cutMesh(sc, sc.objects[0])).volume;
CHECK(std::abs(v - 6.0f) < 1e-3f, "a 1x1 tunnel through a 2-box: 6 left (%.3f)", v);
sc.objects[1].position = {0, 0, 0.5f};
const float moved = checkForPrint(cutMesh(sc, sc.objects[0])).volume;
CHECK(std::abs(moved - 6.0f) < 1e-3f && boundsMax(cutMesh(sc, sc.objects[0])).z == 1.0f, "moved up, a slot at the top instead (%.3f)", moved);
const Scene made = withCutsMade(sc);
CHECK(!made.objects[1].visible && std::abs(checkForPrint(made.objects[0].mesh).volume - 6.0f) < 1e-3f, "exporting: the cut made, the bar hidden");
sc.objects[1].cuts = 2;
CHECK(!cutTarget(sc, sc.objects[1]) && !hasCuts(sc, sc.objects[0]), "cutting itself: no cut");
sc.objects[1].cuts = 1;
const auto file = (std::filesystem::temp_directory_path() / "fry3dx_test" / "cuts.fry").string();
std::filesystem::create_directories(std::filesystem::path(file).parent_path());
auto back = saveScene(file, sc) ? loadScene(file) : std::nullopt;
CHECK(back && back->objects.size() == 2 && back->objects[1].cuts == back->objects[0].id && back->objects[0].cuts == 0, "cuts saved and back (to the new ids)");
}
{ // copies along a path: a square's outline, a line, a cube's biggest face { // copies along a path: a square's outline, a line, a cube's biggest face
Mesh sq; Mesh sq;
sq.verts = {{0, 0, 0}, {2, 0, 0}, {2, 2, 0}, {0, 2, 0}}; sq.verts = {{0, 0, 0}, {2, 0, 0}, {2, 2, 0}, {0, 2, 0}};