OverviewFilesCommitsIssuesDocsReleasesPeers

dc-terrain / src / dc_level.h

505 lines · c ref:
#ifndef DC_LEVEL_H
#define DC_LEVEL_H

// ---------------------------------------------------------------------------
// DCLevel — the Godot-side node for the EXACT brush level.
//
// docs/level-mesh-implementation.md §2 (module map), §12 step 3.
// Owns a DCBrushStore (durable state) and derives everything else from it:
//
//     BrushStore  --csg4-->  visible skin  --dc_level_mesh-->  ArrayMesh
//                 \--unclipped hulls------------------------>  convex colliders
//
// This node is NOT the octree clipmap and shares no code with it. The world
// (procedural, destructible-everywhere) keeps DCTerrain; the level (authored,
// finite, sharp, low-poly) gets this. That separation IS the pivot.
//
// Attribute conventions match DCTerrain's chunk meshes so the SAME terrain
// shader works unchanged:
//   ARRAY_COLOR    R = material_id/255, A = 1 on textured faces (route ②)
//   ARRAY_CUSTOM0  RG_FLOAT = two per-face projection candidates
// On the exact mesh both candidates are the SAME face — a fragment's face is
// known exactly, so the shader's crease-pick becomes a no-op rather than a
// heuristic. That is the material-quantization problem disappearing by
// construction, not being solved.
// ---------------------------------------------------------------------------

#include <godot_cpp/classes/node3d.hpp>
#include <godot_cpp/classes/mesh_instance3d.hpp>
#include <godot_cpp/classes/array_mesh.hpp>
#include <godot_cpp/classes/physics_server3d.hpp>
#include <godot_cpp/variant/packed_float32_array.hpp>
#include <godot_cpp/variant/packed_int32_array.hpp>
#include <godot_cpp/variant/dictionary.hpp>
#include <godot_cpp/variant/vector3.hpp>
#include <godot_cpp/variant/packed_vector3_array.hpp>
#include <godot_cpp/variant/packed_string_array.hpp>
#include <godot_cpp/variant/array.hpp>

#include <cstring>
#include <memory>
#include <unordered_map>
#include <unordered_set>

#include <vector>

#include "dc_brush_store.h"
#include "dc_csg4.h"
#include "dc_level_mesh.h"
#include "dc_pocket.h"
#include "dc_brush_split.h"
#include "dc_level_journal.h"
#include "dc_pick.h"

using namespace godot;

class DCLevel : public Node3D {
    GDCLASS(DCLevel, Node3D)

public:
    DCLevel();
    ~DCLevel();

    void _ready() override;
    void _exit_tree() override;

    // ---- import ----
    // `planes` is flat (nx,ny,nz,d) quads — the SAME layout the Quake importer
    // already passes to DCTerrain::edit_terrain_convex, so it can drive either.
    // `plane_proj` is parallel per-face (1-based; empty = none).
    // `sky` marks a Quake sky brush: renders and CLIPS normally, but gets no
    // collider (jump through it) and is the authored outside-wall marker.
    // `key` is the brush's DURABLE name — the dot of the op that created it. Pass
    // "" for an IMPORT, which is named "i:<level_id>:<n>" deterministically on
    // every peer. Returns 0 when the plane set encloses nothing, or when a brush
    // with this key already exists (in which case it is that brush's id: replay is
    // not once-only, so a create must be idempotent).
    // `clearance` marks AIR THE LEVEL OWNS rather than geometry — the headroom
    // over a flight of stairs, the bore of an archway driven into a hillside. It
    // renders nothing, collides with nothing and clips nothing; its only effect
    // is that build_shell encloses it, so the surrounding DCTerrain has the
    // passage subtracted from it. See DCBrush::clearance (hyper-games #25 §2).
    // `group` names the operator invocation this brush came from — every
    // voussoir and the clearance of one arch share it, so the whole thing moves
    // and dies together. See DCBrush::group.
    int add_brush(const PackedFloat32Array &planes, int material_id,
                  const PackedInt32Array &plane_proj, int level_id, bool sky,
                  const String &key, bool clearance = false,
                  const String &group = String());

    // Brush ids of every live sky brush — the level's authored boundary, for the
    // level<->world seam (§5a). The map author marked it by texturing it sky,
    // which beats inferring "outside" from visible-skin membership.
    PackedInt32Array get_sky_brush_ids() const;

