OverviewFilesCommitsIssuesDocsReleasesPeers

dc-terrain / tests / test_journal_csg.cpp

229 lines · cpp ref:
// ============================================================================
// CSG THROUGH THE FOLD — the layer where a wrong record is silent.
//
// The store ops are tested by volume (test_brush_subtract, test_brush_csg). This
// tests the thing above them, which is where the real trap is: a journal record must
// name its operand in a way that means the SAME THING on another peer.
//
// Brush ids come from a local counter, so two peers creating a brush each allocate
// the same id for different geometry. A record naming an id would therefore replay
// against whatever happened to land on that number — an edit that succeeds, changes
// real geometry, and is wrong, which is the failure dots exist to prevent. So:
//
//   subtract / intersect  carry the operand's PLANE SET outright. Self-contained,
//                         and independent of a cutter brush the op itself consumes.
//   merge                 carries durable brush KEYS, in order, because which brush
//                         SURVIVES is part of the meaning and cannot be said with
//                         planes alone.
//
// And the fold has to be idempotent over all three: it rewinds to base and replays
// every live record on every change, so an op that accumulated would grow the level
// without bound.
//
// Build: g++ -O2 -std=c++17 -I libdc/include -o /tmp/jcsg \
//          tests/test_journal_csg.cpp libdc/src/dc_csg4.cpp
// ============================================================================
#include "dc_brush_store.h"
#include "dc_level_journal.h"
#include "dc_csg4.h"
#include <cstdio>
#include <cmath>
#include <vector>

using dc_journal::Kind;
using dc_journal::Record;

static int fails = 0;

static void ok(const char* what, bool cond) {
    if (!cond) fails++;
    std::printf("  %-64s %s\n", what, cond ? "OK" : "**FAIL**");
}

static void eqi(const char* what, long got, long want) {
    bool c = got == want;
    if (!c) fails++;
    std::printf("  %-64s %-4ld want %-4ld %s\n", what, got, want, c ? "OK" : "**FAIL**");
}

static std::vector<ConvexPlane> box(float x0, float y0, float z0,
                                    float x1, float y1, float z1) {
    return {
        {-1, 0, 0, -x0}, {1, 0, 0, x1},
        {0, -1, 0, -y0}, {0, 1, 0, y1},
        {0, 0, -1, -z0}, {0, 0, 1, z1},
    };
}

static int live_brushes(const DCBrushStore& s) {
    int n = 0;
    for (const DCBrush& b : s.brushes()) if (!b.tombstoned) n++;
    return n;
}

static bool in_any(const DCBrushStore& s, float x, float y, float z) {
    for (const DCBrush& b : s.brushes()) {
        if (b.tombstoned) continue;
        bool in = true;
        for (const ConvexPlane& p : b.planes)
            if (p.nx * x + p.ny * y + p.nz * z > p.d + 1e-4f) { in = false; break; }
        if (in) return true;
    }
    return false;
}

// An imported wall, so a rewind does not tombstone the subject itself.
static uint32_t import_wall(DCBrushStore& s) {
    return s.add_brush(box(0, 0, 0, 8, 4, 1), {}, 7, 0, false, "");
}

