// ============================================================================
// SHAPE-EDIT GATE — the (B) branch of level-mesh-architecture.md's resolved fork.
//
// The architecture says shape editing "is safe by construction: a brush IS an
// intersection of half-spaces, and ANY set of half-spaces intersects to a convex
// solid — so moving a face, adding a face, or removing a face all yield a valid
// brush. No model, no re-fitting, no loss."
//
// That is true and it is not the whole truth. A set of half-spaces intersects to
// a convex solid, but that solid CAN BE EMPTY. Push a face past its opposite and
// the brush still has six perfectly valid planes and encloses nothing. Nothing in
// the store notices: set_face_plane returns true, generation increments, and a
// wall silently ceases to exist with no error anywhere. For a player-facing
// editor that is the difference between "I nudged that face too far" and "the
// room is gone and I cannot get it back except by undo I may not have".
//
// So this file pins the contract the store does NOT currently keep:
//
// *** AN EDIT THAT WOULD EMPTY A BRUSH IS REJECTED, NOT APPLIED. ***
//
// Rejected rather than auto-deleted on purpose: deletion is a separate, explicit
// operation (remove_brush tombstones and restore_brush brings it back), and an
// editor that deletes geometry as a side effect of a drag is a worse tool than
// one that refuses the drag.
//
// Emptiness is decided with the REAL generator (csg4 face_winding), not a
// bespoke predicate — the whole point is that the mesher and the validator agree
// about what exists.
//
// Build: g++ -O2 -std=c++17 -I libdc/include -o /tmp/brush_edit \
// tests/test_brush_edit.cpp libdc/src/dc_csg4.cpp
// ============================================================================
#include "dc_brush_store.h"
#include "dc_csg4.h"
#include <cstdio>
#include <vector>
static int g_fail = 0;
static void ok(const char* what, bool cond) {
if (!cond) g_fail++;
printf(" %-62s %s\n", what, cond ? "OK" : "**FAIL**");
}
static void eq_u(const char* what, uint64_t got, uint64_t want) {
const bool c = (got == want);
if (!c) g_fail++;
printf(" %-62s %-8llu want %-8llu %s\n", what,
(unsigned long long)got, (unsigned long long)want, c ? "OK" : "**FAIL**");
}
// An axis-aligned box as six half-spaces, outward normals.
static std::vector<ConvexPlane> box_planes(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 },
};
}
// Does this brush enclose anything? Decided by the mesher: a brush with no face
// that produces a winding encloses nothing, whatever its plane count says.
static bool encloses_volume(const DCBrush& b) {
// CSG4Brush is a non-owning VIEW — the caller owns the storage.
CSG4Brush cb;
cb.planes = b.planes.data();
cb.plane_count = (uint32_t)b.planes.size();
for (uint32_t i = 0; i < cb.plane_count; i++) {
std::vector<CSG4Vec3> pts;
if (dc_csg4::face_winding(cb, i, pts) && pts.size() >= 3) return true;
}
return false;
}
int main() {
printf("=== Shape-edit gate (level-mesh-architecture.md (B)) ===\n\n");
// ---- baseline: a unit cube is a real solid -----------------------------
printf("-- a cube encloses volume, and edits preserve the parallel arrays --\n");
DCBrushStore store;
const std::vector<uint32_t> proj = { 1, 2, 3, 4, 5, 6 };
const uint32_t id = store.add_brush(box_planes(0, 0, 0, 1, 1, 1), proj, /*material*/ 7);
const DCBrush* b = store.get(id);
ok("brush reads back", b != nullptr);
if (!b) { printf("\nFAILED\n"); return 1; }
ok("cube encloses volume", encloses_volume(*b));
eq_u("planes", b->planes.size(), 6);
eq_u("plane_proj stays parallel", b->plane_proj.size(), b->planes.size());
eq_u("material survives", b->material_id, 7);
const uint32_t gen0 = b->generation;
// ---- read-back is what makes undo possible -----------------------------
// set_face_plane has no inverse unless the caller can record the plane it is
// replacing, so this is load-bearing for the editor, not a convenience.
const ConvexPlane before = b->planes[1];
ok("set_face_plane accepts a legal move", store.set_face_plane(id, 1, { 1, 0, 0, 0.5f }));
b = store.get(id);
ok("the plane actually changed", b->planes[1].d == 0.5f);
ok("generation advanced (replication needs this)", b->generation > gen0);
ok("still encloses volume", encloses_volume(*b));
ok("undo restores the prior plane", store.set_face_plane(id, 1, before));
ok("restored", store.get(id)->planes[1].d == before.d);
// ---- add / remove keep proj parallel -----------------------------------
printf("\n-- add_face / remove_face keep plane_proj in lockstep --\n");
const uint32_t f = store.add_face(id, { 0.577f, 0.577f, 0.577f, 1.2f }, /*proj*/ 42);
ok("add_face returned a face", f != UINT32_MAX);
b = store.get(id);
eq_u("planes after add", b->planes.size(), 7);
eq_u("proj parallel after add", b->plane_proj.size(), b->planes.size());
eq_u("proj value kept", b->plane_proj[f], 42);
ok("bevelled brush still encloses volume", encloses_volume(*b));
ok("remove_face", store.remove_face(id, f));
b = store.get(id);
eq_u("planes after remove", b->planes.size(), 6);
eq_u("proj parallel after remove", b->plane_proj.size(), b->planes.size());
// ---- THE GATE: an edit that empties the brush must be refused ----------
printf("\n-- an edit that would empty the brush is REJECTED --\n");
b = store.get(id);
const size_t planes_before = b->planes.size();
const uint32_t gen_before = b->generation;
// Push +X back past -X. Six valid planes, zero volume.
const bool applied = store.set_face_plane(id, 1, { 1, 0, 0, -0.5f });
b = store.get(id);
ok("set_face_plane REFUSES an emptying move", !applied);
ok("brush still encloses volume after the refusal", encloses_volume(*b));
eq_u("plane count untouched", b->planes.size(), planes_before);
eq_u("generation NOT bumped by a refused edit", b->generation, gen_before);
// Same hazard through add_face: a plane that cuts the whole solid away.
const uint32_t bad = store.add_face(id, { -1, 0, 0, -2.0f }, 0);
b = store.get(id);
ok("add_face REFUSES a plane that empties the brush", bad == UINT32_MAX);
ok("brush survives the refused add", encloses_volume(*b));
eq_u("proj still parallel after a refused add", b->plane_proj.size(), b->planes.size());
// Deleting geometry stays EXPLICIT, and stays reversible.
printf("\n-- deletion is explicit and reversible, never a side effect --\n");
ok("remove_brush tombstones", store.remove_brush(id));
ok("restore_brush brings it back", store.restore_brush(id));
ok("restored brush is intact", encloses_volume(*store.get(id)));
// ---- face picking: the unit the architecture says to edit ---------------
// get_brush_id_from_shape resolves a raycast to a BRUSH. Face-level editing
// needs the FACE, and a ray hit gives a point on the surface — so the pick is
// "which of this brush's planes does this point lie on". Lives here rather
// than in DCLevel so it is testable without Godot.
printf("\n-- face picking from a surface point --\n");
eq_u("+X face", (uint64_t)(int64_t)store.nearest_face(id, 1.0f, 0.5f, 0.5f), 1);
eq_u("-X face", (uint64_t)(int64_t)store.nearest_face(id, 0.0f, 0.5f, 0.5f), 0);
eq_u("+Y face", (uint64_t)(int64_t)store.nearest_face(id, 0.5f, 1.0f, 0.5f), 3);
eq_u("-Z face", (uint64_t)(int64_t)store.nearest_face(id, 0.5f, 0.5f, 0.0f), 4);
ok("unknown brush picks nothing", store.nearest_face(999999, 0, 0, 0) < 0);
// A point slightly off the surface still resolves to the nearest face — a
// raycast hit is never exactly on the plane in floating point.
eq_u("+X face from a point 1mm off the surface",
(uint64_t)(int64_t)store.nearest_face(id, 1.001f, 0.5f, 0.5f), 1);
// The pick must ignore faces that carry no winding, or a bevel plane that
// touches the solid at a single point could out-rank the face actually hit.
printf("\n-- the pick ignores planes that bound nothing --\n");
const uint32_t bevel = store.add_face(id, { 0.577f, 0.577f, 0.577f, 1.2f }, 9);
ok("bevel added", bevel != UINT32_MAX);
eq_u("a point on +X still picks +X, not the bevel",
(uint64_t)(int64_t)store.nearest_face(id, 1.0f, 0.2f, 0.2f), 1);
store.remove_face(id, bevel);
// ---- stable face identity across renumbering ---------------------------
// remove_face RENUMBERS every face after it, and the header says so: "callers
// holding face indices across this call (an editor selection, a pending
// set_face_plane) must re-resolve". That warning is the whole problem:
//
// - an editor selection silently points at a DIFFERENT face after a removal
// - a replicated op-log keyed by face index stops meaning what it said
//
// Both are silent-wrong-geometry bugs, not errors. An index is a position; what
// these callers need is an IDENTITY that survives its neighbours moving.
//
// THE IDENTITY IS A CRDT DOT — (replica, counter) — not a local counter. A
// local counter is only deterministic if every peer applies the same ops in the
// same order, which this stack does NOT give the game layer: the durable view
// is last-write-wins per key, not an ordered op stream. A dot needs no order.
// Imported faces take a reserved "i:<index>" dot because every peer derives
// those from the same .MAP deterministically; added faces take the dot of the
// op that created them, which is already unique and already allocated.
printf("\n-- face identity survives renumbering --\n");
{
DCBrushStore s2;
const uint32_t c = s2.add_brush(box_planes(0, 0, 0, 2, 2, 2),
{ 10, 11, 12, 13, 14, 15 }, 3);
const DCBrush* cb2 = s2.get(c);
eq_u("a fresh brush has one key per face", cb2->plane_key.size(), 6);
ok("imported faces take the deterministic dot", cb2->plane_key[3] == "i:3");
const std::string y_key = cb2->plane_key[3];
const float y_d = cb2->planes[3].d;
const uint32_t y_pr = cb2->plane_proj[3];
ok("face_by_key finds it before the removal", s2.face_by_key(c, y_key) == 3);
// Drop face 1 (+X). Everything after it shifts down by one.
ok("remove_face(1)", s2.remove_face(c, 1));
cb2 = s2.get(c);
eq_u("planes after removal", cb2->planes.size(), 5);
eq_u("keys stay parallel", cb2->plane_key.size(), cb2->planes.size());
eq_u("proj stays parallel", cb2->plane_proj.size(), cb2->planes.size());
// The INDEX moved; the IDENTITY did not.
const int y_now = s2.face_by_key(c, y_key);
eq_u("the +Y face is now at index 2", (uint64_t)(int64_t)y_now, 2);
ok("and it is the same plane", cb2->planes[y_now].d == y_d);
ok("carrying the same projection", cb2->plane_proj[y_now] == y_pr);
ok("the removed face's key no longer resolves", s2.face_by_key(c, "i:1") < 0);
// An added face carries the dot of the op that created it. Two peers
// replaying the same op therefore agree WITHOUT agreeing on an order.
const uint32_t added = s2.add_face(c, { 0.577f, 0.577f, 0.577f, 2.4f }, 99,
"alice:1786900000001");
ok("add_face with a dot succeeded", added != UINT32_MAX);
cb2 = s2.get(c);
ok("the dot is what identifies it",
s2.face_by_key(c, "alice:1786900000001") == (int)added);
// A DIFFERENT replica's op cannot collide, whatever its counter says —
// that is the entire point of the pair.
const uint32_t added2 = s2.add_face(c, { -0.577f, 0.577f, 0.577f, 2.4f }, 98,
"bob:1786900000001");
ok("a different replica with the SAME counter does not collide",
added2 != UINT32_MAX && s2.face_by_key(c, "bob:1786900000001") == (int)added2);
ok("and alice's face is untouched",
s2.face_by_key(c, "alice:1786900000001") >= 0);
// Replay must be IDEMPOTENT: the same op arriving twice (bulk load plus a
// live update, which state_changed does routinely) must not add twice.
const uint32_t dup = s2.add_face(c, { 0.577f, -0.577f, 0.577f, 2.4f }, 97,
"alice:1786900000001");
eq_u("re-adding the same dot is refused, not duplicated",
(uint64_t)dup, (uint64_t)UINT32_MAX);
int seen = 0;
for (const std::string& k : s2.get(c)->plane_key)
if (k == "alice:1786900000001") seen++;
eq_u("the dot appears exactly once", (uint64_t)seen, 1);
}
// ---- the face as a polygon ---------------------------------------------
// An editor cannot show you what you selected without the face's actual
// outline, and every gizmo beyond a highlight (a drag handle, a normal arrow,
// a texture-lock preview) needs the same thing. This is the BRUSH-LOCAL
// winding — the whole face, not the part left after neighbours clip it —
// because that is the face set_face_plane moves.
printf("\n-- a face has a polygon you can draw --\n");
{
DCBrushStore s3;
const uint32_t c = s3.add_brush(box_planes(0, 0, 0, 2, 2, 2),
{ 0, 0, 0, 0, 0, 0 }, 0);
std::vector<CSG4Vec3> pts;
ok("+X face has a winding", s3.face_winding(c, 1, pts));
eq_u("a cube face is a quad", pts.size(), 4);
// Every point must lie ON the plane it came from, or the highlight would
// sit off the surface it claims to mark.
bool on_plane = true;
float min_y = 1e9f, max_y = -1e9f;
for (const CSG4Vec3& p : pts) {
if (std::fabs(p.x - 2.0f) > 1e-4f) on_plane = false;
min_y = std::fmin(min_y, p.y);
max_y = std::fmax(max_y, p.y);
}
ok("every vertex lies on x = 2", on_plane);
ok("and it spans the brush", max_y - min_y > 1.9f);
ok("an out-of-range face has none", !s3.face_winding(c, 99, pts));
ok("an unknown brush has none", !s3.face_winding(999999, 0, pts));
// The winding must FOLLOW the plane, so a highlight tracks the edit
// rather than lagging a frame behind it.
ok("nudge the face in", s3.set_face_plane(c, 1, { 1, 0, 0, 1.0f }));
ok("winding still exists", s3.face_winding(c, 1, pts));
bool moved = true;
for (const CSG4Vec3& p : pts)
if (std::fabs(p.x - 1.0f) > 1e-4f) moved = false;
ok("and it moved with the plane", moved);
}
printf("\n%s\n", g_fail ? "FAILED" : "PASS");
return g_fail ? 1 : 0;
}