    // Brush ids of every live clearance brush. Nothing draws them — that is what
    // they are — so a view that wants to SHOW them (the editor's clearance
    // toggle) has to ask, and so does anything wanting to aim at one.
    PackedInt32Array get_clearance_brush_ids() const;

    // Every live brush id, in STORE ORDER — which is import order, and therefore
    // the same on every peer. The outliner needs to enumerate what is in the
    // level and there was no way to: get_brush(id) answers about one id, and the
    // only lists were the two flag-specific ones above. Walking 1..N and probing
    // for gaps works until a tombstone leaves one, which it always does.
    PackedInt32Array get_live_brush_ids() const;

    // The group key of one brush, "" when it belongs to none (every imported
    // brush, and anything made before groups existed).
    String get_brush_group(int brush_id) const;
    // Every live brush carrying `group`, in store order. The outliner's group row
    // is this call — DERIVED, so it cannot disagree with the geometry.
    PackedInt32Array get_group_brush_ids(const String &group) const;
    // Every distinct group in the level, in first-appearance order.
    PackedStringArray get_group_keys() const;

    // ---- HIDING (the outliner's eye — hyper-games #28) ----------------------
    //
    // VIEW STATE, NOT GEOMETRY, and the placement of it says so: the hidden set
    // lives on this NODE and not in DCBrushStore. That is structural rather than
    // careful. A `hidden` field on DCBrush would sit one line away from `sky` and
    // `clearance`, inside the object every view iterates, and the first mistake
    // anyone makes with it is the expensive one — dropping hidden brushes from
    // shell_view. Hide a boundary wall, the flood fill escapes, the shell stops
    // sealing and the hillside fills every room. Out here it cannot reach the
    // store's views or the journal at all.
    //
    // Hiding filters at MESH EMIT, after the clip, so csg4 sees the level
    // unchanged. Dropping hidden brushes from its INPUT instead would re-clip
    // their neighbours — faces buried behind a wall become visible the moment you
    // hide it — so unhiding would not restore what you had, and every toggle
    // would re-clip the whole level. Filtering the faces keeps hiding a view.
    //
    // Colliders go with it, so you can fly through what you hid. NOT the pocket
    // trimeshes of a carved brush yet — see the note in rebuild().
    //
    // Sets dirty_ but does NOT rebuild: hiding a collection is N calls and one
    // rebuild, the same reason CreateBoxOperator batches a flight of stairs.
    void set_brush_hidden(int brush_id, bool hidden);
    bool is_brush_hidden(int brush_id) const;
    PackedInt32Array get_hidden_brush_ids() const;
    void clear_hidden();

    /// The level's SEALED VOLUME as a closed mesh, for one subtractive
    /// TerrainEdit::MESH on the surrounding DCTerrain — level-mesh-
    /// implementation.md §5a.2. Replaces the inflated-AABB moat, which subtracts
    /// 324622 m^3 where the sealed volume is ~38500 and the solid is 12600.
    ///
    /// Returns { vertices: PackedVector3Array, indices: PackedInt32Array,
    ///           center: Vector3, triangles, volume, area, snapped, unmoved }.
    ///
    /// `center` is the mesh's own centroid and MUST be what the caller passes as
    /// stamp_mesh_arrays' `position`: that function re-centres the vertices on
    /// their centroid and treats `position` as where the centroid lands, so
    /// anything else silently translates the whole shell.
    ///
    /// Requires the level to have been imported WITH SKY BRUSHES. A sky brush is
    /// solid and only its rendering is special; without them the boundary has
    /// holes, the flood fill leaks out through the ceiling and the result is an
    /// empty or wildly wrong volume — check `volume` against the level's size
    /// rather than trusting a non-empty mesh.
    Dictionary build_shell(float cell = 0.25f, float clamp_cells = 0.5f) const;

