// ============================================================================
// 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;
}