OverviewFilesCommitsIssuesDocsReleasesPeers

dc-terrain / tests / test_brush_edit.cpp

296 lines · cpp ref:
// ============================================================================
// 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;
}