    /// The same shell, off the caller's thread — because a 2.2 s freeze on every
    /// edit is what this costs when the editor re-derives it live.
    ///
    /// THE SNAPSHOT IS THE WHOLE POINT. build_shell's first act is to copy the
    /// brush view out of the store, and everything after that reads only the copy.
    /// So `start` takes that copy HERE, on the caller's thread, and the flood runs
    /// on a worker against memory nothing else can touch. Handing a Godot thread
    /// the whole of build_shell instead would race every edit made during the
    /// build, which is precisely what the build is being moved off the frame to
    /// allow.
    ///
    /// The worker touches no engine type: it fills a POD ShellMesh, and the
    /// Dictionary is packed in `take`, on whichever thread calls it.
    ///
    ///     if (level.shell_ready()) apply(level.build_shell_take())
    ///     elif not level.shell_building(): level.build_shell_start()
    ///
    /// `start` returns false if a build is already running or the level is empty.
    /// `take` returns an empty Dictionary unless a build has finished, and hands
    /// ownership over: the next `start` begins a fresh one. Nothing here cancels —
    /// a build whose answer is already stale is cheaper to let finish and discard
    /// than to make interruptible, and the caller compares its own cache key.
    bool build_shell_start(float cell = 0.25f, float clamp_cells = 0.5f);
    bool shell_building() const;
    bool shell_ready() const;
    Dictionary build_shell_take();

    // ---- lifecycle (tombstones — there is no durable delete path) ----
    bool remove_brush(int brush_id);
    bool restore_brush(int brush_id);
    int  remove_level(int level_id);
    int  restore_level(int level_id);
    void clear();

    // ---- transform + shape editing (stays a brush; no model involved) ----
    bool translate_brush(int brush_id, const Vector3 &delta);
    // AXIS-ALIGNED SCALE about `pivot` — exact on half-spaces, see
    // DCBrushStore::scale_brush. Refused (false, nothing changed) on a zero
    // factor or if it would leave the brush enclosing nothing.
    //
    // The pivot is the CALLER'S, not the brush's own centre: a group scales
    // about one shared point or its pieces fly apart, and the clearance volume
    // of an arch has to move with the ring it belongs to.
    bool scale_brush(int brush_id, const Vector3 &scale, const Vector3 &pivot);
    // TRUE VERTEX / EDGE DRAG — move named CORNERS and rebuild the solid from its
    // points, so a corner moves without dragging its neighbours. Everything else
    // here moves planes, which is exact and is precisely why it cannot do this.
    // `from` names corners by position; a position that is not a corner is refused.
    bool move_brush_points(int brush_id, const PackedVector3Array &from,
                           const Vector3 &delta, const String &key);
    // CSG. Planes are flattened 4-per-plane (nx, ny, nz, d), the same shape add_brush
    // takes, so a plane set read back from get_brush can be handed straight over.
    // subtract returns the piece count, 0 if the cutter swallowed the brush, or -1 if
    // it missed and nothing changed.
    int subtract_brush(int brush_id, const PackedFloat32Array &cutter, const String &key);
    bool intersect_brush(int brush_id, const PackedFloat32Array &other, const String &key);
    bool merge_brushes(const PackedInt32Array &brush_ids, const String &key);
    bool set_face_plane(int brush_id, int face, const Vector3 &normal, float d);
    // `key` is the CRDT dot of the op creating this face ("<replica>:<counter>").
    // Pass "" only for local geometry that is never journalled — two peers cannot
    // agree which face an add refers to without it. Re-adding a dot the brush
    // already carries is refused, so replay is idempotent.
    int  add_face(int brush_id, const Vector3 &normal, float d, int proj,
                  const String &key);
    bool remove_face(int brush_id, int face);

    // CLIP / SPLIT — the operation that turns "adjust a level" into "author one".
    // add_face can only SHRINK a convex solid, so removing material from the middle
    // of a wall was unreachable; a doorway is now split, split, remove the middle.
    // The original keeps the front half (n.x <= d); the back half is a new brush
    // named `key` (the dot of the split record). Returns its id, or 0 if the plane
    // does not divide the solid, in which case nothing changed.
    int split_brush(int brush_id, const Vector3 &normal, float d, const String &key);

    // Durable name -> local handle, 0 when unknown. Anything that outlives a
    // single call — a journal record, a selection held across a re-fold — must hold
    // the key and resolve it here, because the id is local to this peer.
    int    brush_by_key(const String &key) const;
    String get_brush_key(int brush_id) const;

    // ---- carves (derived-and-reversible; the brush stays authoritative) ----
    // `key` is the carve's durable name (the dot of the op). Pass "" for a carve
    // that is never journalled. The id-taking undo/redo below are LOCAL-ONLY: a
    // replaying peer mints its own ids, so undoing by another peer's number undoes
    // whichever carve happens to hold it here — which is exactly the defect that
    // shipped (hyper-games #22 §5 item 4). Cross-peer code uses the _by_key forms.
    int  apply_carve_sphere(const Vector3 &center, float radius, const String &key);
    int  apply_carve_box(const Vector3 &center, const Vector3 &half_extents,
                         const String &key);
    bool undo_carve(int carve_id);
    bool redo_carve(int carve_id);
    bool undo_carve_by_key(const String &key);
    bool redo_carve_by_key(const String &key);

