OverviewFilesCommitsIssuesDocsReleasesPeers

dc-terrain / libdc / include / dc_brush_split.h

242 lines · c ref:
#ifndef DC_BRUSH_SPLIT_H
#define DC_BRUSH_SPLIT_H

// ---------------------------------------------------------------------------
// dc_brush_split — peel a brush around a carve's AABB so the voxel pocket is
// bounded by the EDIT instead of by the BRUSH.
//
// THE PROBLEM, measured in-game (hyper-tps 0.823.0): a 0.6 m sphere carve
// produced 34,520 then 45,384 pocket triangles, because dc_pocket voxelized the
// whole connected component. Carve a 20 m wall and you voxelize 20 m of wall to
// show a 1 m dent. Pocket cost tracked the brush; it should track the edit.
//
// THE ENABLING PROPERTY. A brush is not a mesh, it is a SET OF HALF-SPACES, so
// splitting one by a plane costs nothing:
//
//     brush AND halfspace(P)  ==  brush.planes + P        <- still a brush
//     brush AND halfspace(-P) ==  brush.planes + (-P)     <- also still a brush
//
// No boolean, no clipping, no geometry. So the carve's AABB peels the brush by
// its six planes, one slab at a time:
//
//     remainder = brush
//     for each AABB plane P:
//         piece     = remainder + outward(P)    -> EXACT, stays a brush
//         remainder = remainder + inward(P)
//     remainder == brush AND aabb                -> the ONLY part that voxelizes
//
// At most 6 pieces, each with at most plane_count+6 planes. Pieces keep sharp
// edges, real texture projections and convex colliders — everything voxelizing
// would have thrown away.
//
// DERIVED, NOT DURABLE. This never touches DCBrushStore's journal. The store
// keeps brush + carve exactly as before and this runs in the derive step beside
// the pocket, so undo still drops the carve and returns the ORIGINAL brush
// bit-identical (tests/test_pocket.cpp PART 3 is unaffected). There is still no
// model->brush conversion and nothing re-fits planes.
//
// INTERNAL FACES. The six added planes create faces that are interior to the
// union: solid on both sides, because the brush continues into the AABB where
// the pocket takes over. csg4 cannot cancel them — it only cancels a face
// against another BRUSH, and the pocket is not one — so they are flagged here
// via plane_internal and the renderer must skip them. Unflagged, they would
// draw as untextured surfaces sitting inside the wall.
//
// Engine-free (libdc rule); gated by tests/test_brush_split.cpp.
// ---------------------------------------------------------------------------

#include <cstdint>
#include <vector>
#include <algorithm>

#include "dc_edit.h"        // ConvexPlane
#include "dc_csg4.h"        // CSG4Vec3, CSG4Brush, face_winding, compute_bounds
#include "dc_brush_store.h" // DCBrush

