Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
123 changes: 123 additions & 0 deletions src/editor/document.cpp
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
#include "editor/document.hh"

#include <algorithm>
#include <cmath>
#include <exception>
#include <functional>
#include <system_error>
Expand Down Expand Up @@ -93,6 +94,7 @@ std::optional<std::string> Document::load(const fs::path& project, const fs::pat
clearHistory();
++m_loadId;
m_selection.reset();
m_pinned.reset();
m_collapsed.clear();
m_meshes.clear();
m_textures.clear();
Expand Down Expand Up @@ -126,6 +128,7 @@ void Document::reset(splash::Scene scene, const fs::path& project, const std::st
++m_loadId;
++m_revision;
m_selection.reset();
m_pinned.reset();
m_collapsed.clear();
m_meshes.clear();
m_textures.clear();
Expand Down Expand Up @@ -305,11 +308,94 @@ class StructureCommand : public Command {
bool m_insert;
};

// Moves one object with its subtree from `from` to `to` (a path in the tree
// as it is once the object has been taken out), swapping its transform.
class MoveCommand : public Command {
public:
MoveCommand(ObjectPath from, ObjectPath to, splash::Transform before, splash::Transform after)
: m_from(std::move(from)), m_to(std::move(to)), m_before(before), m_after(after) {}
void apply(Document& doc) override { move(doc, m_from, m_to, m_after); }
void revert(Document& doc) override { move(doc, m_to, m_from, m_before); }

private:
static void move(Document& doc, const ObjectPath& a, const ObjectPath& b, const splash::Transform& t) {
std::vector<splash::Object>* src = doc.childrenMut(ObjectPath(a.begin(), a.end() - 1));
if (!src || static_cast<size_t>(a.back()) >= src->size()) return;
const bool pinned = doc.pinned() && *doc.pinned() == a;
splash::Object obj = std::move((*src)[a.back()]);
src->erase(src->begin() + a.back());
doc.shiftPaths(a, -1);
std::vector<splash::Object>* dst = doc.childrenMut(ObjectPath(b.begin(), b.end() - 1));
if (!dst) return;
obj.transform = t;
size_t i = std::min(static_cast<size_t>(b.back()), dst->size());
dst->insert(dst->begin() + static_cast<std::ptrdiff_t>(i), std::move(obj));
doc.shiftPaths(b, +1);
doc.select(b);
if (pinned) doc.pin(b);
}
ObjectPath m_from, m_to;
splash::Transform m_before, m_after;
};

splash::Quat mulQ(splash::Quat a, splash::Quat b) {
return {a.w * b.x + a.x * b.w + a.y * b.z - a.z * b.y, a.w * b.y + a.y * b.w + a.z * b.x - a.x * b.z,
a.w * b.z + a.z * b.w + a.x * b.y - a.y * b.x, a.w * b.w - a.x * b.x - a.y * b.y - a.z * b.z};
}

// World position, rotation and lossy scale composed the way splash::flatten does.
struct World {
splash::Vec3 pos;
splash::Quat rot;
splash::Vec3 scale{1, 1, 1};
splash::Mat34 m = splash::Mat34::trs({}, {}, {1, 1, 1});
};

World worldOf(const Document& doc, const ObjectPath& path) {
World w;
for (size_t n = 1; n <= path.size(); ++n) {
const splash::Transform& t = doc.object(ObjectPath(path.begin(), path.begin() + n))->transform;
if (n == 1) {
w.pos = t.position;
w.rot = t.rotation;
w.scale = t.scale;
} else {
w.pos = w.m.point(t.position);
w.rot = mulQ(w.rot, t.rotation);
w.scale = splash::scale(w.scale, t.scale);
}
w.m = splash::Mat34::trs(w.pos, w.rot, w.scale);
}
return w;
}

// The local transform that puts an object at `w` under a parent at `p`.
splash::Transform localUnder(const World& p, const World& w) {
splash::Transform t;
const float(*m)[4] = p.m.m;
splash::Vec3 d = w.pos - p.m.position();
float a = m[0][0], b = m[0][1], c = m[0][2], e = m[1][0], f = m[1][1], g = m[1][2], h = m[2][0], i = m[2][1], k = m[2][2];
float det = a * (f * k - g * i) - b * (e * k - g * h) + c * (e * i - f * h);
if (std::fabs(det) < 1e-12f) {
t.position = d;
} else {
float inv = 1.0f / det;
t.position = {((f * k - g * i) * d.x + (c * i - b * k) * d.y + (b * g - c * f) * d.z) * inv,
((g * h - e * k) * d.x + (a * k - c * h) * d.y + (c * e - a * g) * d.z) * inv,
((e * i - f * h) * d.x + (b * h - a * i) * d.y + (a * f - b * e) * d.z) * inv};
}
t.rotation = mulQ({-p.rot.x, -p.rot.y, -p.rot.z, p.rot.w}, w.rot);
auto div = [](float x, float y) { return y != 0 ? x / y : x; };
t.scale = {div(w.scale.x, p.scale.x), div(w.scale.y, p.scale.y), div(w.scale.z, p.scale.z)};
return t;
}

} // namespace

void Document::shiftPaths(const ObjectPath& at, int delta) {
if (at.empty()) return;
if (m_selection) m_selection = shifted(*m_selection, at, delta);
if (m_pinned) m_pinned = shifted(*m_pinned, at, delta);
std::set<ObjectPath> collapsed;
for (const ObjectPath& p : m_collapsed)
if (p.empty()) collapsed.insert(p);
Expand Down Expand Up @@ -401,6 +487,34 @@ std::optional<ObjectPath> Document::duplicateObject(const ObjectPath& path) {
return at;
}

std::optional<ObjectPath> Document::moveObject(const ObjectPath& from, const ObjectPath& toParent, int index) {
const splash::Object* o = object(from);
if (!o || index < 0) return std::nullopt;
if (!toParent.empty() && !object(toParent)) return std::nullopt;
// Into itself or its own subtree.
if (toParent.size() >= from.size() && std::equal(from.begin(), from.end(), toParent.begin())) return std::nullopt;
const std::vector<splash::Object>& kids = toParent.empty() ? m_scene.objects : object(toParent)->children;
index = std::min(index, static_cast<int>(kids.size()));
const ObjectPath fromParent(from.begin(), from.end() - 1);
// Where the object lands once it has been taken out.
ObjectPath to = *shifted(toParent, from, -1);
if (fromParent == toParent && from.back() < index) --index;
to.push_back(index);
if (to == from) return std::nullopt;
const splash::Transform before = o->transform;
splash::Transform after;
const World w = worldOf(*this, from);
if (toParent.empty()) {
after.position = w.pos;
after.rotation = w.rot;
after.scale = w.scale;
} else {
after = localUnder(worldOf(*this, toParent), w);
}
execute(std::make_unique<MoveCommand>(from, to, before, after));
return to;
}

bool Document::undo() {
if (!canUndo()) return false;
m_mergeOpen = false;
Expand Down Expand Up @@ -453,6 +567,15 @@ void Document::toggleExpanded(const ObjectPath& path) {
if (!m_collapsed.erase(path)) m_collapsed.insert(path);
}

void Document::setAllExpanded(bool expanded) {
const bool rootCollapsed = m_collapsed.count(ObjectPath{}) != 0;
m_collapsed.clear();
if (rootCollapsed) m_collapsed.insert(ObjectPath{});
if (expanded) return;
for (const ObjectPath& p : flatPaths())
if (!object(p)->children.empty()) m_collapsed.insert(p);
}

const MeshInfo* Document::mesh(const std::string& projectPath) const {
auto it = m_meshes.find(projectPath);
return it == m_meshes.end() ? nullptr : &it->second;
Expand Down
14 changes: 14 additions & 0 deletions src/editor/document.hh
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,13 @@ public:
// Every node starts expanded; the empty path is the scene root row.
bool expanded(const ObjectPath& path) const { return !m_collapsed.count(path); }
void toggleExpanded(const ObjectPath& path);
// Expands or collapses every object that has children (the root row stays as it is).
void setAllExpanded(bool expanded);

// The inspector's pinned object: shown instead of the selection while set.
// Follows the object through inserts and removes; cleared when it goes.
const std::optional<ObjectPath>& pinned() const { return m_pinned; }
void pin(std::optional<ObjectPath> path) { m_pinned = std::move(path); }

// Editing. Every change goes through the history: it bumps revision() and
// marks the document dirty.
Expand All @@ -112,6 +119,12 @@ public:
// Inserts a copy right after the original, named the way Unity names
// duplicates ("Crate (2)"), and selects it. Returns the copy's path.
std::optional<ObjectPath> duplicateObject(const ObjectPath& path);
// Moves the object at `from`, with its children, to be child `index` of
// `toParent` (empty = the scene root), keeping where it is in the world.
// `index` counts the new parent's children as they are now, before the
// move. Refused (nullopt) when `toParent` is the object itself or inside
// it. Selects the object and returns its new path.
std::optional<ObjectPath> moveObject(const ObjectPath& from, const ObjectPath& toParent, int index);
// Applies `cmd` and pushes it, dropping anything that could be redone.
// With `mergeable`, it may fold into the previous command (see Command::merge).
void execute(std::unique_ptr<Command> cmd, bool mergeable = false);
Expand Down Expand Up @@ -176,6 +189,7 @@ private:
unsigned m_assetsId = 0;
std::optional<ObjectPath> m_selection;
std::set<ObjectPath> m_collapsed;
std::optional<ObjectPath> m_pinned;
std::map<std::string, MeshInfo> m_meshes;
std::map<std::string, TextureInfo> m_textures;
std::map<std::string, bool> m_files;
Expand Down
85 changes: 85 additions & 0 deletions src/editor/tests/editor_tests.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -861,6 +861,90 @@ void testAdoptAsset() {

} // namespace

// Reparenting keeps the object where it is in the world, follows the paths
// of everything else, and undoes in one step.
void testMove() {
auto worldOf = [](const editor::Document& d, const std::string& name) {
for (const splash::FlatObject& fo : splash::flatten(d.scene()))
if (fo.object->name == name) return fo;
return splash::FlatObject{};
};
auto same = [](splash::Vec3 a, splash::Vec3 b) { return near(a.x, b.x) && near(a.y, b.y) && near(a.z, b.z); };
editor::Document d;
splash::Scene s = sample();
// B: moved, turned 90 degrees around Y and scaled 2; C sits elsewhere.
s.objects[1].transform.position = {10, 0, 0};
s.objects[1].transform.rotation = {0, 0.70710678f, 0, 0.70710678f};
s.objects[1].transform.scale = {2, 2, 2};
s.objects[1].children[0].transform.position = {1, 2, 3};
s.objects[2].transform.position = {5, 1, -2};
d.reset(s);
const splash::FlatObject c0 = worldOf(d, "C");
const splash::FlatObject b10 = worldOf(d, "B1");

// C into B: becomes B's second child, same world position.
d.select(editor::ObjectPath{0});
d.pin(editor::ObjectPath{0});
auto to = d.moveObject({2}, {1}, 1);
CHECK(to && *to == editor::ObjectPath({1, 1}));
CHECK(d.scene().objects.size() == 2 && d.object({1, 1}) && d.object({1, 1})->name == "C");
CHECK(same(worldOf(d, "C").worldPosition, c0.worldPosition));
CHECK(same(worldOf(d, "C").lossyScale, c0.lossyScale));
{
// Same rotation (q and -q are the same turn).
splash::Quat a = worldOf(d, "C").worldRotation, b = c0.worldRotation;
CHECK(near(std::fabs(a.x * b.x + a.y * b.y + a.z * b.z + a.w * b.w), 1));
}
CHECK(d.object({1, 1})->transform.scale == splash::Vec3({0.5f, 0.5f, 0.5f}));
CHECK(d.selected() && d.selected()->name == "C");
CHECK(d.pinned() && d.object(*d.pinned())->name == "A"); // the pin stays on A
CHECK(d.undo());
CHECK(d.object({2}) && d.object({2})->name == "C" && d.object({1})->children.size() == 1);
CHECK(d.object({2})->transform.position == splash::Vec3({5, 1, -2}));
CHECK(d.redo());
CHECK(d.object({1, 1})->name == "C");
CHECK(d.undo());

// B1 out to the root, before A: world position kept, B loses its child.
auto out = d.moveObject({1, 0}, {}, 0);
CHECK(out && *out == editor::ObjectPath({0}));
CHECK(d.object({0})->name == "B1" && d.object({2})->name == "B" && d.object({2})->children.empty());
CHECK(same(worldOf(d, "B1").worldPosition, b10.worldPosition));
CHECK(same(d.object({0})->transform.position, b10.worldPosition));
CHECK(d.pinned() && d.object(*d.pinned())->name == "A"); // A moved to index 1, the pin followed
CHECK(d.undo());
CHECK(d.object({1, 0}) && d.object({1, 0})->name == "B1");
CHECK(d.object({1, 0})->transform.position == splash::Vec3({1, 2, 3}));

// Reorder among siblings: A after C (index counts A still in place).
auto re = d.moveObject({0}, {}, 3);
CHECK(re && *re == editor::ObjectPath({2}));
CHECK(d.object({0})->name == "B" && d.object({2})->name == "A");
CHECK(d.pinned() && *d.pinned() == editor::ObjectPath({2})); // the pinned object itself moved
CHECK(d.undo());

// Refused: into itself, into its own child, no-op, bad paths.
size_t steps = d.historySize();
CHECK(!d.moveObject({1}, {1}, 0));
CHECK(!d.moveObject({1}, {1, 0}, 0));
CHECK(!d.moveObject({1}, {}, 1));
CHECK(!d.moveObject({1}, {}, 2)); // after itself = where it is
CHECK(!d.moveObject({9}, {}, 0));
CHECK(!d.moveObject({0}, {7}, 0));
CHECK(d.historySize() == steps);

// Expand / collapse all.
d.setAllExpanded(false);
CHECK(!d.expanded({1}) && d.expanded({0}) && d.expanded({}));
d.setAllExpanded(true);
CHECK(d.expanded({1}));

// Removing the pinned object clears the pin.
d.pin(editor::ObjectPath{1});
CHECK(d.removeObject({1}));
CHECK(!d.pinned());
}

int main(int argc, char** argv) {
std::error_code ec;
std::filesystem::path exe = argc > 0 ? std::filesystem::absolute(argv[0], ec) : std::filesystem::path();
Expand All @@ -871,6 +955,7 @@ int main(int argc, char** argv) {
testRenderPeak();
testDirtyAndSave();
testStructure();
testMove();
testPick();
testGizmoMath();
testScaleClamp();
Expand Down
Loading
Loading