    // ---- the journal (dc_journal::fold — see libdc/include/dc_level_journal.h) --
    //
    // THE JOURNAL IS THE STATE AND THIS NODE IS A DERIVED CACHE. Hand it every
    // live record and it rewinds to the imported base and replays them in
    // (seq, author) order. Undo is a record marked `undone` plus another call —
    // there are no inverse ops, and both this UI and blender-bridge get the same
    // undo because the fold is in libdc rather than in either of them.
    //
    // Each record is a Dictionary:
    //   author      String    the memberId that authored it
    //   seq         int       its counter; (seq, author) is the total order
    //   kind        String    "brush.create" | "brush.remove" | "brush.translate"
    //                       | "face.set" | "face.add" | "face.remove" | "carve"
    //   undone      bool      a tombstone VALUE — erase has no durable path
    //   brush_key   String    what it targets (ignored by brush.create)
    //   face_key    String    what it targets (face.set / face.remove)
    //   planes      PackedFloat32Array, proj PackedInt32Array, material_id,
    //               level_id, sky                      (brush.create)
    //   n Vector3, d float, face_proj int              (face.set / face.add)
    //   delta       Vector3                            (brush.translate)
    //   carve       Dictionary {type:"sphere"|"box", pos:Vector3,
    //                           radius:float, half:Vector3}   (carve)
    //
    // Returns {applied, skipped, undone, records}. `skipped` is not noise: it
    // counts live records whose target is gone or that the store refused, i.e.
    // places where the journal and this peer's geometry disagree.
    Dictionary apply_journal(const Array &records);

    // Order-independent hash of live brush geometry, over DURABLE KEYS — so two
    // peers can be compared directly rather than each against its own past.
    // Convergence is the property that matters; determinism alone is not enough.
    int64_t fingerprint() const;

    // ---- derive ----
    void rebuild();                       // full: csg4 -> mesh (+ collision) -> pockets
    void set_build_collision(bool v);
    bool get_build_collision() const;

    // Voxel size for carved pockets. Defaults to the level import's grid-aligned
    // 0.224 (8 Quake units x world_scale) so a pocket lands on the same lattice as
    // everything else. Exposed because this is the Nyquist knob: Quake's 8-16 unit
    // walls are 0.224-0.448m, i.e. 1-2 voxels here, so a carved THIN wall can hole
    // at the default and wants a finer pocket. Finer is affordable precisely
    // because it applies only to the carved region.
    void  set_pocket_voxel_size(float v);
    float get_pocket_voxel_size() const;

    // ---- picking (editor) ----
    // Resolve a raycast hit back to the brush that produced it. A Godot ray
    // result gives {collider, shape}; `shape` indexes into THIS body's shape
    // list, which is NOT the brush index — build_colliders skips any brush
    // whose hull has <4 points, so the mapping is RECORDED as shapes are added
    // rather than inferred. Returns -1 when out of range.
    int get_brush_id_from_shape(int shape_index) const;

    // Read a brush back. Face-level editing needs this for two things the API
    // cannot otherwise supply: an editor has to know what it is editing, and
    // set_face_plane has NO INVERSE unless the caller recorded the plane it is
    // replacing — so undo is built on this, not on a snapshot of the whole store.
    // `generation` is included because it is what a replicated op-log compares to
    // decide whether it is talking about the brush it thinks it is.
    // Empty Dictionary if the id is unknown.
    Dictionary get_brush(int brush_id) const;

    // Which face did the player click? A raycast gives a point on the surface;
    // this turns that into the face index the edit operations take. -1 if the
    // brush is unknown or bounds nothing.
    int pick_face(int brush_id, const Vector3 &point) const;