namespace dc_brush_split {

struct Piece {
    std::vector<ConvexPlane> planes;
    std::vector<uint32_t>    plane_proj;      // parallel to planes
    // Parallel to planes. 1 = a cut face introduced by the split, interior to
    // the union and therefore NOT to be rendered. 0 = an original brush face.
    std::vector<uint8_t>     plane_internal;
    uint32_t material_id = 0;
    uint32_t brush_id = 0;                    // provenance: the brush this came from
    bool     sky = false;
    // Set on the remainder only: clips, draws nothing, and gets NO COLLIDER.
    // Without the collider half, the carved volume stays solid to the player.
    bool     clip_only = false;
};

struct SplitResult {
    std::vector<Piece> pieces;                // exact geometry OUTSIDE the pocket
    // brush AND aabb — the pocket's region, as a brush. CLIP-ONLY: every plane is
    // flagged internal so it emits no face, and it must NOT get a collider (a
    // convex shape here would fill the hole you just carved).
    //
    // It exists because replacing a brush with its pieces would otherwise LOSE
    // hidden-face removal inside the pocket: the pieces cover only brush-minus-
    // AABB, so a neighbouring brush's face buried in the AABB portion is suddenly
    // clipped by nothing and becomes visible through the carve hole — the one
    // place the player is looking. Feeding the remainder back as a clip-only
    // solid restores exactly what the un-split brush used to do.
    Piece remainder;
    bool has_remainder = false;
    CSG4Vec3 pocket_lo{0,0,0}, pocket_hi{0,0,0};
    bool pocket_empty = false;                // the AABB missed the brush entirely
};

// Is this plane set a non-empty solid? Asked via the winding builder rather than
// a linear-programming feasibility test, because that is the SAME predicate csg4
// and compute_bounds use — a piece this call accepts but csg4 later finds
// degenerate would be a piece that renders as nothing.
inline bool is_non_empty(const std::vector<ConvexPlane>& planes,
                         const std::vector<uint32_t>& proj) {
    if (planes.size() < 4) return false;      // <4 half-spaces cannot bound a solid
    CSG4Brush v;
    v.planes = planes.data();
    v.plane_count = (uint32_t)planes.size();
    v.plane_proj = proj.empty() ? nullptr : proj.data();
    dc_csg4::compute_bounds(v);
    if (!v.bounds_valid) return false;
    // compute_bounds succeeds on a degenerate slab (a plane touching a corner
    // yields one point). Require real extent on every axis, or the split emits
    // zero-volume pieces that cost a collider and draw nothing.
    const float ex = v.hi.x - v.lo.x, ey = v.hi.y - v.lo.y, ez = v.hi.z - v.lo.z;
    return ex > 1e-5f && ey > 1e-5f && ez > 1e-5f;
}

// Peel `b` around [lo,hi]. Returns false only when the brush itself is unusable;
// an AABB that swallows the brush is a SUCCESS with zero pieces, because that is
// the legitimate "the whole brush voxelizes" case the old behaviour always took.
inline bool split_by_aabb(const DCBrush& b, const CSG4Vec3& lo, const CSG4Vec3& hi,
                          SplitResult& out) {
    out.pieces.clear();
    out.pocket_empty = false;
    if (b.planes.size() < 4) return false;

    // CLAMP the pocket to the brush before peeling. A carve larger than the
    // brush would otherwise leave the pocket the size of the CARVE, and the
    // sampler would spend its whole grid on empty space outside the solid —
    // the exact cost this module exists to remove, reintroduced at the other
    // end. Clamping cannot change the split geometry (a cut outside the brush
    // yields an empty piece and an unchanged remainder), only how much volume
    // the pocket has to sample.
    CSG4Vec3 clo = lo, chi = hi;
    if (b.bounds_valid) {
        clo.x = std::max(clo.x, b.lo.x); chi.x = std::min(chi.x, b.hi.x);
        clo.y = std::max(clo.y, b.lo.y); chi.y = std::min(chi.y, b.hi.y);
        clo.z = std::max(clo.z, b.lo.z); chi.z = std::min(chi.z, b.hi.z);
    }
    out.pocket_lo = clo;
    out.pocket_hi = chi;
    // The carve missed this brush entirely: one piece, the whole brush, no
    // pocket. Returning early keeps that case from generating six empty cuts.
    if (clo.x > chi.x || clo.y > chi.y || clo.z > chi.z) {
        Piece p;
        p.planes = b.planes;
        p.plane_proj = b.plane_proj;
        p.plane_proj.resize(p.planes.size(), 0u);
        p.plane_internal.assign(p.planes.size(), 0u);
        p.material_id = b.material_id;
        p.brush_id = b.id;
        p.sky = b.sky;
        out.pieces.push_back(std::move(p));
        out.pocket_empty = true;
        return true;
    }

    // The six AABB planes, expressed the way a brush wants them: inside is
    // n.p <= d. `inward` keeps the side TOWARDS the box; `outward` is its
    // negation and keeps the slab outside it.
    struct Cut { ConvexPlane inward, outward; };
    const Cut cuts[6] = {
        {{-1, 0, 0, -clo.x}, { 1, 0, 0,  clo.x}},
        {{ 1, 0, 0,  chi.x}, {-1, 0, 0, -chi.x}},
        {{ 0,-1, 0, -clo.y}, { 0, 1, 0,  clo.y}},
        {{ 0, 1, 0,  chi.y}, { 0,-1, 0, -chi.y}},
        {{ 0, 0,-1, -clo.z}, { 0, 0, 1,  clo.z}},
        {{ 0, 0, 1,  chi.z}, { 0, 0,-1, -chi.z}},
    };

    std::vector<ConvexPlane> rem = b.planes;
    std::vector<uint32_t>    rem_proj = b.plane_proj;
    std::vector<uint8_t>     rem_internal(b.planes.size(), 0u);
    rem_proj.resize(rem.size(), 0u);

    for (int i = 0; i < 6; i++) {
        // The slab outside this AABB face, still bounded by everything peeled so
        // far — which is what makes the pieces disjoint by construction rather
        // than by a later overlap test.
        Piece p;
        p.planes = rem;
        p.plane_proj = rem_proj;
        p.plane_internal = rem_internal;
        p.planes.push_back(cuts[i].outward);
        p.plane_proj.push_back(0u);
        p.plane_internal.push_back(1u);      // a cut face: interior, do not draw
        p.material_id = b.material_id;
        p.brush_id = b.id;
        p.sky = b.sky;
        if (is_non_empty(p.planes, p.plane_proj)) out.pieces.push_back(std::move(p));

        // ...and keep peeling the inside.
        rem.push_back(cuts[i].inward);
        rem_proj.push_back(0u);
        rem_internal.push_back(1u);
    }

    // What survives all six cuts is brush AND aabb: the voxel pocket's region.
    // If that is empty the carve never actually reached this brush.
    out.pocket_empty = !is_non_empty(rem, rem_proj);
    if (!out.pocket_empty) {
        out.remainder.planes = rem;
        out.remainder.plane_proj = rem_proj;
        // ALL internal: it is a real solid for clipping purposes and draws
        // nothing. The original brush faces are internal here too — the pocket's
        // DC surface is what renders that geometry now.
        out.remainder.plane_internal.assign(rem.size(), 1u);
        out.remainder.material_id = b.material_id;
        out.remainder.brush_id = b.id;
        out.remainder.sky = b.sky;
        out.remainder.clip_only = true;
        out.has_remainder = true;
    }
    return true;
}

// World AABB of one carve shape. Mirrors DCBrushStore's own private shape_bounds
// so the split peels around exactly the box the store used to decide which
// brushes a carve TOUCHED — if these two ever disagree, a brush gets split around
// a box that does not contain the carve that selected it.
inline void carve_bounds(const TerrainEdit& e, CSG4Vec3& lo, CSG4Vec3& hi) {
    float ex, ey, ez;
    if (e.shape == TerrainEdit::BOX) {
        ex = e.half_ext[0]; ey = e.half_ext[1]; ez = e.half_ext[2];
    } else {
        ex = ey = ez = e.radius;      // sphere / mesh / convex: bounding sphere
    }
    lo = {e.x - ex, e.y - ey, e.z - ez};
    hi = {e.x + ex, e.y + ey, e.z + ez};
}

inline void union_aabb(CSG4Vec3& lo, CSG4Vec3& hi, const CSG4Vec3& l, const CSG4Vec3& h) {
    lo.x = std::min(lo.x, l.x); lo.y = std::min(lo.y, l.y); lo.z = std::min(lo.z, l.z);
    hi.x = std::max(hi.x, h.x); hi.y = std::max(hi.y, h.y); hi.z = std::max(hi.z, h.z);
}

// The pocket's sampling volume: the carve AABB inflated by a margin, clamped to
// the brush. Inflated deliberately — it costs a slightly larger pocket and keeps
// the DC surface away from the sampling boundary, where a vertex landing exactly
// on the edge is where seam artifacts come from. The exact pieces are peeled
// with the SAME inflated box, so the two still meet exactly.
inline void inflate(CSG4Vec3& lo, CSG4Vec3& hi, float margin) {
    lo.x -= margin; lo.y -= margin; lo.z -= margin;
    hi.x += margin; hi.y += margin; hi.z += margin;
}

}  // namespace dc_brush_split

#endif // DC_BRUSH_SPLIT_H