int main() {
    std::printf("=== csg through the journal fold ===\n\n");

    std::printf("-- a subtract record cuts a doorway, and folds again to the same --\n");
    {
        DCBrushStore s;
        uint32_t w = import_wall(s);
        const std::string wkey = s.get(w)->key;

        Record r;
        r.author = "alice"; r.seq = 1; r.kind = Kind::BrushSubtract;
        r.brush_key = wkey;
        r.planes = box(3, -1, -1, 5, 3, 2);

        std::vector<Record> journal = {r};
        dc_journal::fold(journal, s);
        const int after_one = live_brushes(s);
        ok("the doorway is open", !in_any(s, 4, 1, 0.5f));
        ok("and the wall either side is not", in_any(s, 1, 1, 0.5f) &&
                                              in_any(s, 7, 1, 0.5f));
        std::printf("     -> %d brushes\n", after_one);

        // THE FOLD RUNS ON EVERY CHANGE. Three more of them must not grow anything.
        dc_journal::fold(journal, s);
        dc_journal::fold(journal, s);
        dc_journal::fold(journal, s);
        eqi("three more folds change nothing", live_brushes(s), after_one);
        ok("and the doorway is still open", !in_any(s, 4, 1, 0.5f));
    }

    std::printf("\n-- undo is a tombstone plus a re-fold, with no inverse op --\n");
    {
        DCBrushStore s;
        uint32_t w = import_wall(s);
        Record r;
        r.author = "alice"; r.seq = 1; r.kind = Kind::BrushSubtract;
        r.brush_key = s.get(w)->key;
        r.planes = box(3, -1, -1, 5, 3, 2);

        std::vector<Record> journal = {r};
        dc_journal::fold(journal, s);
        ok("cut", !in_any(s, 4, 1, 0.5f));

        journal[0].undone = true;
        dc_journal::fold(journal, s);
        ok("undone — the wall is whole again", in_any(s, 4, 1, 0.5f));
        eqi("and it is ONE brush, not three tombstoned ones", live_brushes(s), 1);

        journal[0].undone = false;
        dc_journal::fold(journal, s);
        ok("redone", !in_any(s, 4, 1, 0.5f));
    }

    std::printf("\n-- TWO PEERS, one journal, identical levels --\n");
    {
        // The whole point of naming operands by plane set rather than by id: these
        // two stores allocate ids independently, so a record naming one would mean
        // something different in each.
        DCBrushStore a, b;
        const uint32_t wa = import_wall(a);
        // Peer B made a brush of its own first. That bumps its ID COUNTER without
        // touching the import sequence — created brushes carry their own key and
        // never draw from it — so B's wall has the SAME durable key as A's on a
        // DIFFERENT local id. Which is the whole point: that divergence is invisible
        // to a record naming a key, and fatal to one naming an id.
        b.add_brush(box(100, 0, 0, 101, 1, 1), {}, 7, 0, false, "scratch:1");
        const uint32_t wb = import_wall(b);

        const std::string ka = a.get(wa)->key;
        const std::string kb = b.get(wb)->key;
        ok("the two walls carry the same durable key", ka == kb);
        ok("on different local ids", wa != wb);

        Record r;
        r.author = "alice"; r.seq = 1; r.kind = Kind::BrushSubtract;
        r.brush_key = ka;
        r.planes = box(3, -1, -1, 5, 3, 2);
        std::vector<Record> journal = {r};

        dc_journal::fold(journal, a);
        dc_journal::fold(journal, b);
        ok("peer A cut its wall", !in_any(a, 4, 1, 0.5f));
        ok("peer B cut the same wall, from the same record", !in_any(b, 4, 1, 0.5f));
        // AND B'S OWN BRUSH IS GONE — which is right, and worth asserting rather
        // than working around. fold() rewinds to base first, and reset_to_base
        // tombstones everything that was CREATED rather than imported. B made that
        // brush directly in the store with no record behind it, so the fold has
        // nothing to rebuild it from. The journal is the state: geometry with no
        // record does not survive a fold, and that is the property the whole design
        // rests on.
        ok("B's record-less scratch brush did NOT survive the fold — the journal is "
           "the state", !in_any(b, 100.5f, 0.5f, 0.5f));
    }

    std::printf("\n-- intersect through a record --\n");
    {
        DCBrushStore s;
        uint32_t a = s.add_brush(box(0, 0, 0, 4, 4, 4), {}, 7, 0, false, "");
        Record r;
        r.author = "alice"; r.seq = 1; r.kind = Kind::BrushIntersect;
        r.brush_key = s.get(a)->key;
        r.planes = box(2, 2, 2, 6, 6, 6);
        std::vector<Record> journal = {r};
        dc_journal::fold(journal, s);
        ok("only the overlap survives", in_any(s, 3, 3, 3) && !in_any(s, 1, 1, 1));
        dc_journal::fold(journal, s);
        ok("and folding again is stable", in_any(s, 3, 3, 3) && !in_any(s, 1, 1, 1));
        eqi("one brush", live_brushes(s), 1);
    }

    std::printf("\n-- merge through a record, named by KEY because order matters --\n");
    {
        DCBrushStore s;
        uint32_t a = s.add_brush(box(0, 0, 0, 1, 1, 1), {}, 7, 0, false, "");
        uint32_t b = s.add_brush(box(3, 0, 0, 4, 1, 1), {}, 7, 0, false, "");
        const std::string ka = s.get(a)->key;
        const std::string kb = s.get(b)->key;

        Record r;
        r.author = "alice"; r.seq = 1; r.kind = Kind::BrushMerge;
        r.brush_keys = {ka, kb};
        std::vector<Record> journal = {r};
        dc_journal::fold(journal, s);
        eqi("two brushes became one", live_brushes(s), 1);
        ok("the FIRST key is the survivor, keeping its name", s.brush_by_key(ka) != 0);
        ok("and the gap is filled, because this is a hull", in_any(s, 2, 0.5f, 0.5f));

        dc_journal::fold(journal, s);
        eqi("folding again is stable", live_brushes(s), 1);
    }

    std::printf("\n-- a record whose target is gone is SKIPPED, never guessed at --\n");
    {
        DCBrushStore s;
        import_wall(s);
        Record r;
        r.author = "alice"; r.seq = 1; r.kind = Kind::BrushSubtract;
        r.brush_key = "i:999";                   // no such brush
        r.planes = box(3, -1, -1, 5, 3, 2);
        std::vector<Record> journal = {r};
        const dc_journal::FoldStats st = dc_journal::fold(journal, s);
        eqi("skipped", (long)st.skipped, 1);
        eqi("nothing applied", (long)st.applied, 0);
        ok("and the wall is untouched", in_any(s, 4, 1, 0.5f));
    }

    std::printf("\n%s\n", fails ? "FAILED" : "PASS");
    return fails ? 1 : 0;
}