    // WHAT AM I POINTING AT — with no physics server involved.
    //
    // The pair above (get_brush_id_from_shape + pick_face) needs a Godot collision
    // shape index, so the editor's most basic query required the physics server to
    // have been fed, stepped and queried. blender-bridge has none, which made this
    // a blocker for a second consumer rather than a convenience. A brush is an
    // intersection of half-spaces, so the query is a convex slab clip against its
    // own planes (dc_pick.h) and needs nothing else.
    //
    // `from`/`to` match PhysicsRayQueryParameters3D.create, so a caller can swap
    // one for the other. Returns {} on a miss, else:
    //   brush int, face int, brush_key String, face_key String,
    //   position Vector3, normal Vector3, distance float, inside bool
    // The DURABLE names are included because anything that outlives the call — a
    // journal record, a selection across a re-fold — must hold those, not the ids.
    //
    // Differs from the physics path in two ways, deliberately: SKY brushes are
    // pickable (they have no collider, so a physics ray goes through the boundary
    // the author marked), and a CARVED brush is hit at its hull (the pocket is a
    // derived cache; the brush is the editable thing).
    //
    // CLEARANCE brushes are NOT hit unless `include_clearance` says so. One
    // encloses the shape it belongs to, so a pickable clearance volume would
    // intercept every ray aimed at the stairs inside it and the editor would be
    // unable to select the geometry it is there to serve. The editor's clearance
    // view passes true, which is what makes their extents draggable with the
    // ordinary face tools rather than needing a parallel set of their own.
    Dictionary raycast(const Vector3 &from, const Vector3 &to,
                       bool include_clearance = false) const;

    // Face INDICES are positions and remove_face renumbers them. Anything that
    // outlives a single call — an editor selection, a replicated op-log — must
    // hold the dot from get_brush()["plane_keys"] and resolve it here.
    // -1 if that face is gone.
    int face_by_key(int brush_id, const String &key) const;

    // Is this face a SURFACE, or a dormant plane? A plane that bounds nothing
    // stays in the set — that is what makes topology change reversible — so
    // "face" and "plane" are not the same thing and an editor must distinguish
    // them. get_brush()["live_faces"] is the count; this asks per face.
    bool face_is_live(int brush_id, int face) const;

    // The selected face as a polygon, world space, wound around its normal.
    // Empty if the brush or face is unknown. An editor needs this to SHOW what
    // is selected; every gizmo past a highlight needs the same outline.
    PackedVector3Array get_face_winding(int brush_id, int face) const;

    // ---- the wireframe ----
    // Every live brush's outline as LINE PAIRS, ready for PRIMITIVE_LINES. The level
    // renders as one merged surface, so without this the structure being edited is
    // invisible — and a split, which produces two brushes that tile the original
    // exactly, looks like nothing happened at all. Dormant planes are skipped: they
    // bound nothing, so drawing them would show an edge that is not there.
    PackedVector3Array get_brush_edges() const;
    // The clearance volumes, on their own, for the editor's clearance view.
    // Deliberately not folded into get_brush_edges: they are a different kind of
    // thing from a wall and drawing them in the same colour says they are not.
    PackedVector3Array get_clearance_edges() const;
    PackedVector3Array get_brush_edges_of(int brush_id) const;
    // The DISTINCT corners of one brush — the handles an editor lets you aim at.
    // Deduped in the store so every consumer gets the same answer and there is one
    // epsilon to be careful about rather than one per caller.
    PackedVector3Array get_brush_vertices_of(int brush_id) const;

    // The polygon a split by this plane WOULD create — the operator preview. Empty
    // when the plane does not divide the solid, which is the same condition
    // split_brush refuses on: a preview that shows nothing is telling the truth.
    PackedVector3Array get_cut_preview(int brush_id, const Vector3 &normal, float d) const;

    // ---- introspection ----
    int get_brush_count() const;          // live (non-tombstoned)
    int get_triangle_count() const;
    int get_vertex_count() const;
    int get_collider_count() const;
    int get_carve_count() const;
    int get_pocket_count() const;
    Dictionary get_stats() const;

protected:
    static void _bind_methods();

private:
    // The in-flight shell build. Defined in the .cpp so this header does not have
    // to pull in <thread>/<atomic> or the shell builder; ~DCLevel is out of line,
    // which is what an incomplete type in a unique_ptr needs. Its destructor joins,
    // so a level freed mid-build waits for its own worker rather than outliving it.
    struct ShellJob;
    std::unique_ptr<ShellJob> shell_job_;

    // Hidden brush ids. Runtime only: never journalled, never persisted, never
    // seen by DCBrushStore. See set_brush_hidden.
    std::unordered_set<uint32_t> hidden_;

    void _upload_mesh(const DCLevelMeshData &m);
    void _upload_collision(const std::vector<DCConvexCollider> &cols);
    void _free_collision();
    void _free_all_shapes();
    // One pocket per connected component of carved brushes. Runs AFTER
    // _upload_collision, because pocket trimeshes are added to the body it
    // creates — a carved brush leaves the CLEAN view, so it loses its convex
    // collider too, and without this you fall through a wall you carved.
    void _build_pockets();
    void _compute_pocket_boxes();
    uint64_t _pocket_key(size_t cluster_index) const;
    struct PocketCache;
    void _add_pocket_collision(PocketCache &pc, const std::vector<uint32_t> &comp);

    // A pocket's built output, keyed by the identity of its inputs. One carve
    // dirties one cluster; the rest are reused instead of re-running DC, which
    // is what took ms_pocket from 5.9ms on the first carve to 422ms on the 15th.
    struct PocketCache {
        uint64_t key = 0;
        Array arrays;                // ready for add_surface_from_arrays
        PackedVector3Array faces;    // collision trimesh
        uint32_t tris = 0;
        RID shape;                   // collision RID, reused with the mesh
    };
    std::vector<PocketCache> pocket_cache_;
    static CSG4Brush _piece_view(const dc_brush_split::Piece &p);

    // Voxels of margin added to the carve box before peeling.
    //
    // ZERO, and that is a correction, not an omission. It was 2.0 on the
    // reasoning that the DC surface needs to stay off the sampling boundary —
    // but dc_pocket::build_bounded ALREADY provides exactly that margin, pulling
    // the grid origin back by cfg.padding voxels and sizing the grid
    // +2*padding. So the inflate was applying the same margin a second time, to
    // the SPLIT box, where it does nothing for the seam and everything for cost:
    // it turned a 0.6m carve's 1.2m box into 2.1m, a 75% larger extent and ~5x
    // the volume, which is enough to swallow an ordinary Quake brush whole.
    //
    // Measured in-game at 0.826.0 with exactly that outcome: split_swallowed 3,
    // split_pieces 0 — every carved brush consumed entirely, no exact remainder.
    static constexpr float POCKET_INFLATE_VOXELS = 0.0f;

    // Recomputed every rebuild, then SHARED between the split and the pocket.
    // They must agree: the exact pieces are peeled with the same box the pocket
    // samples, and that shared plane is what makes the two meet exactly.
    std::vector<std::vector<uint32_t>> pocket_comps_;
    std::vector<std::pair<CSG4Vec3, CSG4Vec3>> pocket_boxes_;

    DCBrushStore store_;
    MeshInstance3D *mesh_node_ = nullptr;
    Ref<ArrayMesh> mesh_;
    // Separate node from the exact mesh: pockets are voxel geometry with their
    // own material needs and their own rebuild cadence, and keeping them apart
    // means a carve never re-uploads the 12,747-triangle exact mesh.
    MeshInstance3D *pocket_node_ = nullptr;
    Ref<ArrayMesh> pocket_mesh_;

    RID body_;                            // one static body, N convex shapes
    std::vector<RID> shapes_;         // per-rebuild ORDER; the caches own the RIDs
    // Convex shape RIDs by point-set hash. On a carve ~1170 of ~1172 brushes are
    // untouched, so recreating them all was thousands of PhysicsServer calls per
    // keypress for geometry that did not move.
    std::unordered_map<uint64_t, RID> shape_cache_;
    std::vector<uint32_t> shape_brush_ids_;  // parallel to shapes_; ray shape idx -> brush id

    bool build_collision_ = true;
    bool dirty_ = false;
    float pocket_voxel_size_ = 0.224f;

    // last rebuild stats
    uint32_t last_pocket_tris_ = 0, last_pockets_ = 0;
    uint32_t last_split_pieces_ = 0, last_split_swallowed_ = 0;
    uint32_t last_pockets_rebuilt_ = 0;   // vs reused from cache
    uint32_t last_tris_ = 0, last_verts_ = 0, last_faces_ = 0;
    uint32_t last_removed_ = 0, last_fragmented_ = 0;
    double   last_area_ = 0.0;
    float    last_normal_dev_ = 0.0f;
    double   last_build_ms_ = 0.0;
    // Per-phase breakdown of the last rebuild. A single build_ms cannot say
    // WHICH phase makes an interactive carve hitch, and the three candidates
    // want different fixes.
    double   last_ms_split_ = 0.0, last_ms_clip_ = 0.0, last_ms_mesh_ = 0.0;
    double   last_ms_upload_ = 0.0, last_ms_collide_ = 0.0, last_ms_pocket_ = 0.0;
};

#endif // DC_LEVEL_H