OverviewFilesCommitsIssuesDocsReleasesPeers

dc-terrain / src / dc_terrain.cpp

4894 lines · cpp ref:
#include "dc_terrain.h"
#include <thread>
#include "dc_octree.h"
#include "dc_edit.h"
#include "dc_contour.h"
#include "dc_seam.h"
#include "dc_density_grid.h"
#include "dc_generator_mesh_sdf.h"
#include "dc_gpu_thread.h"
// Removed: dc_lod_dag.h (RT path — re-add later)

#include <algorithm>
#include <chrono>

#include <godot_cpp/classes/mesh.hpp>
#include <godot_cpp/classes/image.hpp>
#include <godot_cpp/classes/image_texture.hpp>
#include <godot_cpp/classes/immediate_mesh.hpp>
#include <godot_cpp/classes/standard_material3d.hpp>
#include <godot_cpp/classes/world3d.hpp>
#include <godot_cpp/classes/viewport.hpp>
#include <godot_cpp/classes/camera3d.hpp>
#include <godot_cpp/variant/packed_vector3_array.hpp>
#include <godot_cpp/variant/packed_int32_array.hpp>
#include <godot_cpp/variant/packed_float32_array.hpp>
#include <godot_cpp/variant/array.hpp>
#include <godot_cpp/variant/dictionary.hpp>
#include <godot_cpp/variant/transform3d.hpp>
#include <godot_cpp/core/class_db.hpp>

#include <godot_cpp/classes/engine.hpp>
#include <godot_cpp/classes/editor_interface.hpp>
#include <godot_cpp/classes/os.hpp>
#include <godot_cpp/classes/sub_viewport.hpp>

#include <chrono>
#include <algorithm>
#include <cstdio>
#include <cmath>

#include <godot_cpp/variant/utility_functions.hpp>

namespace godot {

// ---------------------------------------------------------------------------
// Per-chunk edit culling: filter the global edit list to only edits that
// overlap this chunk's AABB.  Converts O(all_edits) to O(nearby_edits)
// per density sample — typically 0-5 instead of thousands.
// ---------------------------------------------------------------------------
static std::shared_ptr<const std::vector<TerrainEdit>> cull_edits_for_chunk(
    const std::shared_ptr<const std::vector<TerrainEdit>>& all_edits,
    const float chunk_min[3], const float chunk_max[3])
{
    if (!all_edits || all_edits->empty()) return all_edits;

    float cx = (chunk_min[0] + chunk_max[0]) * 0.5f;
    float cy = (chunk_min[1] + chunk_max[1]) * 0.5f;
    float cz = (chunk_min[2] + chunk_max[2]) * 0.5f;
    float hx = (chunk_max[0] - chunk_min[0]) * 0.5f;
    float hy = (chunk_max[1] - chunk_min[1]) * 0.5f;
    float hz = (chunk_max[2] - chunk_min[2]) * 0.5f;

    std::vector<TerrainEdit> culled;
    for (const auto& e : *all_edits) {
        bool overlaps = false;
        if (e.shape == TerrainEdit::BOX) {
            // AABB-AABB overlap (exact)
            overlaps = std::fabs(e.x - cx) <= e.half_ext[0] + hx &&
                       std::fabs(e.y - cy) <= e.half_ext[1] + hy &&
                       std::fabs(e.z - cz) <= e.half_ext[2] + hz;
        } else {
            // Sphere-AABB overlap (sphere/mesh edits)
            float dx = std::max(0.0f, std::max(chunk_min[0] - e.x, e.x - chunk_max[0]));
            float dy = std::max(0.0f, std::max(chunk_min[1] - e.y, e.y - chunk_max[1]));
            float dz = std::max(0.0f, std::max(chunk_min[2] - e.z, e.z - chunk_max[2]));
            overlaps = dx*dx + dy*dy + dz*dz <= e.radius * e.radius;
        }
        if (overlaps) culled.push_back(e);
    }

    if (culled.empty()) return nullptr;
    if (culled.size() == all_edits->size()) return all_edits;
    return std::make_shared<const std::vector<TerrainEdit>>(std::move(culled));
}

// Static shared editor worker (1 RenderingDevice across all editor instances)
std::unique_ptr<DCWorkerCPU> DCTerrain::s_editor_worker_ = nullptr;
int DCTerrain::s_editor_refcount_ = 0;

// ---------------------------------------------------------------------------
// Worker access helpers — abstract over per-instance vs shared editor worker
// ---------------------------------------------------------------------------
bool DCTerrain::_has_workers() const {
    if (using_editor_worker_) return s_editor_worker_ != nullptr;
    return !workers_.empty();
}

bool DCTerrain::_submit_to_worker(const CellRequest& req) {
    if (using_editor_worker_) {
        if (!s_editor_worker_) {
            UtilityFunctions::print("DCTerrain: _submit_to_worker FAILED — no editor worker!");
            return false;
        }
        return s_editor_worker_->submit(req);
    }
    if (workers_.empty()) return false;
    bool ok = workers_[next_worker_]->submit(req);
    next_worker_ = (next_worker_ + 1) % (int)workers_.size();
    return ok;
}

// Route a user-edit remesh through the worker's high-priority lane so it is
// processed before any queued LOD/pre-mesh work (1b). Mirrors the round-robin
// spread of _submit_to_worker.
bool DCTerrain::_submit_to_worker_priority(const CellRequest& req) {
    if (using_editor_worker_) {
        if (!s_editor_worker_) return false;
        return s_editor_worker_->submit_priority(req);
    }
    if (workers_.empty()) return false;
    bool ok = workers_[next_worker_]->submit_priority(req);
    next_worker_ = (next_worker_ + 1) % (int)workers_.size();
    return ok;
}

// Total edits still queued in the priority lanes across all workers (1b).
// Used to suppress speculative pre-mesh while edits are in flight.
size_t DCTerrain::_worker_priority_depth() const {
    if (using_editor_worker_) {
        return s_editor_worker_ ? s_editor_worker_->priority_queue_size() : 0;
    }
    size_t d = 0;
    for (auto& w : workers_) d += w->priority_queue_size();
    return d;
}

void DCTerrain::_poll_workers(std::function<void(CellResult&)> handler, double budget_ms, int min_count) {
    int count = 0;
    auto t_start = std::chrono::high_resolution_clock::now();

    auto poll_one = [&](DCWorkerCPU* w) {
        CellResult result;
        while (w->poll_result(result)) {
            handler(result);
            count++;
            if (count >= min_count && budget_ms > 0) {
                auto elapsed = std::chrono::duration<double, std::milli>(
                    std::chrono::high_resolution_clock::now() - t_start).count();
                if (elapsed >= budget_ms) return false;
            }
        }
        return true;
    };

    if (using_editor_worker_) {
        if (s_editor_worker_) poll_one(s_editor_worker_.get());
    } else {
        for (auto& w : workers_) {
            if (!poll_one(w.get())) break;
        }
    }
}

void DCTerrain::_set_worker_generation(uint32_t gen) {
    if (using_editor_worker_) {
        if (s_editor_worker_) s_editor_worker_->set_generation(gen);
    } else {
        for (auto& w : workers_) w->set_generation(gen);
    }
}

void DCTerrain::_drain_all_workers() {
    // Wait for busy workers
    auto is_any_busy = [this]() {
        if (using_editor_worker_) {
            return s_editor_worker_ && s_editor_worker_->is_busy();
        }
        for (auto& w : workers_) {
            if (w->is_busy()) return true;
        }
        return false;
    };
    while (is_any_busy()) {
        std::this_thread::sleep_for(std::chrono::microseconds(100));
    }
    // Drain results
    CellResult discard;
    if (using_editor_worker_) {
        if (s_editor_worker_) {
            while (s_editor_worker_->poll_result(discard)) {}
        }
    } else {
        for (auto& w : workers_) {
            while (w->poll_result(discard)) {}
        }
    }
}

// ---------------------------------------------------------------------------
// Constructor / Destructor
// ---------------------------------------------------------------------------
DCTerrain::DCTerrain()
    : generator_ptr_(&noise_generator_) {
}

DCTerrain::~DCTerrain() {
    // Stop per-instance workers
    for (auto& worker : workers_) {
        worker->stop();
    }
    workers_.clear();

    // Release shared editor worker ref if still held
    if (using_editor_worker_) {
        s_editor_refcount_--;
        if (s_editor_refcount_ <= 0) {
            if (s_editor_worker_) {
                s_editor_worker_->stop();
                s_editor_worker_.reset();
            }
            s_editor_refcount_ = 0;
        }
        using_editor_worker_ = false;
    }

    // Free all node RIDs via octree traversal
    auto leaves = octree_clipmap_.get_leaves();
    for (auto* node : leaves) {
        _free_node_rids(node);
    }

    // Flush dealloc queue
    for (auto& entry : dealloc_queue_) {
        RenderingServer* rs = RenderingServer::get_singleton();
        if (rs && entry.rs_instance.is_valid()) {
            rs->free_rid(entry.rs_instance);
        }
        if (rs && entry.seam_mesh_instance.is_valid()) {
            rs->free_rid(entry.seam_mesh_instance);
        }
        PhysicsServer3D* ps = PhysicsServer3D::get_singleton();
        if (ps) {
            if (entry.collision_body.is_valid()) ps->free_rid(entry.collision_body);
            if (entry.collision_shape.is_valid()) ps->free_rid(entry.collision_shape);
        }
        entry.mesh.unref();
    }
    dealloc_queue_.clear();
    collision_trimesh_.clear();
    seam_pending_.clear();
    node_meshes_.clear();
    seam_meshes_.clear();
    fade_overrides_.clear();

    octree_clipmap_.clear();
}

// ---------------------------------------------------------------------------
// Godot lifecycle
// ---------------------------------------------------------------------------
void DCTerrain::_ready() {
    set_process(true);

    // Enable wireframe data generation so debug_wireframe toggle works (game mode only)
    if (!Engine::get_singleton()->is_editor_hint()) {
        RenderingServer::get_singleton()->set_debug_generate_wireframes(true);
    }

    // Vulkan context and RT injection are deferred to first _process() frame
    // because RenderingDevice may not be fully initialized during _ready().

    // Detect fork RT injection methods (backward-compatible: no-op on stock Godot)
    {
        RenderingDevice* rd = RenderingServer::get_singleton()->get_rendering_device();
    }

    // Configure density
    _propagate_config();

    // Configure octree clipmap
    octree_clipmap_.base_voxel_size = voxel_size_;
    octree_clipmap_.voxels_per_cell = 28;  // nominal = 32 - 2*overlap(2) = 28
    octree_clipmap_.lod_factor = lod_factor_;
    octree_clipmap_.hysteresis = hysteresis_;
    // DISABLED (was true): content-aware LOD inverts the split/merge hysteresis for
    // empty cells. The empty-cell merge collapse (dc_octree_clipmap.cpp:430-432,
    // merge_factor = lod_factor*0.5 = 1.0) drops the merge threshold BELOW the split
    // threshold (2.0*cs), so every empty cell in the (1.0,2.0)*cs band oscillates
    // split<->merge forever on a stationary camera -> never settles, pre-mesh
    // pipeline saturates, near-camera chunks never refine. Matches the header default
    // (dc_octree_clipmap.h:309) and the disabled-by-default use_screen_space_error,
    // whose comment documents this exact split-merge thrash.
    octree_clipmap_.content_aware_lod = false;
    octree_clipmap_.max_lod_levels = octree_depth_;
    // seamless_lod: force all chunks to split to LOD 0 by using a huge lod_factor.
    // This makes the distance threshold always trigger splits regardless of distance.
    octree_clipmap_.lod_factor = seamless_lod_ ? 1000.0f : 2.0f;
    octree_clipmap_.max_splits_per_frame = 16;
    octree_clipmap_.max_merges_per_frame = 8;
    octree_clipmap_.content_aware_lod = false;  // redundant duplicate — see rationale above
    octree_clipmap_.lod_mode = lod_mode_;
    octree_clipmap_.on_node_free = _on_node_free;
    octree_clipmap_.on_mesh_free = _on_mesh_free;
    octree_clipmap_.on_collision_free = _on_collision_free;
    octree_clipmap_.on_node_free_userdata = this;
    // Only initialize if ENTER_TREE hasn't already done it
    // (in editor mode, _process can run between ENTER_TREE and _ready,
    // so double-init causes use-after-free when worker holds stale pointer)
    if (octree_clipmap_.get_stats().leaf_count == 0) {
        octree_clipmap_.initialize();
    }

    // Create unified terrain shader (used by ALL chunks — settled and fading)
    terrain_shader_.instantiate();
    terrain_shader_->set_code(
        "shader_type spatial;\n"
        "render_mode cull_back;\n"
        "\n"
        "uniform float fade_t : hint_range(0.0, 1.0) = 1.0;\n"
        "uniform vec3 lod_color : source_color = vec3(0.4, 0.5, 0.3);\n"
        "uniform bool use_lod_color = false;\n"
        "uniform float normal_perturbation : hint_range(0.0, 1.0) = 0.0;\n"
        "uniform float triplanar_scale = 0.15;\n"
        "uniform vec3 grass_color : source_color = vec3(0.25, 0.45, 0.15);\n"
        "uniform vec3 rock_color : source_color = vec3(0.35, 0.30, 0.25);\n"
        "uniform vec3 dirt_color : source_color = vec3(0.30, 0.22, 0.12);\n"
        "uniform sampler2DArray tex_atlas : source_color, filter_nearest_mipmap, repeat_enable;\n"  // nearest = crisp Quake pixels (no magnification blur); mipmaps keep distance clean
        "uniform bool use_textures = false;\n"
        // glow_atlas is RGBA: rgb = emissive glow, a = gloss (flipped to roughness
        // below). Packed by QuakeWadLoader._pack_glow_rough because both come from
        // the same source resolution, so one textureGrad serves both — and under
        // path tracing that fetch is paid per hit.
        "uniform sampler2DArray glow_atlas : source_color, filter_nearest_mipmap, repeat_enable;\n" // rgb=glow, a=gloss
        "uniform sampler2DArray norm_atlas : filter_nearest_mipmap, repeat_enable;\n" // PBR normal (NEAREST: pixelated, like the diffuse)
        "uniform float normal_strength : hint_range(0.0, 2.0) = 1.0;\n" // CANONICAL: DarkPlaces applies the normal unscaled
        "uniform bool use_pbr = false;\n"
        "uniform float tex_tile = 1.792;\n"   // world metres per texture tile (~64 quake u * 0.028)
        "uniform sampler2D projection_table : filter_nearest;\n"  // per-face UV projections (3 texels/row)
        "uniform bool has_projection_table = false;\n"
        "uniform int projection_rows = 0;\n"
        "\n"
        "// Quake axial base axes (quake Z-up): floor/ceil/west/east/south/north {normal,u,v}.\n"
        "const vec3 QN[6] = vec3[6](vec3(0,0,1),vec3(0,0,-1),vec3(1,0,0),vec3(-1,0,0),vec3(0,1,0),vec3(0,-1,0));\n"
        "const vec3 QU[6] = vec3[6](vec3(1,0,0),vec3(1,0,0),vec3(0,1,0),vec3(0,1,0),vec3(1,0,0),vec3(1,0,0));\n"
        "const vec3 QV[6] = vec3[6](vec3(0,-1,0),vec3(0,-1,0),vec3(0,0,-1),vec3(0,0,-1),vec3(0,0,-1),vec3(0,0,-1));\n"
        "\n"
        "varying vec3 world_pos;\n"
        "varying vec3 world_normal;\n"
        "varying flat float v_is_brush;\n"   // FLAT: material must NOT interpolate across
        "varying flat float v_mat;\n"        // triangles (else rainbow fringe at seams)
        "varying flat float v_proj;\n"       // per-face projection candidate 0 (0 = none → axial fallback)
        "varying flat float v_proj2;\n"      // candidate 1 (crease other face; picked per-fragment)
        "\n"
        "// Simple hash noise\n"
        "float hash(vec3 p) {\n"
        "    p = fract(p * vec3(443.897, 441.423, 437.195));\n"
        "    p += dot(p, p.yzx + 19.19);\n"
        "    return fract((p.x + p.y) * p.z);\n"
        "}\n"
        "\n"
        "float noise3d(vec3 p) {\n"
        "    vec3 i = floor(p);\n"
        "    vec3 f = fract(p);\n"
        "    f = f * f * (3.0 - 2.0 * f);\n"
        "    return mix(\n"
        "        mix(mix(hash(i), hash(i + vec3(1,0,0)), f.x),\n"
        "            mix(hash(i + vec3(0,1,0)), hash(i + vec3(1,1,0)), f.x), f.y),\n"
        "        mix(mix(hash(i + vec3(0,0,1)), hash(i + vec3(1,0,1)), f.x),\n"
        "            mix(hash(i + vec3(0,1,1)), hash(i + vec3(1,1,1)), f.x), f.y),\n"
        "        f.z);\n"
        "}\n"
        "\n"
        "void vertex() {\n"
        "    world_pos = (MODEL_MATRIX * vec4(VERTEX, 1.0)).xyz;\n"
        "    world_normal = normalize((MODEL_MATRIX * vec4(NORMAL, 0.0)).xyz);\n"
        "    v_is_brush = (COLOR.a > 0.5 && COLOR.g < 0.5 && COLOR.b < 0.5) ? 1.0 : 0.0;\n"
        "    v_mat = COLOR.r;\n"
        "    v_proj = CUSTOM0.x;\n"   // per-face projection candidates (ARRAY_CUSTOM0, RG_FLOAT)
        "    v_proj2 = CUSTOM0.y;\n"
        "}\n"
        "\n"
        "void fragment() {\n"
        "    // Triplanar blend weights from world normal\n"
        "    vec3 blend = abs(world_normal);\n"
        "    blend = pow(blend, vec3(4.0));  // sharpen blend\n"
        "    blend /= (blend.x + blend.y + blend.z + 0.001);\n"
        "\n"
        "    // Triplanar noise for texture variation\n"
        "    vec3 sp = world_pos * triplanar_scale;\n"
        "    float n_xy = noise3d(sp.xyz);\n"
        "    float n_xz = noise3d(sp.xzy);\n"
        "    float n_yz = noise3d(sp.yxz);\n"
        "    float tri_noise = n_xy * blend.z + n_xz * blend.y + n_yz * blend.x;\n"
        "\n"
        "    // Fine detail noise\n"
        "    float detail = noise3d(world_pos * 0.5) * 0.3 + noise3d(world_pos * 1.7) * 0.15;\n"
        "\n"
        "    // Y-up grass blend: tops get grass, sides/bottom get rock\n"
        "    float up_factor = world_normal.y;  // 1.0 = flat top, 0.0 = vertical, -1.0 = underside\n"
        "    float grass_threshold = 0.65 + (tri_noise - 0.5) * 0.4;  // noise at edges\n"
        "    float grass_t = smoothstep(grass_threshold - 0.1, grass_threshold + 0.1, up_factor);\n"
        "\n"
        "    // Height-based: no grass below Y=0\n"
        "    float height_fade = smoothstep(-5.0, 5.0, world_pos.y);\n"
        "    grass_t *= height_fade;\n"
        "\n"
        "    // Dirt on steep slopes (between grass and rock)\n"
        "    float dirt_t = smoothstep(0.3, 0.5, up_factor) * (1.0 - grass_t);\n"
        "\n"
        "    // Final color\n"
        "    vec3 base_rock = rock_color * (0.8 + detail * 0.4);\n"
        "    vec3 base_grass = grass_color * (0.85 + detail * 0.3);\n"
        "    vec3 base_dirt = dirt_color * (0.85 + detail * 0.3);\n"
        "    vec3 color = base_rock;\n"
        "    color = mix(color, base_dirt, dirt_t);\n"
        "    color = mix(color, base_grass, grass_t);\n"
        "\n"
        "    // LOD color override (set by debug_lod_colors)\n"
        "    if (use_lod_color) {\n"
        "        color = lod_color;\n"
        "    }\n"
        "\n"
        "    // Route 2 texturing (T1 debug): imported Quake brush faces carry a\n"
        "    // material_id in COLOR.r (id/255), tagged COLOR = (r,0,0,1). Give each\n"
        "    // material a distinct flat color to validate the channel end-to-end\n"
        "    // (WAD atlas + planar UV reprojection replace this next). The g<0.5 &&\n"
        "    // b<0.5 guard rejects the default white COLOR of no-vertex-color chunks.\n"
        "    vec2 pbr_uv = vec2(0.0); float pbr_layer = 0.0; bool pbr_valid = false;\n"
        "    vec2 pbr_ddx = vec2(0.0); vec2 pbr_ddy = vec2(0.0);\n"   // LOD gradients of the CONTINUOUS uv (pre-fract)
        "    vec3 pbr_T = vec3(1.0,0.0,0.0); vec3 pbr_B = vec3(0.0,1.0,0.0);\n"
        "    if (v_is_brush > 0.5) {\n"
        "        float mid = v_mat * 255.0;\n"
        "        if (use_textures) {\n"
        "            int prow = int(v_proj + 0.5) - 1;\n"   // candidate 0 → row (1-based); <0 = none
        "            int prow1 = int(v_proj2 + 0.5) - 1;\n" // candidate 1 (crease other face)
        "            if (has_projection_table && prow1 >= 0 && prow1 < projection_rows && prow >= 0) {\n"
        "                // Sharp-crease fix: pick the candidate whose face best matches this pixel's\n"
        "                // normal, so each side of the crease textures with its OWN face. Match against\n"
        "                // the face's GEOMETRIC normal (texel3), NOT cross(u_axis,v_axis): Quake projects\n"
        "                // diagonal faces with AXIAL texture axes, so cross(u,v) is axial and mis-picks\n"
        "                // the arch faces (oracle: 77.5%% -> 89.8%% on START.MAP diagonals).\n"
        "                vec3 na = texelFetch(projection_table, ivec2(3, prow),  0).xyz;\n"
        "                vec3 nb = texelFetch(projection_table, ivec2(3, prow1), 0).xyz;\n"
        "                float sa = abs(dot(na, world_normal));\n"
        "                float sb = abs(dot(nb, world_normal));\n"
        "                if (sb > sa) prow = prow1;\n"
        "            }\n"
        "            if (has_projection_table && prow >= 0 && prow < projection_rows) {\n"
        "                // Stage 2: faithful per-FACE Quake projection. Row = 3 texels:\n"
        "                // (layer,u_off,v_off,0)(u_axis.xyz,0)(v_axis.xyz,0), axes already ÷texW/texH.\n"
        "                vec4 t0 = texelFetch(projection_table, ivec2(0, prow), 0);\n"
        "                vec4 t1 = texelFetch(projection_table, ivec2(1, prow), 0);\n"
        "                vec4 t2 = texelFetch(projection_table, ivec2(2, prow), 0);\n"
        "                vec2 raw = vec2(dot(t1.xyz, world_pos) + t0.g, dot(t2.xyz, world_pos) + t0.b);\n"
        "                vec2 ddx = dFdx(raw); vec2 ddy = dFdy(raw);\n"   // LOD from CONTINUOUS uv, not fract() (kills seam-glint)
        "                vec2 uv = fract(raw);\n"
        "                color = textureGrad(tex_atlas, vec3(uv, t0.r), ddx, ddy).rgb;\n"
        "                pbr_uv = uv; pbr_layer = t0.r; pbr_valid = true;\n"
        "                pbr_ddx = ddx; pbr_ddy = ddy;\n"
        "                pbr_T = t1.xyz; pbr_B = t2.xyz;\n"
        "            } else {\n"
        "                // Axial fallback (Stage 1: world-aligned, per-material) when a cell has\n"
        "                // no per-face projection (soft/edge cells, non-imported convex brushes).\n"
        "                vec3 qn = vec3(world_normal.x, -world_normal.z, world_normal.y);\n"
        "                int bi = 0; float best = -2.0;\n"
        "                for (int i = 0; i < 6; i++) { float d = dot(qn, QN[i]); if (d > best) { best = d; bi = i; } }\n"
        "                vec3 gu = vec3(QU[bi].x, QU[bi].z, -QU[bi].y);\n"   // _q2g(quake u axis)
        "                vec3 gv = vec3(QV[bi].x, QV[bi].z, -QV[bi].y);\n"
        "                vec2 raw = vec2(dot(gu, world_pos), dot(gv, world_pos)) / tex_tile;\n"
        "                vec2 ddx = dFdx(raw); vec2 ddy = dFdy(raw);\n"
        "                float layer = max(floor(mid + 0.5) - 1.0, 0.0);\n"   // material_id is 1-based
        "                color = textureGrad(tex_atlas, vec3(fract(raw), layer), ddx, ddy).rgb;\n"
        "                pbr_uv = fract(raw); pbr_layer = layer; pbr_valid = true;\n"
        "                pbr_ddx = ddx; pbr_ddy = ddy;\n"
        "                pbr_T = gu; pbr_B = gv;\n"
        "            }\n"
        "        } else if (mid > 150.0) { color = vec3(1.0, 0.0, 0.0); }\n"   // DEBUG: capped cell
        "        else { color = fract(vec3(mid * 0.13, mid * 0.37, mid * 0.61)) * 0.7 + 0.15; }\n"
        "    }\n"
        "\n"
        "    ALBEDO = color;\n"
        "    ROUGHNESS = 0.85 + tri_noise * 0.15;  // noise-driven roughness\n"
        "    SPECULAR = 0.3;\n"
        "    METALLIC = 0.0;\n"
        // Under path tracing this runs once per BOUNCE per sample, so these atlas
        // fetches multiply by the bounce count. An indirect bounce reaches the
        // image only through the throughput, where roughness detail and a normal
        // map are not distinguishable — but GLOW is, because it emits the light
        // the bounce exists to gather, so that one stays unconditional.
        //
        // PRIMARY_HIT is an RT-only built-in (SceneShaderRaytracing renames), and
        // #ifdef RT is defined by ShaderPreprocessor only for the RT variant
        // (material_storage.cpp), so the rasterizer still compiles the full path
        // and is byte-identical to before.
        "    bool pbr_full = true;\n"
        "#ifdef RT\n"
        "    pbr_full = PRIMARY_HIT;\n"
        "#endif\n"
        "    if (use_pbr && pbr_valid) {\n"
        // One fetch, both channels: rgb is the glow, a is the gloss.
        "        vec4 gr = textureGrad(glow_atlas, vec3(pbr_uv, pbr_layer), pbr_ddx, pbr_ddy);\n"
        "        EMISSION = gr.rgb;\n"
        "        if (pbr_full) {\n"
        "            ROUGHNESS = 1.0 - gr.a * 0.7;\n"  // gloss=specular: matte base, sharp where glossy
        "            vec3 Nn = normalize(world_normal);\n"
        "            vec3 Tn = normalize(pbr_T);\n"   // u_axis (VectorS) — normalize: proj table divides by texW
        "            vec3 Bn = normalize(pbr_B);\n"   // v_axis (VectorT)
        "            vec3 sn = normalize(textureGrad(norm_atlas, vec3(pbr_uv, pbr_layer), pbr_ddx, pbr_ddy).rgb - 0.5);\n"  // DarkPlaces decode
        "            sn.xy *= normal_strength;\n"
        "            vec3 wn = normalize(sn.x * Tn + sn.y * Bn + sn.z * Nn);\n"  // DP: sn.x*VectorS + sn.y*VectorT + sn.z*VectorR (no flip; +v_axis == +V)
        "            NORMAL = normalize((VIEW_MATRIX * vec4(wn, 0.0)).xyz);\n"
        "        }\n"
        "    }\n"
        "}\n"
    );

    // Workers are created in NOTIFICATION_ENTER_TREE (which fires before
    // _ready and persists across editor tab switches). Do NOT create them
    // here — _ready() only fires once but ENTER_TREE handles lifecycle.
}

Dictionary DCTerrain::get_scalar_query_counts() const {
    Dictionary d;
    d["sample_density"] = (int64_t)n_sample_density_.load(std::memory_order_relaxed);
    d["eval_with_edits"] = (int64_t)n_eval_with_edits_.load(std::memory_order_relaxed);
    d["collision_mode"] = (int)collision_mode_;
    return d;
}

void DCTerrain::reset_scalar_query_counts() {
    n_sample_density_.store(0, std::memory_order_relaxed);
    n_eval_with_edits_.store(0, std::memory_order_relaxed);
}

void DCTerrain::_process(double delta) {
    // DC_SCALAR_PROFILE=1 prints the scalar query rate once a second. Off by
    // default and env-gated rather than a property, so measuring costs no game
    // script change and normal runs get no extra log line. #64 NEXT 3.
    if (scalar_profile_accum_ == 0.0) {
        scalar_profile_on_ = OS::get_singleton()->get_environment("DC_SCALAR_PROFILE") == "1";
    }
    if (scalar_profile_on_) {
        scalar_profile_accum_ += delta;
        if (scalar_profile_accum_ >= 1.0) {
            UtilityFunctions::print(
                "DC_SCALAR_PROFILE ", (double)scalar_profile_accum_, "s: sample_density=",
                (int64_t)n_sample_density_.load(std::memory_order_relaxed),
                " eval_with_edits=", (int64_t)n_eval_with_edits_.load(std::memory_order_relaxed),
                " collision_mode=", (int)collision_mode_);
            scalar_profile_accum_ = 0.0001;
        }
    }
    if (!is_inside_tree()) return;

    frame_counter_++;

    // Deferred worker spawn: the pool was held back at ENTER_TREE so the GPU
    // thread's create_local_rendering_device() could have the cores to itself
    // (measured 30.2 s contended vs 4.17 s free). Spawn the frame it lands.
    // The 90 s escape hatch keeps a GPU that never initialises from producing a
    // terrain-less world — we lose the GPU mesher, not the terrain.
    if (workers_deferred_) {
        double waited = std::chrono::duration<double>(
            std::chrono::steady_clock::now() - workers_deferred_since_).count();
        bool ready = gpu_thread_ && gpu_thread_->is_available();
        if (ready || waited > 90.0) {
            workers_deferred_ = false;
            if (debug_logging_ || !ready) {
                fprintf(stderr, "[GPU-FIRST] deferred workers %.2f s (gpu_available=%d)\n",
                    waited, (int)ready);
                fflush(stderr);
            }
            _spawn_workers();
        }
    }

    // 1. Get camera position
    float cam_pos[3] = {0.0f, 0.0f, 0.0f};
    bool has_camera = false;
    Camera3D* cam = nullptr;

    // In editor mode, ALWAYS use editor viewport camera (not the scene's Camera3D)
    if (Engine::get_singleton()->is_editor_hint()) {
        EditorInterface* ei = EditorInterface::get_singleton();
        if (ei) {
            SubViewport* editor_vp = ei->get_editor_viewport_3d(0);
            if (editor_vp) {
                cam = editor_vp->get_camera_3d();
            }
        }
    } else {
        // Game mode: use the viewport camera
        Viewport* vp = get_viewport();
        if (vp) {
            cam = vp->get_camera_3d();
        }
    }

    if (cam) {
        Vector3 gp = cam->get_global_position();
        cam_pos[0] = gp.x;
        cam_pos[1] = gp.y;
        cam_pos[2] = gp.z;
        has_camera = true;

        // Update camera direction for frustum-aware LOD
        Vector3 fwd = -cam->get_global_basis().get_column(2);  // -Z is forward
        octree_clipmap_.cam_forward[0] = fwd.x;
        octree_clipmap_.cam_forward[1] = fwd.y;
        octree_clipmap_.cam_forward[2] = fwd.z;

        // Update screen-space error metric parameters from camera
        octree_clipmap_.camera_fov_rad = Math::deg_to_rad(cam->get_fov());
        Viewport* cam_vp = cam->get_viewport();
        if (cam_vp) {
            octree_clipmap_.viewport_height = static_cast<float>(
                cam_vp->get_visible_rect().size.y);
        }
    }

    // Debug: log status on first frames, then every 60 frames for the WHOLE run.
    // The `debug_frames_ < 20` cap used to stop this line ~39 s in, which meant
    // the resident-set counters (leaves / blocked / dealloc_q) went dark for
    // exactly the part of a session where they matter — cost that grows with
    // accumulated world rather than with what the camera is doing right now is
    // invisible without them (2026-09-03).
    if (debug_logging_ && (debug_frames_ < 5 || frame_counter_ % 60 == 0)) {
        auto stats = octree_clipmap_.get_stats();
        const char* mode_str = (lod_mode_ == LOD_BLOCKING_SWAP) ? "BLOCKING" : "EAGER";
        UtilityFunctions::print(String("DCTerrain[{0}]: mode={1} cam={2} pos=({3},{4},{5}) leaves={6} pending={7} blocked={8} dealloc_q={9} workers={10}").format(
            Array::make(debug_frames_, mode_str, has_camera,
                cam_pos[0], cam_pos[1], cam_pos[2],
                stats.leaf_count, stats.pending_count,
                stats.blocked_count,
                static_cast<int>(dealloc_queue_.size()),
                using_editor_worker_ ? (s_editor_worker_ ? 1 : 0) : static_cast<int>(workers_.size()))));
        debug_frames_++;
    }

    // --- Camera movement detection ---
    // Dead-zone: skip LOD re-evaluation if camera barely moved.
    // This prevents infinite re-traversal when the camera is stationary.
    float cam_speed_sq = 0.0f;
    if (has_camera && frame_counter_ > 1) {
        float dx = cam_pos[0] - prev_cam_pos_[0];
        float dy = cam_pos[1] - prev_cam_pos_[1];
        float dz = cam_pos[2] - prev_cam_pos_[2];
        cam_speed_sq = dx * dx + dy * dy + dz * dz;

        // Camera moved past dead-zone — re-traverse LOD
        float dead_zone = voxel_size_ * 0.5f;
        if (cam_speed_sq > dead_zone * dead_zone) {
            octree_clipmap_.invalidate_settled();
        }
    }
    if (has_camera) {
        prev_cam_pos_[0] = cam_pos[0];
        prev_cam_pos_[1] = cam_pos[1];
        prev_cam_pos_[2] = cam_pos[2];
    }

    // Speed-aware merge throttle: reduce merges when camera is moving fast
    float finest_cell = voxel_size_ * 14.0f;  // LOD0 cell world size
    float fast_threshold_sq = finest_cell * finest_cell * 4.0f;  // 2x cell size
    bool camera_moving_fast = cam_speed_sq > fast_threshold_sq;
    if (camera_moving_fast) {
        // Temporarily reduce merges to prevent freeing in-flight nodes
        octree_clipmap_.max_merges_per_frame = 1;
    } else {
        octree_clipmap_.max_merges_per_frame = 8;
    }

    // Clear freed-node tracker from previous frame
    freed_nodes_.clear();

    // --- Frame timing ---
    auto frame_ms = [](auto t0, auto t1) {
        return std::chrono::duration<double, std::milli>(t1 - t0).count();
    };
    auto t_frame = std::chrono::high_resolution_clock::now();

    // 2. Traverse octree (unless LOD frozen)
    // Burst split budget for first 10 frames (or after rebuild) to populate all octants
    // uniformly at spawn. Normal budget (16) can starve far-side octants via stack bias.
    if (frame_counter_ <= 30) {
        octree_clipmap_.max_splits_per_frame = 256;
    } else {
        octree_clipmap_.max_splits_per_frame = 16;
    }
    if (!lod_frozen_) {
        // Incremental re-center: shift root by one child-span when camera
        // moves beyond half a child-span from center. Transplants overlapping
        // children, frees non-overlapping, creates new PENDING at edges.
        if (has_camera) {
            int child_span = lod_voxel_span(octree_clipmap_.voxels_per_cell,
                                             octree_clipmap_.max_lod_levels - 2);
            int threshold = child_span / 2;
            Vector3i cam_voxel(
                static_cast<int>(std::floor(cam_pos[0] / voxel_size_)),
                static_cast<int>(std::floor(cam_pos[1] / voxel_size_)),
                static_cast<int>(std::floor(cam_pos[2] / voxel_size_)));
            Vector3i center = octree_clipmap_.get_root_center();
            Vector3i delta(cam_voxel.x - center.x, cam_voxel.y - center.y, cam_voxel.z - center.z);

            // Shift one child-span at a time per axis
            Vector3i shift(0, 0, 0);
            if (delta.x > threshold) shift.x = child_span;
            else if (delta.x < -threshold) shift.x = -child_span;
            if (delta.y > threshold) shift.y = child_span;
            else if (delta.y < -threshold) shift.y = -child_span;
            if (delta.z > threshold) shift.z = child_span;
            else if (delta.z < -threshold) shift.z = -child_span;

            if (shift.x != 0 || shift.y != 0 || shift.z != 0) {
                Vector3i new_center(center.x + shift.x, center.y + shift.y, center.z + shift.z);
                octree_clipmap_.recenter_incremental(new_center, frame_counter_);
                generation_++;
                for (auto& w : workers_) w->set_generation(generation_);
                // Reset stuck pre-mesh entries: recenter invalidates in-flight
                // requests (stale generation), so MESH_GENERATING entries will
                // never receive results. Reset them to MESH_PENDING for redispatch.
                auto& cache = octree_clipmap_.pre_mesh_cache_ref();
                for (auto& [morton, entry] : cache) {
                    if (entry.state == OctreeClipmapNode::MESH_GENERATING) {
                        entry.state = OctreeClipmapNode::MESH_PENDING;
                    }
                }

                // FROZEN-CHUNK FIX: the generation_++ above also strands octree
                // NODES that were in GENERATING — their in-flight results are
                // dropped at the global-generation check (req_gen != cur_gen) and
                // mark_pending() refuses to re-enqueue a GENERATING node, so they
                // sit in GENERATING forever: never ACTIVE, never split -> permanent
                // frozen coarse chunks. Force-requeue them (requeue_pending bypasses
                // the state guard — its documented purpose), mirroring the edit
                // path's per-node fix. Only live/transplanted leaves remain here;
                // non-transplanted subtrees were already freed by recenter.
                auto stranded_leaves = octree_clipmap_.get_leaves();
                int restranded = 0;
                for (auto* node : stranded_leaves) {
                    if (node->state == OctreeClipmapNode::GENERATING) {
                        octree_clipmap_.requeue_pending(node);
                        restranded++;
                    }
                }
                if (restranded > 0) {
                    octree_clipmap_.invalidate_settled();
                    UtilityFunctions::print("[LOD] recenter re-queued ", restranded,
                        " stranded GENERATING nodes (frozen-chunk fix)");
                }
            }
        }
        octree_clipmap_.traverse(cam_pos, frame_counter_);
    }

    if (lod_mode_ == LOD_BLOCKING_SWAP) {
        // BLOCKING_SWAP: apply already-completed cached meshes here (consumes
        // results from past frames' pre-mesh dispatches; doesn't issue new work).
        // Pre-mesh DISPATCH is deferred to after the regular pending dispatch
        // loop below, so user-initiated edits get worker queue slots first.
        _apply_cached_meshes();
        // Stale parent cleanup (parent mesh hidden after children uploaded)
        octree_clipmap_.check_stale_parents();
    } else {
        // EAGER_FADE: check if any split-in-progress parents can now be freed
        // (all children became ACTIVE since the split)
        octree_clipmap_.check_stale_parents();
    }

    // DIAGNOSTIC (ungated, throttled, self-silencing): the "coarse LOD doesn't leave —
    // yellow over green" bug. An INTERNAL (already-split) node that still owns a valid
    // mesh_instance is literally a coarse mesh drawn over its finer children. Report
    // any such node and WHY it wasn't freed: its parent_mesh_stale flag (false = the
    // flag was cleared without freeing = leak; true = free blocked) and the first
    // child that fails the free gate (state / fade). Silent once the tree is clean.
    if ((frame_counter_ % 60) == 0 && octree_clipmap_.get_root()) {
        std::vector<OctreeClipmapNode*> stk;
        stk.push_back(octree_clipmap_.get_root());
        int leaked = 0;
        int col_leaked = 0;
        while (!stk.empty()) {
            OctreeClipmapNode* n = stk.back(); stk.pop_back();
            if (!n) continue;
            if (!n->is_leaf()) {
                // COLLISION, WHICH THIS DIAGNOSTIC NEVER CHECKED.
                //
                // It reported internal nodes still DRAWING a coarse mesh and
                // said nothing about internal nodes still COLLIDING with one --
                // and the collision case is the worse of the two, because it is
                // invisible. _on_mesh_free deliberately keeps a parent's
                // collision alive while its children mesh, so a parent that
                // never passes the Stage 2 gate leaves a coarse surface, 2
                // voxels of overlap wide, sitting above the fine one you can
                // see. A capsule between them collects contacts from both,
                // barely penetrates either, and gets ejected: measured 5.64
                // contacts per frame on ground whose steepest triangle is 11.8
                // degrees, with a contact normal at 168.8 degrees -- a ceiling,
                // on ground with no overhang anywhere in the visible mesh.
                //
                // debug_collision_faces cannot see it either: it walks
                // get_leaves(), and a node that has split is not a leaf.
                if (n->collision_body.is_valid()) {
                    int cbad = -1, cbstate = -1; float cbfade = -1.0f;
                    for (int i = 0; i < 8; i++) {
                        OctreeClipmapNode* c = n->children[i];
                        bool ok = c && (c->state == OctreeClipmapNode::ACTIVE ||
                                        c->state == OctreeClipmapNode::EMPTY) && c->fade_t >= 0.99f;
                        if (!ok) { cbad = i; cbstate = c ? (int)c->state : -1; cbfade = c ? c->fade_t : -1.0f; break; }
                    }
                    if (col_leaked < 8) {
                        UtilityFunctions::print("[STALECOL] INTERNAL lod=", n->lod_level,
                            " still COLLIDING pos=(", n->position.x, ",", n->position.y, ",", n->position.z,
                            ") parent_collision_stale=", n->parent_collision_stale,
                            " blockChild=", cbad, " childState=", cbstate, " childFade=", cbfade);
                    }
                    col_leaked++;
                }
                if (n->mesh_instance.is_valid() || n->seam_mesh_instance.is_valid()) {
                    int bad = -1, bstate = -1; float bfade = -1.0f;
                    for (int i = 0; i < 8; i++) {
                        OctreeClipmapNode* c = n->children[i];
                        bool ok = c && (c->state == OctreeClipmapNode::ACTIVE ||
                                        c->state == OctreeClipmapNode::EMPTY) && c->fade_t >= 0.99f;
                        if (!ok) { bad = i; bstate = c ? (int)c->state : -1; bfade = c ? c->fade_t : -1.0f; break; }
                    }
                    if (leaked < 8) {
                        UtilityFunctions::print("[STALEDIAG] INTERNAL lod=", n->lod_level,
                            " still-drawing mesh=", n->mesh_instance.is_valid(),
                            " seam=", n->seam_mesh_instance.is_valid(),
                            " parent_mesh_stale=", n->parent_mesh_stale,
                            " blockChild=", bad, " childState=", bstate, " childFade=", bfade);
                    }
                    leaked++;
                }
                for (int i = 0; i < 8; i++) if (n->children[i]) stk.push_back(n->children[i]);
            }
        }
        if (leaked > 0) {
            UtilityFunctions::print("[STALEDIAG] ", leaked,
                " internal nodes still drawing a coarse mesh/seam over their children");
        }
        if (col_leaked > 0) {
            UtilityFunctions::print("[STALECOL] ", col_leaked,
                " internal nodes still COLLIDING with a coarse surface over their children");
        }
    }

    // NOTE: Stale parent shadow disable was removed — disabling shadows on
    // parents while children also have shadows off (during fade-in) causes
    // black flickering. Instead, children get shadows enabled immediately
    // (see _upload_cell change below) so both parent and child cast shadows
    // during transition. Minor double-shadow is less visible than no shadow.

    // Animate fade_t on leaves for dithered screen-door transitions.
    // Per-chunk ShaderMaterial with regular uniform (NOT instance uniform) —
    // avoids Godot's instance uniform buffer overflow entirely.
    // Skip entirely in BLOCKING_SWAP mode — no fading, visibility is binary.
    if (lod_mode_ != LOD_BLOCKING_SWAP)
    {
        static const Color LOD_COLORS[] = {
            Color(0.2f, 0.8f, 0.2f), Color(0.2f, 0.6f, 0.8f),
            Color(0.8f, 0.8f, 0.2f), Color(0.8f, 0.4f, 0.1f),
            Color(0.8f, 0.2f, 0.2f), Color(0.6f, 0.2f, 0.8f),
            Color(0.2f, 0.2f, 0.8f), Color(0.8f, 0.6f, 0.8f),
            Color(0.4f, 0.4f, 0.4f), Color(1.0f, 1.0f, 1.0f),
            Color(0.5f, 0.3f, 0.1f), Color(0.1f, 0.5f, 0.5f),
        };
        // Velocity-boosted fade speed — fast camera movement accelerates transitions
        float effective_fade_speed = fade_speed_;
        if (cam_speed_sq > 0.0f) {
            float cam_speed = std::sqrt(cam_speed_sq);
            float finest_cell_size = voxel_size_ * static_cast<float>(octree_clipmap_.voxels_per_cell);
            float speed_ratio = cam_speed / (finest_cell_size * static_cast<float>(delta));
            if (speed_ratio > 1.0f) {
                effective_fade_speed = fade_speed_ * std::min(speed_ratio, 10.0f);
            }
        }
        RenderingServer* rs_fade = RenderingServer::get_singleton();
        auto leaves = octree_clipmap_.get_leaves();
        for (auto* leaf : leaves) {
            if (!leaf->mesh_instance.is_valid()) continue;

            float diff = leaf->fade_target - leaf->fade_t;
            if (std::fabs(diff) > 0.001f) {
                // Actively fading — animate fade_t
                float dir = (diff > 0.0f) ? 1.0f : -1.0f;
                leaf->fade_t += dir * effective_fade_speed * static_cast<float>(delta);
                if (dir > 0.0f) {
                    if (leaf->fade_t > leaf->fade_target)
                        leaf->fade_t = leaf->fade_target;
                } else {
                    if (leaf->fade_t < leaf->fade_target)
                        leaf->fade_t = leaf->fade_target;
                }

                // Create per-chunk ShaderMaterial on first fade frame
                auto it = fade_overrides_.find(leaf);
                if (it == fade_overrides_.end()) {
                    auto mesh_it = node_meshes_.find(leaf);
                    if (mesh_it == node_meshes_.end() || mesh_it->second.is_null()) continue;

                    Ref<ShaderMaterial> mat;
                    mat.instantiate();
                    mat->set_shader(terrain_shader_);
                    _apply_texture_params(mat);

                    int lod = leaf->lod_level;
                    if (lod < 0) lod = 0;
                    if (lod >= MAX_LOD_MATERIALS) lod = MAX_LOD_MATERIALS - 1;
                    Color c = debug_lod_colors_ ? LOD_COLORS[lod < (int)(sizeof(LOD_COLORS)/sizeof(LOD_COLORS[0]))
                        ? lod : (int)(sizeof(LOD_COLORS)/sizeof(LOD_COLORS[0])) - 1] : terrain_color_;
                    mat->set_shader_parameter("use_lod_color", debug_lod_colors_);
                    mat->set_shader_parameter("lod_color",
                        Vector3(c.r, c.g, c.b));
                    mat->set_shader_parameter("fade_t", leaf->fade_t);
                    mat->set_shader_parameter("normal_perturbation", normal_perturbation_);

                    // Set on ArrayMesh surface
                    mesh_it->second->surface_set_material(0, mat);
                    fade_overrides_[leaf] = mat;
                } else {
                    // Update fade_t on existing per-chunk ShaderMaterial
                    it->second->set_shader_parameter("fade_t", leaf->fade_t);
                }
            } else {
                // Fade complete — restore shared lod material, free per-chunk override
                auto it = fade_overrides_.find(leaf);
                if (it != fade_overrides_.end()) {
                    auto mesh_it = node_meshes_.find(leaf);
                    if (mesh_it != node_meshes_.end() && mesh_it->second.is_valid()) {
                        int lod = leaf->lod_level;
                        if (lod < 0) lod = 0;
                        if (lod >= MAX_LOD_MATERIALS) lod = MAX_LOD_MATERIALS - 1;
                        mesh_it->second->surface_set_material(0, lod_materials_[lod]);
                    }
                    // Re-enable shadow casting now that fade is complete
                    if (rs_fade && leaf->mesh_instance.is_valid()) {
                        rs_fade->instance_geometry_set_cast_shadows_setting(
                            leaf->mesh_instance,
                            RenderingServer::SHADOW_CASTING_SETTING_ON);
                    }
                    fade_overrides_.erase(it);
                }
            }
        }

        // Clean up fade overrides for nodes that are no longer leaves
        // (they split while fading, fade_t was forced to 1.0 in _split_node)
        for (auto it = fade_overrides_.begin(); it != fade_overrides_.end(); ) {
            if (!it->first->is_leaf()) {
                auto mesh_it = node_meshes_.find(it->first);
                if (mesh_it != node_meshes_.end() && mesh_it->second.is_valid()) {
                    int lod = it->first->lod_level;
                    if (lod < 0) lod = 0;
                    if (lod >= MAX_LOD_MATERIALS) lod = MAX_LOD_MATERIALS - 1;
                    mesh_it->second->surface_set_material(0, lod_materials_[lod]);
                }
                it = fade_overrides_.erase(it);
            } else {
                ++it;
            }
        }
    }

    auto t_traverse = std::chrono::high_resolution_clock::now();

    // 3. Dispatch pending nodes to workers (priority: closest to camera first)
    // Skip dispatch if workers haven't been created (editor mode skips worker init).
    int regular_dispatched_this_frame = 0;
    int edits_dispatched_this_frame = 0;  // routed through the priority lane (1b)
    auto pending = octree_clipmap_.drain_pending();
    if (!_has_workers()) {
        // Re-queue everything so it's not lost.
        // drain_pending() pulled these out without resetting state, so use
        // requeue_pending which bypasses the "already PENDING" guard.
        for (auto* node : pending) {
            octree_clipmap_.requeue_pending(node);
        }
    } else {
    if (has_camera && pending.size() > 1) {
        std::sort(pending.begin(), pending.end(),
            [&cam_pos, this](OctreeClipmapNode* a, OctreeClipmapNode* b) {
                // User edits always dispatch before speculative LOD streaming (1b).
                if (a->edit_dirty != b->edit_dirty) return a->edit_dirty;
                // Manhattan distance to camera — lower LOD + closer = higher priority
                auto dist = [&](OctreeClipmapNode* n) {
                    float lv = lod_voxel_size(voxel_size_, n->lod_level);
                    float cs = 14.0f * lv;
                    float cx = n->position.x * voxel_size_ + cs * 0.5f;
                    float cy = n->position.y * voxel_size_ + cs * 0.5f;
                    float cz = n->position.z * voxel_size_ + cs * 0.5f;
                    return std::fabs(cam_pos[0] - cx) + std::fabs(cam_pos[1] - cy) + std::fabs(cam_pos[2] - cz);
                };
                return dist(a) < dist(b);
            });
    }
    for (auto* node : pending) {
        CellRequest req;
        req.node = node;

        float lod_voxel = lod_voxel_size(voxel_size_, node->lod_level);
        int overlap = 2;  // 2 voxels overlap on each side
        int grid_size = 32;  // power-of-2 grid (8x GPU occupancy vs 16)
        int nominal = grid_size - 2 * overlap;  // 28 owned voxels per cell
        float nominal_ws = static_cast<float>(nominal) * lod_voxel;

        // Nominal origin: the region this cell "owns"
        req.nominal_origin[0] = node->position.x * voxel_size_;
        req.nominal_origin[1] = node->position.y * voxel_size_;
        req.nominal_origin[2] = node->position.z * voxel_size_;
        req.nominal_size = nominal_ws;

        // Grid origin: shift back by overlap so nominal region sits in the middle
        req.cell_origin[0] = req.nominal_origin[0] - overlap * lod_voxel;
        req.cell_origin[1] = req.nominal_origin[1] - overlap * lod_voxel;
        req.cell_origin[2] = req.nominal_origin[2] - overlap * lod_voxel;

        req.voxel_size = lod_voxel;
        req.grid_dims = grid_size + 1;  // 17 (stride for 16-cell grid)
        req.voxels_per_cell = grid_size; // 16 (actual cells, power-of-2)
        req.overlap = overlap;
        req.node_position = node->position;
        req.lod_level = node->lod_level;
        req.density = &density_;
        req.generator = generator_ptr_;
        req.gpu_eligible = config_.use_gpu && generator_ptr_ != nullptr && generator_ptr_->has_gpu_shader();
        req.use_adaptive_qef = use_adaptive_qef_;
        req.use_shortest_diagonal = use_shortest_diagonal_;
        req.use_mesh_simplify = use_mesh_simplify_;
        // Cull edits to only those overlapping this chunk's AABB.
        // At 10K edits, a typical chunk overlaps 0-5 — massive speedup.
        {
            float chunk_span = static_cast<float>(grid_size) * lod_voxel;
            float cmin[3] = { req.cell_origin[0], req.cell_origin[1], req.cell_origin[2] };
            float cmax[3] = { cmin[0] + chunk_span, cmin[1] + chunk_span, cmin[2] + chunk_span };
            req.edits = cull_edits_for_chunk(config_.edits, cmin, cmax);
        }
        req.generation = generation_;
        req.node_generation = node->generation;
        req.submit_time_us = std::chrono::duration_cast<std::chrono::microseconds>(
            std::chrono::steady_clock::now().time_since_epoch()).count();

        if (req.edits && !req.edits->empty() && debug_logging_) {
            UtilityFunctions::print("[DISPATCH] chunk lod=", node->lod_level,
                " edits=", (int)req.edits->size(),
                " (of ", config_.edits ? (int)config_.edits->size() : 0, ")",
                " gen=", req.generation);
        }
        // User edits take the worker's high-priority lane so they never wait
        // behind a burst of queued LOD/pre-mesh chunks (1b).
        const bool is_edit = node->edit_dirty;
        const bool submitted = is_edit ? _submit_to_worker_priority(req)
                                       : _submit_to_worker(req);
        if (submitted) {
            node->state = OctreeClipmapNode::GENERATING;
            node->edit_dirty = false;  // consumed — dispatched into its lane
            // Consumed on the SAME terms as edit_dirty: this dispatch reads the
            // current config_.edits (cull_edits_for_chunk above), so the follow-up
            // it was asking for is the request being submitted right now. Leaving
            // it set would re-queue this node again when the result lands, forever.
            node->remesh_after_upload = false;
            regular_dispatched_this_frame++;
            if (is_edit) edits_dispatched_this_frame++;
            if (debug_logging_) {
                UtilityFunctions::print("[LIFE] DISP", is_edit ? "(EDIT)" : "", " chunk=(", node->position.x, ",", node->position.y, ",", node->position.z,
                    ") lod=", node->lod_level, " node_gen=", node->generation,
                    " edits=", (int)(req.edits ? req.edits->size() : 0));
            }
        } else {
            // Queue full — force re-enqueue so it's retried next frame.
            // drain_pending() pulled this node out without resetting state from
            // PENDING, so plain mark_pending() would no-op via its guard. Use
            // requeue_pending which bypasses the guard. Without this, the node
            // is orphaned: stuck in PENDING state but not in the queue, and
            // edit_terrain_box's state filter excludes PENDING, so subsequent
            // edits silently skip it.
            octree_clipmap_.requeue_pending(node);
            if (debug_logging_) {
                UtilityFunctions::print("[LIFE] DISP_FAIL_QUEUE_FULL chunk=(", node->position.x, ",", node->position.y, ",", node->position.z,
                    ") lod=", node->lod_level);
            }
        }
    }
    } // end else (workers_ not empty)

    // Pre-mesh dispatch runs AFTER regular pending dispatch so edit/streaming
    // chunks claim any available worker queue slots first. Pre-mesh is
    // speculative LOD refinement and takes whatever capacity is left.
    // Pass the regular-dispatched count so pre-mesh can adapt its per-frame
    // cap: yield when regular work was active, run faster when system is idle.
    // Suppress speculative pre-mesh while user edits are in flight (dispatched
    // this frame or still queued in a worker's priority lane) so workers stay
    // focused on the edit. Resumes automatically once the priority lanes drain (1b).
    // FAIR-SHARE (was `&& !edits_active`): pre-mesh LOD refinement now runs EVEN while
    // edits are in flight. Edits keep worker priority regardless — they go to the
    // PRIORITY lane (_submit_to_worker_priority above) which workers drain before the
    // NORMAL lane that pre-mesh uses; and _dispatch_pre_mesh_requests self-caps at
    // TARGET_QUEUE_DEPTH while still guaranteeing MIN_PREMESH_PER_FRAME. So edits stay
    // responsive but LOD makes guaranteed forward progress. The old `!edits_active`
    // kill-switch dropped pre-mesh to ZERO for the ENTIRE duration of any edit backlog
    // (the 476-edit spawn replay + the 3s state-poll re-feed), which starved near-edit
    // LOD refinement for 1-2 minutes — the verified root cause of the "coarse chunks
    // near edits never refine" stall.
    const bool edits_active = edits_dispatched_this_frame > 0 || _worker_priority_depth() > 0;
    if (lod_mode_ == LOD_BLOCKING_SWAP) {
        _dispatch_pre_mesh_requests(regular_dispatched_this_frame);
        if (edits_active && debug_logging_) {
            UtilityFunctions::print("[LOD] fair-share: pre-mesh running alongside edits in flight");
        }
    }

    auto t_dispatch = std::chrono::high_resolution_clock::now();

    // 4. Poll results from workers — time-budgeted upload
    int uploaded = 0;
    if (_has_workers()) {
        _poll_workers([&](CellResult& result) {
            // Discard stale results from previous generations
            if (result.generation != generation_) {
                if (debug_logging_ && result.node) {
                    UtilityFunctions::print("[LIFE] DROP reason=global_gen_mismatch chunk=(",
                        result.node->position.x, ",", result.node->position.y, ",", result.node->position.z,
                        ") lod=", result.node->lod_level,
                        " req_gen=", result.generation, " cur_gen=", generation_);
                }
                return;
            }
            // Pre-mesh result: store in cache, don't upload
            if (result.is_pre_mesh) {
                auto& cache = octree_clipmap_.pre_mesh_cache_ref();
                auto it = cache.find(result.pre_mesh_morton);
                if (it != cache.end() && (it->second.state == OctreeClipmapNode::MESH_PENDING ||
                                          it->second.state == OctreeClipmapNode::MESH_GENERATING)) {
                    it->second.state = OctreeClipmapNode::MESH_UP_TO_DATE;
                    it->second.data.mesh_data = std::move(result.mesh_data);
                    it->second.data.seam_nodes = std::move(result.seam_nodes);
                    it->second.data.active_voxel_count = result.active_voxel_count;
                    it->second.data.triangle_count = result.triangle_count;
                    it->second.data.geometric_error = result.geometric_error;
                    octree_clipmap_.invalidate_settled();
                }
                return;
            }
            if (result.success && result.node &&
                freed_nodes_.find(result.node) == freed_nodes_.end() &&
                result.node->generation == result.node_generation) {
                _upload_cell(result);
                uploaded++;
                // Node state changed — may affect split/merge decisions
                octree_clipmap_.invalidate_settled();
                // 1c: an edit that landed while this chunk was generating left
                // edit_dirty set (its in-flight result just uploaded with the
                // older edit prefix). Re-queue exactly once so it remeshes with
                // the latest edits — guarantees forward progress under rapid
                // carving instead of livelocking on generation bumps.
                // remesh_after_upload is the same guarantee for a NON-interactive
                // edit (the §5a.2 level shell), which must not enter the priority
                // lane but must still remesh — see OctreeClipmapNode.
                if (result.node->edit_dirty || result.node->remesh_after_upload) {
                    octree_clipmap_.mark_pending(result.node);
                }
                if (debug_logging_) {
                    int64_t now_us = std::chrono::duration_cast<std::chrono::microseconds>(
                        std::chrono::steady_clock::now().time_since_epoch()).count();
                    int64_t total_us = now_us - result.submit_time_us;
                    int64_t poll_wait_us = now_us - result.result_enqueue_us;
                    UtilityFunctions::print("[LIFE] UPLD chunk=(",
                        result.node->position.x, ",", result.node->position.y, ",", result.node->position.z,
                        ") lod=", result.node->lod_level, " node_gen=", result.node->generation,
                        " tris=", result.triangle_count,
                        " | total_ms=", total_us / 1000.0,
                        " qwait_ms=", result.queue_wait_us / 1000.0,
                        " compute_ms=", result.compute_us / 1000.0,
                        " pollwait_ms=", poll_wait_us / 1000.0);
                }
            } else if (debug_logging_) {
                const char* reason = "unknown";
                if (!result.success) reason = "worker_failed";
                else if (!result.node) reason = "null_node";
                else if (freed_nodes_.find(result.node) != freed_nodes_.end()) reason = "node_freed";
                else if (result.node->generation != result.node_generation) reason = "node_gen_mismatch";
                if (result.node) {
                    UtilityFunctions::print("[LIFE] DROP reason=", reason,
                        " chunk=(", result.node->position.x, ",", result.node->position.y, ",", result.node->position.z,
                        ") lod=", result.node->lod_level,
                        " req_node_gen=", result.node_generation,
                        " cur_node_gen=", result.node->generation);
                } else {
                    UtilityFunctions::print("[LIFE] DROP reason=", reason, " (no node)");
                }
            }
        }, 6.0, 4);
    }

    auto t_upload = std::chrono::high_resolution_clock::now();

    // 4b. Seam mesh generation — process nodes waiting for neighbors
    int seams_built = 0;
    {
        constexpr int MAX_SEAMS_PER_FRAME = 16;

        // Remove stale entries (nodes that were freed/merged, OR that SPLIT before
        // their throttled seam was built). A split node is still ACTIVE but is now
        // internal — building a seam on it produces a COARSE-resolution seam that
        // spans the parent cell over its fine LOD0 children (the "coarse mesh drawn
        // over green" z-fight) and is never freed (the split-frame _on_mesh_free
        // already ran). So gate on is_leaf() too.
        for (int i = static_cast<int>(seam_pending_.size()) - 1; i >= 0; i--) {
            auto* node = seam_pending_[i].node;
            if (!node || node->generation != seam_pending_[i].node_generation ||
                node->state != OctreeClipmapNode::ACTIVE || !node->is_leaf()) {
                seam_pending_.erase(seam_pending_.begin() + i);
            }
        }

        // Cap seam queue at 1024
        if (seam_pending_.size() > 1024) {
            seam_pending_.erase(seam_pending_.begin(),
                seam_pending_.begin() + (seam_pending_.size() - 1024));
        }

        // Sort by distance to camera (nearest first) for priority processing
        if (has_camera && !seam_pending_.empty()) {
            std::sort(seam_pending_.begin(), seam_pending_.end(),
                [&cam_pos, this](const SeamRequest& a, const SeamRequest& b) {
                    if (!a.node || !b.node) return a.node != nullptr;
                    float ax = a.node->position.x * voxel_size_ - cam_pos[0];
                    float ay = a.node->position.y * voxel_size_ - cam_pos[1];
                    float az = a.node->position.z * voxel_size_ - cam_pos[2];
                    float bx = b.node->position.x * voxel_size_ - cam_pos[0];
                    float by = b.node->position.y * voxel_size_ - cam_pos[1];
                    float bz = b.node->position.z * voxel_size_ - cam_pos[2];
                    return (ax*ax+ay*ay+az*az) < (bx*bx+by*by+bz*bz);
                });
        }

        static const int OFFSETS[8][3] = {
            {0,0,0}, {0,0,1}, {0,1,0}, {0,1,1},
            {1,0,0}, {1,0,1}, {1,1,0}, {1,1,1},
        };

        // Process nearest chunks first (sorted front-to-back)
        int scan = 0;
        for (int i = 0; i < static_cast<int>(seam_pending_.size()) && seams_built < MAX_SEAMS_PER_FRAME && scan < 64; i++) {
            auto* node = seam_pending_[i].node;
            if (!node) continue;
            scan++;

            // Check if all 8 octant neighbors are ACTIVE (or outside bounds)
            bool neighbors_ready = true;
            int voxel_span = static_cast<int>(lod_voxel_size(1.0f, node->lod_level) * octree_clipmap_.voxels_per_cell);

            for (int d = 0; d < 8; d++) {
                int nx = node->position.x + OFFSETS[d][0] * voxel_span;
                int ny = node->position.y + OFFSETS[d][1] * voxel_span;
                int nz = node->position.z + OFFSETS[d][2] * voxel_span;
                auto* neighbor = octree_clipmap_.find_leaf_containing(nx, ny, nz);
                if (neighbor && neighbor->state != OctreeClipmapNode::ACTIVE) {
                    neighbors_ready = false;
                    break;
                }
            }

            if (neighbors_ready) {
                _generate_seam_for_node(node);
                seam_pending_.erase(seam_pending_.begin() + i);
                i--; // adjust index after erase
                seams_built++;
            }
        }
    }

    auto t_seam = std::chrono::high_resolution_clock::now();

    // 5. Collision: build concave trimesh shapes (delegated to DCCollisionTrimesh).
    int collisions_built = 0;
    if (collision_mode_ != COLLISION_SDF) {
        bool workers_busy = octree_clipmap_.get_stats().pending_count > 0;
        Ref<World3D> world = get_world_3d();
        collisions_built = collision_trimesh_.process_queue(cam_pos, voxel_size_, workers_busy, world, debug_logging_);
    }

    auto t_collision = std::chrono::high_resolution_clock::now();

    // 6. Progressive deallocation — adaptive drain proportional to queue size
    int freed = 0;
    {
        int target_drain = std::max(4, static_cast<int>(dealloc_queue_.size() / 8));
        target_drain = std::min(target_drain, 64); // hard cap to avoid stutter
        while (freed < target_drain && !dealloc_queue_.empty()) {
            auto& entry = dealloc_queue_.front();
            RenderingServer* rs = RenderingServer::get_singleton();
            if (rs) {
                if (entry.rs_instance.is_valid()) {
                    rs->free_rid(entry.rs_instance);
                }
                if (entry.seam_mesh_instance.is_valid()) {
                    rs->free_rid(entry.seam_mesh_instance);
                }
            }
            PhysicsServer3D* ps = PhysicsServer3D::get_singleton();
            if (ps) {
                if (entry.collision_body.is_valid()) {
                    ps->free_rid(entry.collision_body);
                }
                if (entry.collision_shape.is_valid()) ps->free_rid(entry.collision_shape);
            }
            entry.mesh.unref();
            dealloc_queue_.pop_front();
            freed++;
        }
    }

    auto t_dealloc = std::chrono::high_resolution_clock::now();

    // RT/CLAS LOD update removed — re-add after CPU MVP

    // Debug visualizations
    if (debug_seams_) {
        _update_debug_seams();
    }
    if (debug_clipmap_) {
        _update_debug_clipmap();
    }

    auto t_end = std::chrono::high_resolution_clock::now();

    // Log frame timing if total _process exceeds 4ms
    double total_frame = frame_ms(t_frame, t_end);
    if (debug_logging_ && total_frame > 4.0) {
        fprintf(stderr, "FRAME: traverse=%.1f dispatch=%.1f(%d,pm=%d) upload=%.1f(%d) seam=%.1f(%d,q=%d) collision=%.1f(%d,q=%d) dealloc=%.1f(%d) TOTAL=%.1fms\n",
            frame_ms(t_frame, t_traverse),
            frame_ms(t_traverse, t_dispatch), static_cast<int>(pending.size()),
            premesh_pending_last_frame_,
            frame_ms(t_dispatch, t_upload), uploaded,
            frame_ms(t_upload, t_seam), seams_built, static_cast<int>(seam_pending_.size()),
            frame_ms(t_seam, t_collision), collisions_built, collision_trimesh_.queue_size(),
            frame_ms(t_collision, t_dealloc), freed,
            total_frame);
    }

}

// ---------------------------------------------------------------------------
// BLOCKING_SWAP: dispatch pre-mesh requests for speculative mesh generation
// ---------------------------------------------------------------------------
void DCTerrain::_dispatch_pre_mesh_requests(int regular_dispatched_this_frame) {
    // Queue-depth-based throttle: keep total worker queue depth at a small
    // target so workers never idle (LOD streams fast), but edit chunks can
    // queue immediately when they arrive and only wait the brief drain time
    // of TARGET_QUEUE_DEPTH × per-chunk-compute / NUM_WORKERS.
    // Static per-frame caps couldn't balance both — too low starved LOD,
    // too high let pre-mesh saturate the queue ahead of edits.
    constexpr size_t TARGET_QUEUE_DEPTH = 8;
    // Always dispatch at least MIN_PREMESH_PER_FRAME of the CLOSEST pre-mesh
    // (the head of the distance-ordered list) so heavy regular generation
    // (spawn burst / creatures / edits) can't fully starve near-camera LOD
    // refinement and leave chunks frozen coarse (the LOD "chunk freeze").
    // The throttle still governs the bulk, preserving edit responsiveness.
    constexpr int MIN_PREMESH_PER_FRAME = 4;

    auto& cache = octree_clipmap_.pre_mesh_cache_ref();

    // Collect the pending set WITH each entry's camera distance in the SAME
    // pass we already make over the cache.
    //
    // This used to push bare mortons and then std::sort them with a comparator
    // that did TWO unordered_map lookups per comparison — O(N log N) hash
    // lookups EVERY FRAME across the whole backlog. Since the loop below is
    // distance-ordered and dispatches at most TARGET_QUEUE_DEPTH per frame, and
    // pre_mesh_cache_ has no distance eviction, the far tail is never
    // dispatched and never dropped: it just gets re-sorted for ever, so the
    // cost grows with exploration and never comes back down.
    //
    // Measured 2026-09-03 on map_dc_diffusion (841-tile set, same map both
    // runs): the dispatch phase averaged 6.12 ms while dispatching ZERO regular
    // chunks, climbing 3.61 -> 6.86 -> 9.05 ms over the last 60 s of the run,
    // against 1.46 ms before. 11.0 hitches/s (>4 ms) vs 2.9.
    std::vector<std::pair<float, uint64_t>> pending;  // (distance^2, morton)
    pending.reserve(cache.size());
    for (auto& [morton, entry] : cache) {
        if (entry.state != OctreeClipmapNode::MESH_PENDING) continue;
        float dx = entry.position.x * voxel_size_ - prev_cam_pos_[0];
        float dy = entry.position.y * voxel_size_ - prev_cam_pos_[1];
        float dz = entry.position.z * voxel_size_ - prev_cam_pos_[2];
        pending.emplace_back(dx * dx + dy * dy + dz * dz, morton);
    }
    // Instrumentation only — surfaced in the FRAME line as pm=<n>. Computed
    // BEFORE the no-workers return so the backlog is visible during the GPU
    // bring-up window, when the pool is deliberately not spawned yet.
    premesh_pending_last_frame_ = static_cast<int>(pending.size());

    if (!_has_workers()) return;

    // Only the HEAD of the distance-ordered list can ever be dispatched: every
    // iteration below either dispatches or breaks, and the break fires once the
    // queue is at TARGET_QUEUE_DEPTH and MIN_PREMESH_PER_FRAME have gone out —
    // so at most TARGET_QUEUE_DEPTH iterations run. partial_sort produces that
    // head in EXACTLY the order a full sort would, so which chunks get
    // dispatched, and in what order, is unchanged. Only the cost of finding
    // them drops, from O(N log N) to O(N log K).
    const size_t k = std::min(pending.size(),
        TARGET_QUEUE_DEPTH + static_cast<size_t>(MIN_PREMESH_PER_FRAME));
    std::partial_sort(pending.begin(), pending.begin() + k, pending.end(),
        [](const std::pair<float, uint64_t>& a, const std::pair<float, uint64_t>& b) {
            return a.first < b.first;
        });

    size_t total_queue_depth = 0;
    if (using_editor_worker_ && s_editor_worker_) {
        total_queue_depth = s_editor_worker_->request_queue_size();
    } else {
        for (auto& w : workers_) total_queue_depth += w->request_queue_size();
    }
    (void)regular_dispatched_this_frame;  // queue-depth approach makes this redundant

    int premesh_dispatched = 0;
    for (const auto& pm : pending) {
        if (total_queue_depth >= TARGET_QUEUE_DEPTH &&
            premesh_dispatched >= MIN_PREMESH_PER_FRAME) break;
        const uint64_t morton = pm.second;
        auto& entry = cache[morton];

        CellRequest req;
        req.node = nullptr;
        req.pre_mesh_morton = morton;
        req.is_pre_mesh = true;

        float lod_voxel = lod_voxel_size(voxel_size_, entry.lod_level);
        int overlap = 2;
        int grid_size = 32;
        int nominal = grid_size - 2 * overlap;
        float nominal_ws = static_cast<float>(nominal) * lod_voxel;

        req.nominal_origin[0] = entry.position.x * voxel_size_;
        req.nominal_origin[1] = entry.position.y * voxel_size_;
        req.nominal_origin[2] = entry.position.z * voxel_size_;
        req.nominal_size = nominal_ws;

        req.cell_origin[0] = req.nominal_origin[0] - overlap * lod_voxel;
        req.cell_origin[1] = req.nominal_origin[1] - overlap * lod_voxel;
        req.cell_origin[2] = req.nominal_origin[2] - overlap * lod_voxel;

        req.voxel_size = lod_voxel;
        req.grid_dims = grid_size + 1;
        req.voxels_per_cell = grid_size;
        req.overlap = overlap;
        req.node_position = entry.position;
        req.lod_level = entry.lod_level;
        req.density = &density_;
        req.generator = generator_ptr_;
        req.gpu_eligible = config_.use_gpu && generator_ptr_ != nullptr && generator_ptr_->has_gpu_shader();
        req.use_adaptive_qef = use_adaptive_qef_;
        req.use_shortest_diagonal = use_shortest_diagonal_;
        req.use_mesh_simplify = use_mesh_simplify_;
        // Cull edits to only those overlapping this chunk's AABB — mirror the
        // regular dispatch path (see cull at the pending-dispatch loop). This path
        // previously passed the FULL edit list to every speculative LOD chunk, so
        // each one paid O(all-edits) per voxel in density eval — the dominant
        // "CSG edits get laggy as you carve" cost, since pre-mesh runs continuously.
        {
            float chunk_span = static_cast<float>(grid_size) * lod_voxel;
            float cmin[3] = { req.cell_origin[0], req.cell_origin[1], req.cell_origin[2] };
            float cmax[3] = { cmin[0] + chunk_span, cmin[1] + chunk_span, cmin[2] + chunk_span };
            req.edits = cull_edits_for_chunk(config_.edits, cmin, cmax);
        }
        req.generation = generation_;
        req.node_generation = 0;
        req.submit_time_us = std::chrono::duration_cast<std::chrono::microseconds>(
            std::chrono::steady_clock::now().time_since_epoch()).count();

        if (_submit_to_worker(req)) {
            entry.state = OctreeClipmapNode::MESH_GENERATING;
            entry.dispatch_frame = frame_counter_;
            total_queue_depth++;
            premesh_dispatched++;
        } else {
            // Worker queue is full — don't keep trying this frame, leave the
            // remaining MESH_PENDING entries for next frame when capacity frees.
            break;
        }
    }
}

// ---------------------------------------------------------------------------
// BLOCKING_SWAP: upload cached meshes for newly-split children / merged parents
// ---------------------------------------------------------------------------
void DCTerrain::_apply_cached_meshes() {
    OctreeClipmapNode* root = octree_clipmap_.get_root();
    if (!root) return;

    auto& cache = octree_clipmap_.pre_mesh_cache_ref();

    // Walk the tree looking for ACTIVE leaves that need mesh upload
    std::vector<OctreeClipmapNode*> stack;
    stack.push_back(root);

    while (!stack.empty()) {
        OctreeClipmapNode* n = stack.back();
        stack.pop_back();
        if (!n) continue;

        if (n->is_leaf()) {
            // Check if this leaf needs a cached mesh uploaded
            if (n->state == OctreeClipmapNode::ACTIVE &&
                n->mesh_state == OctreeClipmapNode::MESH_UP_TO_DATE &&
                !n->mesh_instance.is_valid()) {
                auto it = cache.find(n->morton_code);
                if (it != cache.end() && it->second.state == OctreeClipmapNode::MESH_UP_TO_DATE) {
                    // Build CellResult from cached data and upload
                    CellResult result;
                    result.node = n;
                    result.mesh_data = std::move(it->second.data.mesh_data);
                    result.seam_nodes = std::move(it->second.data.seam_nodes);
                    result.active_voxel_count = it->second.data.active_voxel_count;
                    result.triangle_count = it->second.data.triangle_count;
                    result.geometric_error = it->second.data.geometric_error;
                    result.success = true;
                    result.generation = generation_;
                    result.node_generation = n->generation;
                    _upload_cell(result);

                    // Evict consumed cache entry
                    cache.erase(it);
                }
            }
        } else {
            for (int i = 0; i < 8; i++) {
                if (n->children[i]) stack.push_back(n->children[i]);
            }
        }
    }
}

void DCTerrain::_update_debug_seams() {
    // Create or reuse debug mesh node
    if (!debug_mesh_node_) {
        debug_mesh_node_ = memnew(MeshInstance3D);
        add_child(debug_mesh_node_);
    }

    Ref<ImmediateMesh> im;
    im.instantiate();

    // Wireframe material — bright yellow, unshaded, always on top
    Ref<StandardMaterial3D> mat;
    mat.instantiate();
    mat->set_shading_mode(BaseMaterial3D::SHADING_MODE_UNSHADED);
    mat->set_albedo(Color(1.0f, 1.0f, 0.0f, 0.8f));
    mat->set_transparency(BaseMaterial3D::TRANSPARENCY_ALPHA);
    mat->set_flag(BaseMaterial3D::FLAG_DISABLE_DEPTH_TEST, true);

    im->surface_begin(Mesh::PRIMITIVE_LINES, mat);

    auto leaves = octree_clipmap_.get_leaves();
    for (auto* node : leaves) {
        if (node->state != OctreeClipmapNode::ACTIVE) continue;

        float cs = cell_world_size(node->lod_level, voxel_size_, 32);
        float x0 = node->position.x * voxel_size_;
        float y0 = node->position.y * voxel_size_;
        float z0 = node->position.z * voxel_size_;
        float x1 = x0 + cs;
        float y1 = y0 + cs;
        float z1 = z0 + cs;

        // Draw 12 edges of the cell AABB
        // Bottom face
        im->surface_add_vertex(Vector3(x0, y0, z0)); im->surface_add_vertex(Vector3(x1, y0, z0));
        im->surface_add_vertex(Vector3(x1, y0, z0)); im->surface_add_vertex(Vector3(x1, y0, z1));
        im->surface_add_vertex(Vector3(x1, y0, z1)); im->surface_add_vertex(Vector3(x0, y0, z1));
        im->surface_add_vertex(Vector3(x0, y0, z1)); im->surface_add_vertex(Vector3(x0, y0, z0));
        // Top face
        im->surface_add_vertex(Vector3(x0, y1, z0)); im->surface_add_vertex(Vector3(x1, y1, z0));
        im->surface_add_vertex(Vector3(x1, y1, z0)); im->surface_add_vertex(Vector3(x1, y1, z1));
        im->surface_add_vertex(Vector3(x1, y1, z1)); im->surface_add_vertex(Vector3(x0, y1, z1));
        im->surface_add_vertex(Vector3(x0, y1, z1)); im->surface_add_vertex(Vector3(x0, y1, z0));
        // Verticals
        im->surface_add_vertex(Vector3(x0, y0, z0)); im->surface_add_vertex(Vector3(x0, y1, z0));
        im->surface_add_vertex(Vector3(x1, y0, z0)); im->surface_add_vertex(Vector3(x1, y1, z0));
        im->surface_add_vertex(Vector3(x1, y0, z1)); im->surface_add_vertex(Vector3(x1, y1, z1));
        im->surface_add_vertex(Vector3(x0, y0, z1)); im->surface_add_vertex(Vector3(x0, y1, z1));
    }

    im->surface_end();
    debug_mesh_node_->set_mesh(im);
}

void DCTerrain::_update_debug_clipmap() {
    if (!debug_clipmap_node_) {
        debug_clipmap_node_ = memnew(MeshInstance3D);
        auto mat = Ref<StandardMaterial3D>();
        mat.instantiate();
        mat->set_shading_mode(BaseMaterial3D::SHADING_MODE_UNSHADED);
        mat->set_flag(BaseMaterial3D::FLAG_ALBEDO_FROM_VERTEX_COLOR, true);
        mat->set_transparency(BaseMaterial3D::TRANSPARENCY_ALPHA);
        mat->set_flag(BaseMaterial3D::FLAG_DISABLE_DEPTH_TEST, true);
        debug_clipmap_node_->set_material_override(mat);
        add_child(debug_clipmap_node_);
    }

    Ref<ImmediateMesh> im;
    im.instantiate();

    // Per-LOD colors for wireframe boxes
    static const Color lod_colors[] = {
        Color(1,1,1,0.9f), Color(0,1,0,0.8f), Color(0,0.5f,1,0.7f),
        Color(1,1,0,0.6f), Color(1,0,1,0.5f), Color(0,1,1,0.5f),
        Color(1,0.5f,0,0.4f), Color(0.5f,0,1,0.4f), Color(1,0,0,0.3f),
        Color(0.5f,1,0,0.3f), Color(0,0.5f,0.5f,0.3f), Color(1,0.5f,0.5f,0.3f)
    };

    // Single surface with vertex colors — avoids MAX_MESH_SURFACES limit
    im->surface_begin(Mesh::PRIMITIVE_LINES);

    auto leaves = octree_clipmap_.get_leaves();
    for (auto* node : leaves) {
        if (node->state != OctreeClipmapNode::ACTIVE) continue;
        if (node->triangle_count == 0) continue;  // skip empty air chunks

        int lod = node->lod_level;
        Color col = lod_colors[lod % 12];

        float lod_voxel = lod_voxel_size(voxel_size_, lod);
        int nominal = 12;
        float nominal_ws = static_cast<float>(nominal) * lod_voxel;

        float x0 = node->position.x * voxel_size_;
        float y0 = node->position.y * voxel_size_;
        float z0 = node->position.z * voxel_size_;
        float x1 = x0 + nominal_ws;
        float y1 = y0 + nominal_ws;
        float z1 = z0 + nominal_ws;

        // 12 edges of the cell AABB, all with same vertex color
        im->surface_set_color(col);
        im->surface_add_vertex(Vector3(x0,y0,z0)); im->surface_add_vertex(Vector3(x1,y0,z0));
        im->surface_add_vertex(Vector3(x1,y0,z0)); im->surface_add_vertex(Vector3(x1,y0,z1));
        im->surface_add_vertex(Vector3(x1,y0,z1)); im->surface_add_vertex(Vector3(x0,y0,z1));
        im->surface_add_vertex(Vector3(x0,y0,z1)); im->surface_add_vertex(Vector3(x0,y0,z0));
        im->surface_add_vertex(Vector3(x0,y1,z0)); im->surface_add_vertex(Vector3(x1,y1,z0));
        im->surface_add_vertex(Vector3(x1,y1,z0)); im->surface_add_vertex(Vector3(x1,y1,z1));
        im->surface_add_vertex(Vector3(x1,y1,z1)); im->surface_add_vertex(Vector3(x0,y1,z1));
        im->surface_add_vertex(Vector3(x0,y1,z1)); im->surface_add_vertex(Vector3(x0,y1,z0));
        im->surface_add_vertex(Vector3(x0,y0,z0)); im->surface_add_vertex(Vector3(x0,y1,z0));
        im->surface_add_vertex(Vector3(x1,y0,z0)); im->surface_add_vertex(Vector3(x1,y1,z0));
        im->surface_add_vertex(Vector3(x1,y0,z1)); im->surface_add_vertex(Vector3(x1,y1,z1));
        im->surface_add_vertex(Vector3(x0,y0,z1)); im->surface_add_vertex(Vector3(x0,y1,z1));
    }

    im->surface_end();
    debug_clipmap_node_->set_mesh(im);
}

void DCTerrain::_spawn_workers() {
    // Auto-scale worker count to available CPU cores, capped at 8.
    // Higher counts (tested 14 on a 16-core box) actually hurt
    // frame times: workers complete meshes faster than the main
    // thread can build their collision trimeshes, leaving a
    // backlog that produces frame hitches > 100ms.
    // The real ceiling is main-thread downstream (collision + upload),
    // not worker compute. Until that's parallelized too, 8 workers
    // is the sweet spot.
    int cpu_count = OS::get_singleton()->get_processor_count();
    int worker_count = std::clamp(cpu_count - 2, 2, 8);
    if (debug_logging_) {
        UtilityFunctions::print("DCTerrain: spawning ", worker_count,
            " workers (", cpu_count, " logical cores detected)");
    }
    for (int i = 0; i < worker_count; i++) {
        auto worker = std::make_unique<DCWorkerCPU>();
        worker->set_generation(generation_);
        // Always pass GPU thread pointer. submit() checks
        // gpu_available_ atomically — workers fall back to CPU
        // until GPU init completes, then use GPU automatically.
        if (gpu_thread_) {
            worker->set_gpu_thread(gpu_thread_.get());
        }
        worker->start(i);
        workers_.push_back(std::move(worker));
    }
}

void DCTerrain::_notification(int p_what) {
    switch (p_what) {
        case NOTIFICATION_ENTER_TREE: {
            // Single shared worker is only required when GPU is enabled — multiple
            // RenderingDevices in the editor crash. CPU-only workers are safe to run
            // per-instance in either mode, and 4 workers vs 1 is a 4x throughput win
            // (critical when CLI scene-launch reports is_editor_hint=true).
            bool force_shared = Engine::get_singleton()->is_editor_hint() && config_.use_gpu;
            fprintf(stderr, "[DCTerrain] ENTER_TREE editor_hint=%d use_gpu=%d force_shared=%d prev_workers_size=%d prev_using_editor_worker=%d s_editor_refcount=%d\n",
                (int)Engine::get_singleton()->is_editor_hint(),
                (int)config_.use_gpu,
                (int)force_shared,
                (int)workers_.size(),
                (int)using_editor_worker_,
                s_editor_refcount_);
            fflush(stderr);
            if (force_shared) {
                using_editor_worker_ = true;
                if (s_editor_refcount_ == 0) {
                    s_editor_worker_ = std::make_unique<DCWorkerCPU>();
                    s_editor_worker_->set_generation(generation_);
                    s_editor_worker_->start(0);
                }
                s_editor_refcount_++;
            } else {
                // Game mode: 4 dedicated workers per instance
                using_editor_worker_ = false;
                if (workers_.empty()) {
                    // Start GPU density fill thread if GPU is enabled
                    if (!gpu_thread_ && config_.use_gpu) {
                        gpu_thread_ = std::make_unique<DCGpuThread>();
                        gpu_thread_->start();
                    }
                    // ⛔ DO NOT SPAWN WORKERS INTO A GPU THAT IS STILL COMING UP.
                    //
                    // start() only spawns the thread; compute_.initialize() then runs
                    // create_local_rendering_device() — a whole second VkDevice. With
                    // 8 workers already saturating 16 cores that measured 30.2 s; with
                    // the cores free it is 4.17 s. A 7.2x difference, and it is pure
                    // CPU starvation (2026-09-03).
                    //
                    // While the GPU is down submit() returns false and every chunk
                    // falls back to the CPU MESHER — a different dual-contouring
                    // implementation from qef_solve.glsl. Measured: 47 solid chunks
                    // came from dc_contour.cpp and then stayed in the world next to
                    // GPU-meshed neighbours. Waiting takes that to 0.
                    //
                    // ⚠ DEFERRED, NOT BLOCKED. A first cut blocked here and cost
                    // 4.6 s of frozen map load (0.81 s -> 5.44 s) — the right total,
                    // the wrong shape. _process() spawns them the frame the GPU is
                    // ready, so load stays responsive and nothing meshes early.
                    if (gpu_thread_ && !gpu_thread_->is_available() &&
                        !getenv("DC_NO_GPU_FIRST")) {
                        workers_deferred_ = true;
                        workers_deferred_since_ = std::chrono::steady_clock::now();
                    } else {
                        _spawn_workers();
                    }
                } else {
                    generation_++;
                    _set_worker_generation(generation_);
                }
            }

            // Re-initialize octree if it was cleared by EXIT_TREE.
            if (octree_clipmap_.get_stats().leaf_count == 0) {
                _propagate_config();
                octree_clipmap_.base_voxel_size = voxel_size_;
                octree_clipmap_.max_lod_levels = octree_depth_;
    // seamless_lod: force all chunks to split to LOD 0 by using a huge lod_factor.
    // This makes the distance threshold always trigger splits regardless of distance.
    octree_clipmap_.lod_factor = seamless_lod_ ? 1000.0f : 2.0f;
                octree_clipmap_.lod_mode = lod_mode_;
                octree_clipmap_.on_node_free = _on_node_free;
                octree_clipmap_.on_mesh_free = _on_mesh_free;
                octree_clipmap_.on_collision_free = _on_collision_free;
                octree_clipmap_.on_node_free_userdata = this;
                octree_clipmap_.initialize();
                debug_frames_ = 0;
            }
        } break;

        case NOTIFICATION_EXIT_TREE: {
            // Drain pending results so they don't reference stale node pointers.
            {
                CellResult discard;
                if (using_editor_worker_) {
                    if (s_editor_worker_) {
                        while (s_editor_worker_->poll_result(discard)) {}
                    }
                } else {
                    for (auto& worker : workers_) {
                        while (worker->poll_result(discard)) {}
                    }
                }
            }

            // Release shared editor worker ref — but KEEP worker alive.
            // In editor mode, scenes constantly enter/exit tree during tab switching.
            // Destroying the worker on refcount 0 causes it to be recreated for every
            // scene, losing GPU state. Keep it alive for the entire editor session.
            if (using_editor_worker_) {
                s_editor_refcount_--;
                if (s_editor_refcount_ <= 0) {
                    // Don't stop the worker — it stays alive and idle, ready for next scene.
                    s_editor_refcount_ = 0;
                }
                using_editor_worker_ = false;
            }

            // Free all node RIDs
            auto leaves = octree_clipmap_.get_leaves();
            for (auto* node : leaves) {
                _free_node_rids(node);
            }

            // Flush dealloc queue
            for (auto& entry : dealloc_queue_) {
                RenderingServer* rs = RenderingServer::get_singleton();
                if (rs) {
                    if (entry.rs_instance.is_valid()) rs->free_rid(entry.rs_instance);
                    if (entry.seam_mesh_instance.is_valid()) rs->free_rid(entry.seam_mesh_instance);
                }
                PhysicsServer3D* ps = PhysicsServer3D::get_singleton();
                if (ps) {
                    if (entry.collision_body.is_valid()) ps->free_rid(entry.collision_body);
                    if (entry.collision_shape.is_valid()) ps->free_rid(entry.collision_shape);
                }
                entry.mesh.unref();
            }
            dealloc_queue_.clear();
            collision_trimesh_.clear();
            seam_pending_.clear();
            node_meshes_.clear();
            seam_meshes_.clear();

            octree_clipmap_.clear();

            // Reset debug counter so diagnostics print again on re-enter
            debug_frames_ = 0;
        } break;

        case NOTIFICATION_TRANSFORM_CHANGED: {
            // Cells are placed in world space, not relative to this node.
        } break;
    }
}

// ---------------------------------------------------------------------------
// rebuild() — public, callable from GDScript. Clears all cells and triggers
// full regeneration.
// ---------------------------------------------------------------------------
void DCTerrain::rebuild() {
    if (!is_inside_tree()) return;

    UtilityFunctions::print("DCTerrain::rebuild() gen=", generation_ + 1,
        " has_workers=", _has_workers(),
        " generator=", generator_resource_.is_valid() ? "resource" : "built-in",
        " gpu_shader=", (generator_ptr_ && generator_ptr_->has_gpu_shader()) ? "yes" : "no");

    // Bump generation — any in-flight worker results will be discarded
    generation_++;

    // Tell workers to skip requests from old generation BEFORE modifying density.
    _set_worker_generation(generation_);

    // Drain all in-flight worker results before freeing nodes
    _drain_all_workers();

    // Reset stuck pre-mesh entries (stale generation → results discarded)
    {
        auto& cache = octree_clipmap_.pre_mesh_cache_ref();
        for (auto& [morton, entry] : cache) {
            if (entry.state == OctreeClipmapNode::MESH_GENERATING) {
                entry.state = OctreeClipmapNode::MESH_PENDING;
            }
        }
    }

    // Free all node RIDs
    auto leaves = octree_clipmap_.get_leaves();
    for (auto* node : leaves) {
        _free_node_rids(node);
    }

    // Flush dealloc queue
    for (auto& entry : dealloc_queue_) {
        RenderingServer* rs = RenderingServer::get_singleton();
        if (rs) {
            if (entry.rs_instance.is_valid()) rs->free_rid(entry.rs_instance);
            if (entry.seam_mesh_instance.is_valid()) rs->free_rid(entry.seam_mesh_instance);
        }
        PhysicsServer3D* ps = PhysicsServer3D::get_singleton();
        if (ps && entry.collision_body.is_valid()) ps->free_rid(entry.collision_body);
        entry.mesh.unref();
    }
    dealloc_queue_.clear();
    collision_trimesh_.clear();
    seam_pending_.clear();
    node_meshes_.clear();
    seam_meshes_.clear();

    // Reconfigure and reinitialize the octree
    _propagate_config();
    octree_clipmap_.base_voxel_size = voxel_size_;
    octree_clipmap_.max_lod_levels = octree_depth_;
    // seamless_lod: force all chunks to split to LOD 0 by using a huge lod_factor.
    // This makes the distance threshold always trigger splits regardless of distance.
    octree_clipmap_.lod_factor = seamless_lod_ ? 1000.0f : 2.0f;
    octree_clipmap_.initialize();

    // Reset debug counter and frame counter (burst split budget re-activates)
    debug_frames_ = 0;
    frame_counter_ = 0;
}

// ---------------------------------------------------------------------------
// warmup_shaders() — pre-compile all LOD material pipelines to avoid stutter.
// Creates a tiny triangle mesh, renders it once with each LOD material, then
// frees the mesh. Must be called after terrain_shader_ is created (after
// rebuild() or _enter_tree()).
// ---------------------------------------------------------------------------
void DCTerrain::warmup_shaders() {
    if (terrain_shader_.is_null()) return;

    static const Color LOD_COLORS[] = {
        Color(0.2f, 0.8f, 0.2f), Color(0.2f, 0.6f, 0.8f),
        Color(0.8f, 0.8f, 0.2f), Color(0.8f, 0.4f, 0.1f),
        Color(0.8f, 0.2f, 0.2f), Color(0.6f, 0.2f, 0.8f),
        Color(0.2f, 0.2f, 0.8f), Color(0.8f, 0.6f, 0.8f),
        Color(0.4f, 0.4f, 0.4f), Color(1.0f, 1.0f, 1.0f),
        Color(0.5f, 0.3f, 0.1f), Color(0.1f, 0.5f, 0.5f),
    };

    RenderingServer *rs = RenderingServer::get_singleton();
    if (!rs || !is_inside_tree()) return;

    // Build a tiny triangle mesh for warmup
    Ref<ArrayMesh> warmup_mesh;
    warmup_mesh.instantiate();
    PackedVector3Array verts;
    verts.push_back(Vector3(0, 0, 0));
    verts.push_back(Vector3(0.01f, 0, 0));
    verts.push_back(Vector3(0, 0.01f, 0));
    PackedVector3Array normals;
    normals.push_back(Vector3(0, 0, 1));
    normals.push_back(Vector3(0, 0, 1));
    normals.push_back(Vector3(0, 0, 1));
    Array arrays;
    arrays.resize(Mesh::ARRAY_MAX);
    arrays[Mesh::ARRAY_VERTEX] = verts;
    arrays[Mesh::ARRAY_NORMAL] = normals;
    warmup_mesh->add_surface_from_arrays(Mesh::PRIMITIVE_TRIANGLES, arrays);

    // Create a temporary instance at the camera position
    RID scenario = get_world_3d()->get_scenario();
    RID instance = rs->instance_create();
    rs->instance_set_scenario(instance, scenario);
    rs->instance_set_base(instance, warmup_mesh->get_rid());

    int depth = octree_depth_ > 0 ? octree_depth_ : 5;
    int count = depth < MAX_LOD_MATERIALS ? depth : MAX_LOD_MATERIALS;

    for (int lod = 0; lod < count; lod++) {
        if (lod_materials_[lod].is_null()) {
            lod_materials_[lod].instantiate();
            lod_materials_[lod]->set_shader(terrain_shader_);
            _apply_texture_params(lod_materials_[lod]);
            Color c = LOD_COLORS[lod < (int)(sizeof(LOD_COLORS)/sizeof(LOD_COLORS[0]))
                        ? lod : (int)(sizeof(LOD_COLORS)/sizeof(LOD_COLORS[0])) - 1];
            lod_materials_[lod]->set_shader_parameter("use_lod_color", debug_lod_colors_);
            lod_materials_[lod]->set_shader_parameter("lod_color", Vector3(c.r, c.g, c.b));
            lod_materials_[lod]->set_shader_parameter("fade_t", 1.0f);
            lod_materials_[lod]->set_shader_parameter("normal_perturbation", normal_perturbation_);
        }
        warmup_mesh->surface_set_material(0, lod_materials_[lod]);
        // Force the RenderingServer to process this material+mesh combination
        rs->instance_set_base(instance, warmup_mesh->get_rid());
    }

    // Also warmup a fading variant (fade_t < 1.0)
    Ref<ShaderMaterial> fade_mat;
    fade_mat.instantiate();
    fade_mat->set_shader(terrain_shader_);
    _apply_texture_params(fade_mat);
    fade_mat->set_shader_parameter("fade_t", 0.5f);
    fade_mat->set_shader_parameter("use_lod_color", false);
    fade_mat->set_shader_parameter("normal_perturbation", normal_perturbation_);
    warmup_mesh->surface_set_material(0, fade_mat);
    rs->instance_set_base(instance, warmup_mesh->get_rid());

    // Clean up — the instance rendered this frame, pipelines are queued for compilation
    rs->free_rid(instance);

    UtilityFunctions::print("[DCTerrain] Shader warmup: ", count, " LOD materials + 1 fade variant precompiled");
}

// ---------------------------------------------------------------------------
// _propagate_config() — update density config
// ---------------------------------------------------------------------------
void DCTerrain::_propagate_config() {
    config_.voxel_size = voxel_size_;
    // Generators don't apply edits — workers do with per-chunk culled lists.
    // Strip edits from the generator config to avoid O(all_edits) per sample.
    DensityConfig gen_config = config_;
    gen_config.edits.reset();
    density_.configure(gen_config);

    // Mirror config to the built-in noise generator
    noise_generator_.density.configure(gen_config);

    // Resolve active generator pointer:
    // - If a Resource is assigned, use its native generator
    // - Otherwise fall back to the built-in noise_generator_
    if (generator_resource_.is_valid()) {
        DCGenerator* native = generator_resource_->get_native_generator();
        if (native) {
            generator_ptr_ = native;
            warned_generator_fallback_ = false;
            using_builtin_generator_ = false;
        } else {
            _fallback_to_builtin("_propagate_config");
        }
    } else if (!generator_ptr_) {
        generator_ptr_ = &noise_generator_;
    }
}

// A GENERATOR RESOURCE THAT CANNOT PROVIDE AN EVALUATOR IS A CONFIGURATION
// ERROR, and it used to be a silent one.
//
// The built-in noise generator produces a perfectly plausible landscape, so the
// substitution shows up as "the terrain is wrong somehow" rather than as
// anything pointing at the generator. Say it once per transition -- these paths
// run on every config propagation, and a message per frame is a message nobody
// reads. hyper-games #64 NEXT 4.
void DCTerrain::_fallback_to_builtin(const char* where) {
    generator_ptr_ = &noise_generator_;
    using_builtin_generator_ = true;
    if (warned_generator_fallback_) {
        return;
    }
    warned_generator_fallback_ = true;
    UtilityFunctions::push_error(
        "DCTerrain (", where, "): the assigned generator resource (",
        generator_resource_.is_valid() ? generator_resource_->get_class() : String("<null>"),
        ") returned no native generator, so the BUILT-IN NOISE generator is in "
        "use. The terrain you see is not the one this resource describes. A "
        "DCTerrainGenerator subclass must override get_native_generator().");
}

void DCTerrain::_drain_workers() {
    // Wait for any workers that are mid-_process_cell() to finish.
    // This is critical: workers hold a pointer to density_ and read
    // config_.csg_primitives during evaluate(). We must wait for them
    // to complete before _propagate_config() replaces the config.
    _drain_all_workers();
}

// ---------------------------------------------------------------------------
// _upload_cell() — runs on main thread. Creates ArrayMesh and registers it
// with RenderingServer directly (no scene tree nodes).
// ---------------------------------------------------------------------------
void DCTerrain::_upload_cell(CellResult& result) {
    auto* node = result.node;
    if (!node) return;

    // If this node was split while generation was in-flight,
    // it's now an internal node. Discard — don't create mesh for internal nodes.
    if (!node->is_leaf()) {
        return;
    }

    // Always store geometric error for content-aware LOD
    node->geometric_error = result.geometric_error;

    if (result.mesh_data.positions.empty() || result.mesh_data.indices.empty()) {
        if (node->is_leaf()) {
            node->state = OctreeClipmapNode::ACTIVE;
            node->mesh_state = OctreeClipmapNode::MESH_UP_TO_DATE;
            node->triangle_count = 0;
            node->active_voxel_count = 0;
            node->content_known = true;
            node->fade_t = 1.0f;       // nothing to fade, mark complete
            node->fade_target = 1.0f;
        }
        return;
    }

    auto ms = [](auto t0, auto t1) {
        return std::chrono::duration<double, std::milli>(t1 - t0).count();
    };
    auto t_start = std::chrono::high_resolution_clock::now();

    const DCMeshData& mesh_data = result.mesh_data;

    // Build ArrayMesh from contour data
    int vert_count = static_cast<int>(mesh_data.positions.size() / 3);
    int idx_count = static_cast<int>(mesh_data.indices.size());

    // Chunk-local vertices: subtract chunk origin so vertices are small numbers
    // (0 to ~grid_size * voxel), then use Transform3D to position in world space.
    // This prevents float precision loss at large distances (Cepero's approach).
    float lod_voxel = lod_voxel_size(voxel_size_, node->lod_level);
    float overlap_ws = 2.0f * lod_voxel;  // overlap = 2 voxels (matches 16-grid)
    float chunk_origin_x = node->position.x * voxel_size_ - overlap_ws;
    float chunk_origin_y = node->position.y * voxel_size_ - overlap_ws;
    float chunk_origin_z = node->position.z * voxel_size_ - overlap_ws;

    PackedVector3Array vertices;
    vertices.resize(vert_count);
    Vector3* vptr = vertices.ptrw();
    for (int i = 0; i < vert_count; i++) {
        vptr[i] = Vector3(
            mesh_data.positions[i * 3 + 0] - chunk_origin_x,
            mesh_data.positions[i * 3 + 1] - chunk_origin_y,
            mesh_data.positions[i * 3 + 2] - chunk_origin_z);
    }

    PackedVector3Array normals_array;
    normals_array.resize(vert_count);
    Vector3* nptr = normals_array.ptrw();
    for (int i = 0; i < vert_count; i++) {
        nptr[i] = Vector3(
            mesh_data.normals[i * 3 + 0],
            mesh_data.normals[i * 3 + 1],
            mesh_data.normals[i * 3 + 2]);
    }

    PackedInt32Array indices_array;
    indices_array.resize(idx_count);
    int32_t* iptr = indices_array.ptrw();
    for (int i = 0; i < idx_count; i++) {
        iptr[i] = mesh_data.indices[i];
    }

    // Route ② texturing: per-vertex material_id → ARRAY_COLOR. R = material_id/255
    // (recover as round(R*255)); A = 1 on textured brush faces, 0 on organic
    // terrain (→ shader stays triplanar). Empty on the CPU/organic mesh path.
    PackedColorArray colors_array;
    bool has_materials = (static_cast<int>(mesh_data.materials.size()) == vert_count);
    if (has_materials) {
        colors_array.resize(vert_count);
        Color* cptr = colors_array.ptrw();
        for (int i = 0; i < vert_count; i++) {
            float m = mesh_data.materials[i];
            cptr[i] = Color(m / 255.0f, 0.0f, 0.0f, m > 0.5f ? 1.0f : 0.0f);
        }
    }

    // Stage-2 per-face texturing: two per-vertex projection candidates → ARRAY_CUSTOM0
    // (RG_FLOAT). Each is a small integer (< 2^24), exact as a float; the shader picks
    // between them per-fragment by normal and looks the winner up in the projection table.
    // 0 = none. Two candidates let a sharp crease's two faces each texture correctly.
    PackedFloat32Array proj_array;
    bool has_proj = (static_cast<int>(mesh_data.proj.size()) == vert_count) &&
                    (static_cast<int>(mesh_data.proj2.size()) == vert_count);
    if (has_proj) {
        proj_array.resize(vert_count * 2);
        float* pptr = proj_array.ptrw();
        for (int i = 0; i < vert_count; i++) {
            pptr[i * 2 + 0] = mesh_data.proj[i];
            pptr[i * 2 + 1] = mesh_data.proj2[i];
        }
    }

    // CURVATURE + LOD VOXEL SIZE -> ARRAY_CUSTOM1 (RG_FLOAT).
    //
    // .x is curvature, from qef_solve's horizontal Laplacian of the density
    // grid. A separate channel rather than a third component of CUSTOM0: that
    // one is RG_FLOAT and every shader reading it expects two projection
    // candidates in .xy.
    //
    // .y is THIS CHUNK'S VOXEL SIZE, and it is here because a material has no
    // other way to know how coarse the geometry under it actually is. A shader
    // that wants to substitute a finer normal for an under-detailed mesh was
    // otherwise reduced to inferring it from the pixel footprint, which is a
    // proxy: it gets the boundary wrong whenever the camera climbs, because
    // distance-to-camera and chunk LOD stop agreeing. Comparing voxel size
    // against the footprint answers "is the mesh coarser than a pixel" exactly.
    //
    // WRITTEN UNCONDITIONALLY, even when curvature is unavailable. Gating the
    // whole channel on has_curv would leave .y at zero on any mesh without
    // curvature, and a consumer dividing by it would silently switch itself
    // off rather than fail -- the worst kind of wrong.
    PackedFloat32Array curv_array;
    bool has_curv = (static_cast<int>(mesh_data.curv.size()) == vert_count);
    curv_array.resize(vert_count * 2);
    {
        float* cvp = curv_array.ptrw();
        for (int i = 0; i < vert_count; i++) {
            cvp[i * 2 + 0] = has_curv ? mesh_data.curv[i] : 0.0f;
            cvp[i * 2 + 1] = lod_voxel;
        }
    }

    Array arrays;
    arrays.resize(Mesh::ARRAY_MAX);
    arrays[Mesh::ARRAY_VERTEX] = vertices;
    arrays[Mesh::ARRAY_NORMAL] = normals_array;
    if (has_materials) arrays[Mesh::ARRAY_COLOR] = colors_array;
    if (has_proj) arrays[Mesh::ARRAY_CUSTOM0] = proj_array;
    arrays[Mesh::ARRAY_CUSTOM1] = curv_array;
    arrays[Mesh::ARRAY_INDEX] = indices_array;

    auto t_arrays = std::chrono::high_resolution_clock::now();

    uint64_t surf_flags = 0;
    if (has_proj) surf_flags |= (uint64_t)Mesh::ARRAY_CUSTOM_RG_FLOAT << Mesh::ARRAY_FORMAT_CUSTOM0_SHIFT;
    surf_flags |= (uint64_t)Mesh::ARRAY_CUSTOM_RG_FLOAT << Mesh::ARRAY_FORMAT_CUSTOM1_SHIFT;

    Ref<ArrayMesh> arr_mesh;
    arr_mesh.instantiate();
    arr_mesh->add_surface_from_arrays(Mesh::PRIMITIVE_TRIANGLES, arrays, Array(), Dictionary(), surf_flags);

    auto t_surface = std::chrono::high_resolution_clock::now();

    // Per-LOD material with distinct debug colors
    static const Color LOD_COLORS[] = {
        Color(0.2f, 0.8f, 0.2f),  // LOD0: green (finest)
        Color(0.2f, 0.6f, 0.8f),  // LOD1: cyan
        Color(0.8f, 0.8f, 0.2f),  // LOD2: yellow
        Color(0.8f, 0.4f, 0.1f),  // LOD3: orange
        Color(0.8f, 0.2f, 0.2f),  // LOD4: red
        Color(0.6f, 0.2f, 0.8f),  // LOD5: purple
        Color(0.2f, 0.2f, 0.8f),  // LOD6: blue
        Color(0.8f, 0.6f, 0.8f),  // LOD7: pink
        Color(0.4f, 0.4f, 0.4f),  // LOD8: gray
        Color(1.0f, 1.0f, 1.0f),  // LOD9: white
        Color(0.5f, 0.3f, 0.1f),  // LOD10: brown
        Color(0.1f, 0.5f, 0.5f),  // LOD11: teal
    };
    int lod = node->lod_level;
    if (lod < 0) lod = 0;
    if (lod >= MAX_LOD_MATERIALS) lod = MAX_LOD_MATERIALS - 1;

    // Clear any active fade override — new mesh replaces old fade state
    fade_overrides_.erase(node);

    // Unified ShaderMaterial for all chunks (settled fade_t=1.0).
    if (lod_materials_[lod].is_null()) {
        lod_materials_[lod].instantiate();
        lod_materials_[lod]->set_shader(terrain_shader_);
            _apply_texture_params(lod_materials_[lod]);
        Color c = debug_lod_colors_ ? LOD_COLORS[lod < (int)(sizeof(LOD_COLORS)/sizeof(LOD_COLORS[0]))
                        ? lod : (int)(sizeof(LOD_COLORS)/sizeof(LOD_COLORS[0])) - 1] : terrain_color_;
        lod_materials_[lod]->set_shader_parameter("use_lod_color", debug_lod_colors_);
        lod_materials_[lod]->set_shader_parameter("lod_color", Vector3(c.r, c.g, c.b));
        lod_materials_[lod]->set_shader_parameter("fade_t", 1.0f);
        lod_materials_[lod]->set_shader_parameter("normal_perturbation", normal_perturbation_);
    }
    arr_mesh->surface_set_material(0, custom_material_.is_valid() ? custom_material_ : Ref<Material>(lod_materials_[lod]));

    // If this node needs fading in, create per-chunk override immediately so
    // the very first rendered frame uses the correct fade_t (0.0 = invisible).
    // Without this, the fade animation loop hasn't run yet for this node,
    // so it would render at fade_t=1.0 (opaque) for one frame — visible pop-in.
    // Skip in BLOCKING_SWAP mode — no fading, visibility is binary.
    if (lod_mode_ != LOD_BLOCKING_SWAP && node->fade_t < 0.99f) {
        Ref<ShaderMaterial> mat;
        mat.instantiate();
        mat->set_shader(terrain_shader_);
                    _apply_texture_params(mat);
        Color c = debug_lod_colors_ ? LOD_COLORS[lod < (int)(sizeof(LOD_COLORS)/sizeof(LOD_COLORS[0]))
                        ? lod : (int)(sizeof(LOD_COLORS)/sizeof(LOD_COLORS[0])) - 1] : terrain_color_;
        mat->set_shader_parameter("use_lod_color", debug_lod_colors_);
                    mat->set_shader_parameter("lod_color", Vector3(c.r, c.g, c.b));
        mat->set_shader_parameter("fade_t", node->fade_t);
        mat->set_shader_parameter("normal_perturbation", normal_perturbation_);
        arr_mesh->surface_set_material(0, mat);
        fade_overrides_[node] = mat;
    }

    // Keep ArrayMesh ref alive (prevent GC while RS holds the RID)
    node_meshes_[node] = arr_mesh;

    // --- RenderingServer direct instance (no scene tree overhead) ---
    RenderingServer* rs = RenderingServer::get_singleton();

    RID rs_mesh = arr_mesh->get_rid();

    // Clean up any existing mesh instance (e.g., parent mesh kept for crack fill,
    // now being replaced after merge + re-generation)
    if (node->mesh_instance.is_valid()) {
        rs->free_rid(node->mesh_instance);
        node->mesh_instance = RID();
    }

    // Create a visual instance and bind it to the mesh
    node->mesh_instance = rs->instance_create();
    rs->instance_set_base(node->mesh_instance, rs_mesh);

    // Attach to this node's world scenario (required for rendering)
    Ref<World3D> world = get_world_3d();
    if (world.is_valid()) {
        rs->instance_set_scenario(node->mesh_instance, world->get_scenario());
    }

    // Position chunk in world space via Transform3D (vertices are chunk-local).
    Transform3D chunk_xform;
    chunk_xform.origin = Vector3(chunk_origin_x, chunk_origin_y, chunk_origin_z);
    rs->instance_set_transform(node->mesh_instance, chunk_xform);
    node->mesh_origin[0] = chunk_xform.origin.x;
    node->mesh_origin[1] = chunk_xform.origin.y;
    node->mesh_origin[2] = chunk_xform.origin.z;

    // AABB in local space (relative to chunk origin)
    float cs = 32.0f * lod_voxel;  // full grid extent (includes overlap for conservative culling)
    AABB custom_aabb(Vector3(0, 0, 0), Vector3(cs, cs, cs));
    rs->instance_set_custom_aabb(node->mesh_instance, custom_aabb);

    rs->instance_set_visible(node->mesh_instance, true);
    // Note: fade_t shader is applied in _process() animation loop via
    // per-chunk ShaderMaterial on the ArrayMesh surface — NOT here.

    auto t_rs = std::chrono::high_resolution_clock::now();

    // --- Physics: queue collision for near cells within LOD budget ---
    // Free existing collision body if chunk was re-meshed (CSG edit, LOD change).
    // Without this, process_queue sees the old body as valid and skips the update.
    if (node->collision_body.is_valid()) {
        PhysicsServer3D* ps = PhysicsServer3D::get_singleton();
        if (ps) {
            if (node->collision_shape.is_valid()) ps->free_rid(node->collision_shape);
            ps->free_rid(node->collision_body);
        }
        node->collision_body = RID();
        node->collision_shape = RID();
    }
    if (collision_mode_ != COLLISION_SDF &&
        collision_trimesh_.should_enqueue(node->lod_level, !result.mesh_data.indices.empty())) {
        int tri_count = static_cast<int>(result.mesh_data.indices.size()) / 3;
        PackedVector3Array faces;
        faces.resize(tri_count * 3);
        Vector3* fptr = faces.ptrw();
        const float* pos = result.mesh_data.positions.data();
        const int* idx = result.mesh_data.indices.data();
        // Make faces chunk-local (same as visual mesh) — body transform positions them
        for (int t = 0; t < tri_count; t++) {
            int i0 = idx[t*3+0], i1 = idx[t*3+1], i2 = idx[t*3+2];
            fptr[t*3+0] = Vector3(pos[i0*3] - chunk_origin_x, pos[i0*3+1] - chunk_origin_y, pos[i0*3+2] - chunk_origin_z);
            fptr[t*3+1] = Vector3(pos[i1*3] - chunk_origin_x, pos[i1*3+1] - chunk_origin_y, pos[i1*3+2] - chunk_origin_z);
            fptr[t*3+2] = Vector3(pos[i2*3] - chunk_origin_x, pos[i2*3+1] - chunk_origin_y, pos[i2*3+2] - chunk_origin_z);
        }
        DCCollisionTrimesh::CollisionRequest creq;
        creq.node = node;
        creq.node_generation = node->generation;
        creq.faces = std::move(faces);
        collision_trimesh_.enqueue(std::move(creq));
    }

    auto t_physics = std::chrono::high_resolution_clock::now();

    // Set node to ACTIVE and store stats
    node->state = OctreeClipmapNode::ACTIVE;
    node->mesh_state = OctreeClipmapNode::MESH_UP_TO_DATE;
    node->triangle_count = result.triangle_count;
    node->active_voxel_count = result.active_voxel_count;
    node->content_known = true;

    // Children cast shadows immediately — both parent and child cast during
    // fade transition. Minor double-shadow is less noticeable than black
    // flickering from having no shadow casters during the transition.

    // Store seam nodes
    if (!result.seam_nodes.empty()) {
        // Free any previous seam allocation
        if (node->seam_nodes) {
            delete[] node->seam_nodes;
        }
        node->seam_nodes = new SeamNodeInfo[result.seam_nodes.size()];
        memcpy(node->seam_nodes, result.seam_nodes.data(),
               result.seam_nodes.size() * sizeof(SeamNodeInfo));
        node->num_seam_nodes = static_cast<int>(result.seam_nodes.size());
    }

    // Queue for seam mesh generation (needs neighbors to be ACTIVE too)
    seam_pending_.push_back({node, node->generation});

    // RT/CLAS collection build removed — re-add after CPU MVP

    auto t_end = std::chrono::high_resolution_clock::now();

    // --- Timing breakdown ---
    double total = ms(t_start, t_end);
    if (debug_logging_ && total > 1.0) {
        fprintf(stderr, "UPLOAD LOD%d (%d,%d,%d): arrays=%.1f surface=%.1f rs=%.1f physics=%.1f TOTAL=%.1fms  verts=%d tris=%d\n",
            node->lod_level,
            node->position.x, node->position.y, node->position.z,
            ms(t_start, t_arrays),
            ms(t_arrays, t_surface),
            ms(t_surface, t_rs),
            ms(t_rs, t_physics),
            total, vert_count, idx_count / 3);
    }
}

// ---------------------------------------------------------------------------
// _generate_seam_for_node() — builds seam mesh by collecting boundary nodes
// from 8 octant neighbors and running DC contouring on the temporary octree.
// ---------------------------------------------------------------------------
void DCTerrain::_generate_seam_for_node(OctreeClipmapNode* node) {
    // is_leaf() gate: never build a seam on a node that has split. An internal node
    // is still ACTIVE, so the state check alone lets a coarse seam leak on top of the
    // fine children (see the stale-filter comment). Second line of defense.
    if (!node || node->state != OctreeClipmapNode::ACTIVE || !node->is_leaf()) return;

    // Free any existing seam mesh for this node
    RenderingServer* rs = RenderingServer::get_singleton();
    if (rs && node->seam_mesh_instance.is_valid()) {
        rs->free_rid(node->seam_mesh_instance);
        node->seam_mesh_instance = RID();
    }

    static const int OFFSETS[8][3] = {
        {0,0,0}, {0,0,1}, {0,1,0}, {0,1,1},
        {1,0,0}, {1,0,1}, {1,1,0}, {1,1,1},
    };

    int voxel_span = static_cast<int>(lod_voxel_size(1.0f, node->lod_level) * octree_clipmap_.voxels_per_cell);
    int host_min[3] = { node->position.x, node->position.y, node->position.z };

    std::vector<SeamNeighborInfo> neighbors(8);

    for (int d = 0; d < 8; d++) {
        int nx = host_min[0] + OFFSETS[d][0] * voxel_span;
        int ny = host_min[1] + OFFSETS[d][1] * voxel_span;
        int nz = host_min[2] + OFFSETS[d][2] * voxel_span;

        auto* neighbor = octree_clipmap_.find_leaf_containing(nx, ny, nz);
        if (neighbor && neighbor->state == OctreeClipmapNode::ACTIVE &&
            neighbor->seam_nodes && neighbor->num_seam_nodes > 0) {
            int neighbor_span = static_cast<int>(lod_voxel_size(1.0f, neighbor->lod_level) * octree_clipmap_.voxels_per_cell);
            neighbors[d].seam_nodes = neighbor->seam_nodes;
            neighbors[d].num_seam_nodes = neighbor->num_seam_nodes;
            neighbors[d].cell_size = neighbor_span;
        }
    }

    DCMeshData seam_mesh;
    seam_generate_for_cell(host_min, voxel_span, octree_clipmap_.voxels_per_cell, neighbors, seam_mesh);

    if (!seam_mesh.positions.empty() && !seam_mesh.indices.empty()) {
        _upload_seam_mesh(node, seam_mesh);
    }
}

// ---------------------------------------------------------------------------
// _upload_seam_mesh() — uploads a seam mesh as a separate RS instance.
// ---------------------------------------------------------------------------
void DCTerrain::_upload_seam_mesh(OctreeClipmapNode* node, const DCMeshData& mesh_data) {
    int vert_count = static_cast<int>(mesh_data.positions.size() / 3);
    int idx_count = static_cast<int>(mesh_data.indices.size());

    // Seam vertices: make chunk-local (same as visual mesh)
    float seam_lod_voxel = lod_voxel_size(voxel_size_, node->lod_level);
    float seam_overlap = 1.0f * seam_lod_voxel;  // overlap = 1 voxel (matches 16-grid)
    float seam_ox = node->position.x * voxel_size_ - seam_overlap;
    float seam_oy = node->position.y * voxel_size_ - seam_overlap;
    float seam_oz = node->position.z * voxel_size_ - seam_overlap;

    PackedVector3Array vertices;
    vertices.resize(vert_count);
    Vector3* vptr = vertices.ptrw();
    for (int i = 0; i < vert_count; i++) {
        vptr[i] = Vector3(
            mesh_data.positions[i * 3 + 0] - seam_ox,
            mesh_data.positions[i * 3 + 1] - seam_oy,
            mesh_data.positions[i * 3 + 2] - seam_oz);
    }

    PackedVector3Array normals_array;
    normals_array.resize(vert_count);
    Vector3* nptr = normals_array.ptrw();
    for (int i = 0; i < vert_count; i++) {
        nptr[i] = Vector3(
            mesh_data.normals[i * 3 + 0],
            mesh_data.normals[i * 3 + 1],
            mesh_data.normals[i * 3 + 2]);
    }

    PackedInt32Array indices_array;
    indices_array.resize(idx_count);
    int32_t* iptr = indices_array.ptrw();
    for (int i = 0; i < idx_count; i++) {
        iptr[i] = mesh_data.indices[i];
    }

    Array arrays;
    arrays.resize(Mesh::ARRAY_MAX);
    arrays[Mesh::ARRAY_VERTEX] = vertices;
    arrays[Mesh::ARRAY_NORMAL] = normals_array;
    arrays[Mesh::ARRAY_INDEX] = indices_array;

    Ref<ArrayMesh> arr_mesh;
    arr_mesh.instantiate();
    arr_mesh->add_surface_from_arrays(Mesh::PRIMITIVE_TRIANGLES, arrays);

    // Use LOD0 material for seam meshes (seams bridge LOD boundaries)
    if (lod_materials_[0].is_valid()) {
        arr_mesh->surface_set_material(0, lod_materials_[0]);
    }

    RenderingServer* rs = RenderingServer::get_singleton();

    node->seam_mesh_instance = rs->instance_create();
    rs->instance_set_base(node->seam_mesh_instance, arr_mesh->get_rid());

    Ref<World3D> world = get_world_3d();
    if (world.is_valid()) {
        rs->instance_set_scenario(node->seam_mesh_instance, world->get_scenario());
    }

    // Position seam at chunk origin
    Transform3D seam_xform;
    seam_xform.origin = Vector3(seam_ox, seam_oy, seam_oz);
    rs->instance_set_transform(node->seam_mesh_instance, seam_xform);

    // AABB in local space
    float cs = cell_world_size(node->lod_level, voxel_size_, 32);
    AABB custom_aabb(Vector3(0, 0, 0), Vector3(cs * 2.0f, cs * 2.0f, cs * 2.0f));
    rs->instance_set_custom_aabb(node->seam_mesh_instance, custom_aabb);

    rs->instance_set_visible(node->seam_mesh_instance, true);

    // Keep mesh ref alive — store in node_meshes_ with a shifted key to avoid collision
    // Actually, we need a separate map or just ensure the ref stays alive.
    // The seam_mesh_instance RID keeps the mesh alive in RS, but we need the
    // Ref<ArrayMesh> to prevent GC. Store it alongside the main mesh.
    // Use a simple approach: the seam mesh ref is kept alive via a member vector.
    // For simplicity, just keep the ref in the existing map won't work (same key).
    // Instead, just let the ArrayMesh prevent its own cleanup since RS holds a ref.
    // Actually Godot RS does NOT prevent GC — we must hold the ref.
    // Store in node_meshes_ won't work since same key. Use a separate small vector.
    seam_meshes_[node] = arr_mesh;

    if (debug_logging_) {
        fprintf(stderr, "SEAM LOD%d (%d,%d,%d): verts=%d tris=%d\n",
            node->lod_level, node->position.x, node->position.y, node->position.z,
            vert_count, idx_count / 3);
    }
}

// ---------------------------------------------------------------------------
// _free_node_rids() — frees RenderingServer + PhysicsServer RIDs for a node.
// Queues for deferred deallocation if called during traversal, or frees
// immediately during cleanup.
// ---------------------------------------------------------------------------
void DCTerrain::_on_node_free(OctreeClipmapNode* node, void* userdata) {
    auto* self = static_cast<DCTerrain*>(userdata);
    if (!node) return;

    // Track freed pointers to prevent use-after-free in result processing
    self->freed_nodes_.insert(node);

    // Immediately hide mesh instances to prevent LOD stacking
    // (parent mesh stays visible until dealloc queue processes it otherwise)
    RenderingServer* rs = RenderingServer::get_singleton();
    if (rs) {
        if (node->mesh_instance.is_valid()) {
            rs->instance_set_visible(node->mesh_instance, false);
        }
        if (node->seam_mesh_instance.is_valid()) {
            rs->instance_set_visible(node->seam_mesh_instance, false);
        }
    }

    // Queue RID cleanup via dealloc queue (deferred free to avoid stutter)
    if (node->mesh_instance.is_valid() || node->collision_body.is_valid() || node->seam_mesh_instance.is_valid()) {
        DeallocEntry entry;
        entry.rs_instance = node->mesh_instance;
        entry.collision_body = node->collision_body;
        entry.collision_shape = node->collision_shape;
        entry.seam_mesh_instance = node->seam_mesh_instance;
        if (self->node_meshes_.count(node)) {
            entry.mesh = self->node_meshes_[node];
        }
        self->dealloc_queue_.push_back(std::move(entry));
    }

    node->mesh_instance = RID();
    node->collision_body = RID();
    node->collision_shape = RID();
    node->seam_mesh_instance = RID();
    self->node_meshes_.erase(node);
    self->seam_meshes_.erase(node);
    self->fade_overrides_.erase(node);

    if (node->seam_nodes) {
        delete[] node->seam_nodes;
        node->seam_nodes = nullptr;
        node->num_seam_nodes = 0;
    }
}

// ---------------------------------------------------------------------------
// _on_mesh_free() — frees ONLY visual mesh, keeps collision alive.
// Called during stale parent cleanup when children are ACTIVE but don't yet
// have their own collision bodies. Prevents collision gaps during LOD cascading.
// ---------------------------------------------------------------------------
void DCTerrain::_on_mesh_free(OctreeClipmapNode* node, void* userdata) {
    auto* self = static_cast<DCTerrain*>(userdata);
    if (!node) return;

    // Hide and queue mesh for deferred free
    RenderingServer* rs = RenderingServer::get_singleton();
    if (rs) {
        if (node->mesh_instance.is_valid()) {
            rs->instance_set_visible(node->mesh_instance, false);
        }
        if (node->seam_mesh_instance.is_valid()) {
            rs->instance_set_visible(node->seam_mesh_instance, false);
        }
    }

    if (node->mesh_instance.is_valid() || node->seam_mesh_instance.is_valid()) {
        DeallocEntry entry;
        entry.rs_instance = node->mesh_instance;
        entry.seam_mesh_instance = node->seam_mesh_instance;
        // collision_body and collision_shape left as invalid RID — not freed
        if (self->node_meshes_.count(node)) {
            entry.mesh = self->node_meshes_[node];
        }
        self->dealloc_queue_.push_back(std::move(entry));
    }

    node->mesh_instance = RID();
    node->seam_mesh_instance = RID();
    node->shadow_disabled_for_stale = false;
    self->node_meshes_.erase(node);
    self->seam_meshes_.erase(node);
    self->fade_overrides_.erase(node);

    if (node->seam_nodes) {
        delete[] node->seam_nodes;
        node->seam_nodes = nullptr;
        node->num_seam_nodes = 0;
    }
}

// ---------------------------------------------------------------------------
// _on_collision_free() — frees ONLY collision body, mesh already freed.
// Called when children finally have their own collision bodies.
// ---------------------------------------------------------------------------
void DCTerrain::_on_collision_free(OctreeClipmapNode* node, void* userdata) {
    auto* self = static_cast<DCTerrain*>(userdata);
    if (!node) return;

    if (node->collision_body.is_valid()) {
        DeallocEntry entry;
        entry.collision_body = node->collision_body;
        entry.collision_shape = node->collision_shape;
        self->dealloc_queue_.push_back(std::move(entry));
    }

    node->collision_body = RID();
    node->collision_shape = RID();
}

void DCTerrain::_free_node_rids(OctreeClipmapNode* node) {
    if (!node) return;

    RenderingServer* rs = RenderingServer::get_singleton();
    if (rs) {
        if (node->mesh_instance.is_valid()) {
            rs->free_rid(node->mesh_instance);
            node->mesh_instance = RID();
        }
        if (node->seam_mesh_instance.is_valid()) {
            rs->free_rid(node->seam_mesh_instance);
            node->seam_mesh_instance = RID();
        }
    }

    PhysicsServer3D* ps = PhysicsServer3D::get_singleton();
    if (ps) {
        if (node->collision_body.is_valid()) {
            ps->free_rid(node->collision_body);
            node->collision_body = RID();
        }
        if (node->collision_shape.is_valid()) ps->free_rid(node->collision_shape);
        node->collision_shape = RID();
    }

    // Free seam nodes
    if (node->seam_nodes) {
        delete[] node->seam_nodes;
        node->seam_nodes = nullptr;
        node->num_seam_nodes = 0;
    }

    // Release the ArrayMesh refs for this node
    auto it = node_meshes_.find(node);
    if (it != node_meshes_.end()) {
        it->second.unref();
        node_meshes_.erase(it);
    }
    auto sit = seam_meshes_.find(node);
    if (sit != seam_meshes_.end()) {
        sit->second.unref();
        seam_meshes_.erase(sit);
    }
}

// ---------------------------------------------------------------------------
// Property getters/setters
// ---------------------------------------------------------------------------

// Helper: set a density config value and rebuild if changed
#define DC_SET_CONFIG(field, val) do { \
    if (config_.field != val) { config_.field = val; rebuild(); } \
} while(0)

void DCTerrain::set_seed(int p_seed) { DC_SET_CONFIG(seed, p_seed); }
int DCTerrain::get_seed() const { return config_.seed; }

void DCTerrain::set_primary_frequency(float p_val) { DC_SET_CONFIG(primary_frequency, p_val); }
float DCTerrain::get_primary_frequency() const { return config_.primary_frequency; }
void DCTerrain::set_primary_strength(float p_val) { DC_SET_CONFIG(primary_strength, p_val); }
float DCTerrain::get_primary_strength() const { return config_.primary_strength; }

void DCTerrain::set_tunnel_frequency(float p_val) { DC_SET_CONFIG(tunnel_frequency, p_val); }
float DCTerrain::get_tunnel_frequency() const { return config_.tunnel_frequency; }
void DCTerrain::set_tunnel_strength(float p_val) { DC_SET_CONFIG(tunnel_strength, p_val); }
float DCTerrain::get_tunnel_strength() const { return config_.tunnel_strength; }
void DCTerrain::set_tunnel_radius(float p_val) { DC_SET_CONFIG(tunnel_radius, p_val); }
float DCTerrain::get_tunnel_radius() const { return config_.tunnel_radius; }

void DCTerrain::set_ridge_frequency(float p_val) { DC_SET_CONFIG(ridge_frequency, p_val); }
float DCTerrain::get_ridge_frequency() const { return config_.ridge_frequency; }
void DCTerrain::set_ridge_strength(float p_val) { DC_SET_CONFIG(ridge_strength, p_val); }
float DCTerrain::get_ridge_strength() const { return config_.ridge_strength; }

void DCTerrain::set_warp_frequency(float p_val) { DC_SET_CONFIG(warp_frequency, p_val); }
float DCTerrain::get_warp_frequency() const { return config_.warp_frequency; }
void DCTerrain::set_warp_strength(float p_val) { DC_SET_CONFIG(warp_strength, p_val); }
float DCTerrain::get_warp_strength() const { return config_.warp_strength; }

void DCTerrain::set_detail_frequency(float p_val) { DC_SET_CONFIG(detail_frequency, p_val); }
float DCTerrain::get_detail_frequency() const { return config_.detail_frequency; }
void DCTerrain::set_detail_strength(float p_val) { DC_SET_CONFIG(detail_strength, p_val); }
float DCTerrain::get_detail_strength() const { return config_.detail_strength; }

void DCTerrain::set_ground_bias(float p_val) { DC_SET_CONFIG(ground_bias, p_val); }
float DCTerrain::get_ground_bias() const { return config_.ground_bias; }

void DCTerrain::set_solid_floor_depth(float p_val) { DC_SET_CONFIG(solid_floor_depth, p_val); }
float DCTerrain::get_solid_floor_depth() const { return config_.solid_floor_depth; }
void DCTerrain::set_sky_height(float p_val) { DC_SET_CONFIG(sky_height, p_val); }
float DCTerrain::get_sky_height() const { return config_.sky_height; }

void DCTerrain::set_flat_center_radius(float p_val) { DC_SET_CONFIG(flat_center_radius, p_val); }
float DCTerrain::get_flat_center_radius() const { return config_.flat_center_radius; }

void DCTerrain::set_voronoi_cell_count(int p_val) { DC_SET_CONFIG(voronoi_cell_count, p_val); }
int DCTerrain::get_voronoi_cell_count() const { return config_.voronoi_cell_count; }

void DCTerrain::set_floor_strength(float p_val) { DC_SET_CONFIG(floor_strength, p_val); }
float DCTerrain::get_floor_strength() const { return config_.floor_strength; }
void DCTerrain::set_floor_height_min(float p_val) { DC_SET_CONFIG(floor_height_min, p_val); }
float DCTerrain::get_floor_height_min() const { return config_.floor_height_min; }
void DCTerrain::set_floor_height_max(float p_val) { DC_SET_CONFIG(floor_height_max, p_val); }
float DCTerrain::get_floor_height_max() const { return config_.floor_height_max; }
void DCTerrain::set_room_height(float p_val) { DC_SET_CONFIG(room_height, p_val); }
float DCTerrain::get_room_height() const { return config_.room_height; }
void DCTerrain::set_terrain_size(float p_val) { DC_SET_CONFIG(terrain_size, p_val); }
float DCTerrain::get_terrain_size() const { return config_.terrain_size; }
void DCTerrain::set_use_noise(bool p_val) { DC_SET_CONFIG(use_noise, p_val); }
bool DCTerrain::get_use_noise() const { return config_.use_noise; }
void DCTerrain::set_force_direct_eval(bool p_val) { DC_SET_CONFIG(force_direct_eval, p_val); }
bool DCTerrain::get_force_direct_eval() const { return config_.force_direct_eval; }
void DCTerrain::set_csg_refinement(int p_val) { DC_SET_CONFIG(csg_refinement, std::max(1, std::min(p_val, 4))); }
int DCTerrain::get_csg_refinement() const { return config_.csg_refinement; }
void DCTerrain::set_use_gpu(bool p_val) { DC_SET_CONFIG(use_gpu, p_val); }
bool DCTerrain::get_use_gpu() const { return config_.use_gpu; }
void DCTerrain::set_max_noise_octaves(int p_val) { DC_SET_CONFIG(max_noise_octaves, p_val); }
int DCTerrain::get_max_noise_octaves() const { return config_.max_noise_octaves; }
void DCTerrain::set_max_ridge_octaves(int p_val) { DC_SET_CONFIG(max_ridge_octaves, p_val); }
int DCTerrain::get_max_ridge_octaves() const { return config_.max_ridge_octaves; }

#undef DC_SET_CONFIG

void DCTerrain::set_terrain_color(const Color &p_color) {
    terrain_color_ = p_color;
    // terrain_color_ currently unused — per-LOD debug colors override it
}
Color DCTerrain::get_terrain_color() const { return terrain_color_; }

void DCTerrain::set_custom_material(const Ref<Material> &p_material) {
    custom_material_ = p_material;
    // Apply to all existing mesh surfaces
    for (auto &pair : node_meshes_) {
        if (pair.second.is_valid()) {
            if (custom_material_.is_valid()) {
                pair.second->surface_set_material(0, custom_material_);
            } else {
                int lod = pair.first ? pair.first->lod_level : 0;
                if (lod < 0) lod = 0;
                if (lod >= MAX_LOD_MATERIALS) lod = MAX_LOD_MATERIALS - 1;
                pair.second->surface_set_material(0, lod_materials_[lod]);
            }
        }
    }
}
Ref<Material> DCTerrain::get_custom_material() const { return custom_material_; }

void DCTerrain::set_voxel_size(float p_val) {
    if (voxel_size_ != p_val) { voxel_size_ = p_val; rebuild(); }
}
float DCTerrain::get_voxel_size() const { return voxel_size_; }

void DCTerrain::set_octree_depth(int p_val) {
    if (octree_depth_ != p_val) { octree_depth_ = p_val; rebuild(); }
}
int DCTerrain::get_octree_depth() const { return octree_depth_; }

void DCTerrain::set_seamless_lod(bool p_val) {
    if (seamless_lod_ != p_val) { seamless_lod_ = p_val; rebuild(); }
}
bool DCTerrain::get_seamless_lod() const { return seamless_lod_; }

void DCTerrain::set_collision_mode(int p_mode) { collision_mode_ = static_cast<CollisionMode>(p_mode); }
int DCTerrain::get_collision_mode() const { return static_cast<int>(collision_mode_); }
void DCTerrain::set_collision_lod_max(int p_val) { collision_trimesh_.collision_lod_max = p_val; }
int DCTerrain::get_collision_lod_max() const { return collision_trimesh_.collision_lod_max; }
void DCTerrain::set_collision_distance(float p_val) { collision_trimesh_.collision_distance = p_val; }
float DCTerrain::get_collision_distance() const { return collision_trimesh_.collision_distance; }

void DCTerrain::set_use_adaptive_qef(bool p_val) {
    if (use_adaptive_qef_ != p_val) { use_adaptive_qef_ = p_val; rebuild(); }
}
bool DCTerrain::get_use_adaptive_qef() const { return use_adaptive_qef_; }

void DCTerrain::_apply_texture_params(const Ref<ShaderMaterial> &mat) const {
    if (mat.is_null()) return;
    mat->set_shader_parameter("use_textures", use_textures_);
    if (tex_atlas_.is_valid()) mat->set_shader_parameter("tex_atlas", tex_atlas_);
    mat->set_shader_parameter("has_projection_table", proj_table_tex_.is_valid());
    mat->set_shader_parameter("projection_rows", proj_table_count_);
    if (proj_table_tex_.is_valid()) mat->set_shader_parameter("projection_table", proj_table_tex_);
    mat->set_shader_parameter("use_pbr", glow_atlas_.is_valid() || norm_atlas_.is_valid());
    if (glow_atlas_.is_valid()) mat->set_shader_parameter("glow_atlas", glow_atlas_);
    if (norm_atlas_.is_valid()) mat->set_shader_parameter("norm_atlas", norm_atlas_);
}

void DCTerrain::set_texture_atlas(const Ref<Texture2DArray> &p_atlas) {
    tex_atlas_ = p_atlas;
    for (int i = 0; i < MAX_LOD_MATERIALS; i++) _apply_texture_params(lod_materials_[i]);
    for (auto &kv : fade_overrides_) _apply_texture_params(kv.second);
    UtilityFunctions::print("[DCTerrain] texture atlas set (", p_atlas.is_valid() ? "valid" : "null", ")");
}

void DCTerrain::set_glow_atlas(const Ref<Texture2DArray> &p_atlas) {
    glow_atlas_ = p_atlas;
    for (int i = 0; i < MAX_LOD_MATERIALS; i++) _apply_texture_params(lod_materials_[i]);
    for (auto &kv : fade_overrides_) _apply_texture_params(kv.second);
}

void DCTerrain::set_norm_atlas(const Ref<Texture2DArray> &p_atlas) {
    norm_atlas_ = p_atlas;
    for (int i = 0; i < MAX_LOD_MATERIALS; i++) _apply_texture_params(lod_materials_[i]);
    for (auto &kv : fade_overrides_) _apply_texture_params(kv.second);
}

// Build the per-face projection table as an RGBA32F data texture: 3 texels per entry
// (row-major, width 3, height = entry count). Row r (= projection index r+1 carried by the
// vertex) holds: (layer,u_off,v_off,0)(u_axis.xyz,0)(v_axis.xyz,0). The shader texelFetch()es
// it by the per-vertex projection index to reconstruct the face's Quake UV. See
// scenes/maps/quake_map_importer.gd (_register_face_projection) for the producing side.
void DCTerrain::set_projection_table(const PackedFloat32Array &p_table) {
    int entries = p_table.size() / 16;
    if (entries <= 0) {
        proj_table_tex_ = Ref<Texture2D>();
        proj_table_count_ = 0;
    } else {
        int byte_count = entries * 4 * 16;   // 4 texels/row × 16 bytes/RGBA32F texel (texel3 = geometric normal)
        PackedByteArray bytes;
        bytes.resize(byte_count);
        // The float table is already laid out entry-by-entry (16 floats = 4 texels), which
        // is exactly the row-major RGBAF byte order Image expects — copy straight through.
        memcpy(bytes.ptrw(), p_table.ptr(), byte_count);
        Ref<Image> img = Image::create_from_data(4, entries, false, Image::FORMAT_RGBAF, bytes);
        proj_table_tex_ = ImageTexture::create_from_image(img);
        proj_table_count_ = entries;
    }
    for (int i = 0; i < MAX_LOD_MATERIALS; i++) _apply_texture_params(lod_materials_[i]);
    for (auto &kv : fade_overrides_) _apply_texture_params(kv.second);
    UtilityFunctions::print("[DCTerrain] projection table set (", proj_table_count_, " entries)");
}

void DCTerrain::set_use_textures(bool p_val) {
    use_textures_ = p_val;
    for (int i = 0; i < MAX_LOD_MATERIALS; i++) _apply_texture_params(lod_materials_[i]);
    for (auto &kv : fade_overrides_) _apply_texture_params(kv.second);
    UtilityFunctions::print("[DCTerrain] use_textures = ", p_val);
}
bool DCTerrain::get_use_textures() const { return use_textures_; }

// Recompute the AABB enclosing all convex-brush (architecture) edits, for adaptive arch LOD.
void DCTerrain::_update_arch_bounds() {
    auto edits = edit_list_.snapshot();
    float lo[3] = {1e30f, 1e30f, 1e30f}, hi[3] = {-1e30f, -1e30f, -1e30f};
    bool any = false;
    for (const auto& e : edits) {
        if (e.shape != TerrainEdit::CONVEX) continue;   // architecture = convex brushes
        float r = e.radius > 0.0f ? e.radius : voxel_size_;
        lo[0] = std::min(lo[0], e.x - r); hi[0] = std::max(hi[0], e.x + r);
        lo[1] = std::min(lo[1], e.y - r); hi[1] = std::max(hi[1], e.y + r);
        lo[2] = std::min(lo[2], e.z - r); hi[2] = std::max(hi[2], e.z + r);
        any = true;
    }
    for (int i = 0; i < 3; i++) {
        octree_clipmap_.arch_min[i] = any ? lo[i] : 0.0f;
        octree_clipmap_.arch_max[i] = any ? hi[i] : 0.0f;
    }
    UtilityFunctions::print("[DCTerrain] arch bounds = (", octree_clipmap_.arch_min[0], ",",
        octree_clipmap_.arch_min[1], ",", octree_clipmap_.arch_min[2], ") .. (",
        octree_clipmap_.arch_max[0], ",", octree_clipmap_.arch_max[1], ",", octree_clipmap_.arch_max[2], ")");
}

void DCTerrain::set_adaptive_arch_lod(bool p_val) {
    if (adaptive_arch_lod_ == p_val) return;
    adaptive_arch_lod_ = p_val;
    octree_clipmap_.arch_lod_active = p_val;
    if (p_val) _update_arch_bounds();            // capture the current architecture extent
    octree_clipmap_.invalidate_settled();        // re-traverse so the change takes effect
    UtilityFunctions::print("[DCTerrain] adaptive_arch_lod = ", p_val);
}
bool DCTerrain::get_adaptive_arch_lod() const { return adaptive_arch_lod_; }

void DCTerrain::set_arch_min_lod(int p_lod) {
    int l = p_lod < -3 ? -3 : (p_lod > 0 ? 0 : p_lod);   // clamp to [-3, 0]
    if (octree_clipmap_.arch_min_lod == l) return;
    octree_clipmap_.arch_min_lod = l;
    octree_clipmap_.invalidate_settled();
    UtilityFunctions::print("[DCTerrain] arch_min_lod = ", l);
}
int DCTerrain::get_arch_min_lod() const { return octree_clipmap_.arch_min_lod; }

void DCTerrain::set_use_shortest_diagonal(bool p_val) {
    if (use_shortest_diagonal_ != p_val) { use_shortest_diagonal_ = p_val; rebuild(); }
}
bool DCTerrain::get_use_shortest_diagonal() const { return use_shortest_diagonal_; }

void DCTerrain::set_use_mesh_simplify(bool p_val) {
    if (use_mesh_simplify_ != p_val) { use_mesh_simplify_ = p_val; rebuild(); }
}
bool DCTerrain::get_use_mesh_simplify() const { return use_mesh_simplify_; }

void DCTerrain::set_debug_logging(bool p_val) {
    debug_logging_ = p_val;
}
bool DCTerrain::get_debug_logging() const { return debug_logging_; }

void DCTerrain::set_debug_seams(bool p_val) {
    debug_seams_ = p_val;
    if (!p_val && debug_mesh_node_) {
        debug_mesh_node_->queue_free();
        debug_mesh_node_ = nullptr;
    }
}
bool DCTerrain::get_debug_seams() const { return debug_seams_; }

void DCTerrain::set_debug_clipmap(bool p_val) {
    debug_clipmap_ = p_val;
    if (!p_val && debug_clipmap_node_) {
        debug_clipmap_node_->queue_free();
        debug_clipmap_node_ = nullptr;
    }
}
bool DCTerrain::get_debug_clipmap() const { return debug_clipmap_; }

void DCTerrain::set_debug_edits(bool p_val) {
    debug_edits_ = p_val;
    if (!p_val && debug_edits_node_) {
        debug_edits_node_->queue_free();
        debug_edits_node_ = nullptr;
    }
    if (p_val) _update_debug_edits();
}
bool DCTerrain::get_debug_edits() const { return debug_edits_; }

void DCTerrain::_update_debug_edits() {
    if (!debug_edits_ || !is_inside_tree()) return;
    if (!debug_edits_node_) {
        debug_edits_node_ = memnew(MeshInstance3D);
        debug_edits_node_->set_name("_DebugEdits");
        debug_edits_node_->set_cast_shadows_setting(GeometryInstance3D::SHADOW_CASTING_SETTING_OFF);
        debug_edits_node_->set_as_top_level(true);  // Don't inherit parent transform
        add_child(debug_edits_node_);
    }
    Ref<ImmediateMesh> im;
    im.instantiate();
    auto edits = edit_list_.snapshot();
    if (edits.empty()) {
        debug_edits_node_->set_mesh(Ref<Mesh>());
        return;
    }
    im->surface_begin(Mesh::PRIMITIVE_LINES);
    for (const auto& e : edits) {
        if (e.shape == TerrainEdit::BOX) {
            float hx = e.half_ext[0], hy = e.half_ext[1], hz = e.half_ext[2];
            Vector3 c(e.x, e.y, e.z);
            // 12 edges of a box
            Vector3 corners[8] = {
                c + Vector3(-hx, -hy, -hz), c + Vector3( hx, -hy, -hz),
                c + Vector3( hx,  hy, -hz), c + Vector3(-hx,  hy, -hz),
                c + Vector3(-hx, -hy,  hz), c + Vector3( hx, -hy,  hz),
                c + Vector3( hx,  hy,  hz), c + Vector3(-hx,  hy,  hz),
            };
            int edges[24] = {0,1, 1,2, 2,3, 3,0, 4,5, 5,6, 6,7, 7,4, 0,4, 1,5, 2,6, 3,7};
            Color col = (e.strength < 0.0f) ? Color(1, 0.3, 0.3) : Color(0.3, 1, 0.3);
            for (int i = 0; i < 24; i += 2) {
                im->surface_set_color(col);
                im->surface_add_vertex(corners[edges[i]]);
                im->surface_set_color(col);
                im->surface_add_vertex(corners[edges[i+1]]);
            }
        } else if (e.shape == TerrainEdit::SPHERE) {
            // Simple cross for spheres
            Vector3 c(e.x, e.y, e.z);
            float r = e.radius;
            Color col = (e.strength < 0.0f) ? Color(1, 0.5, 0.5) : Color(0.5, 1, 0.5);
            im->surface_set_color(col); im->surface_add_vertex(c - Vector3(r,0,0));
            im->surface_set_color(col); im->surface_add_vertex(c + Vector3(r,0,0));
            im->surface_set_color(col); im->surface_add_vertex(c - Vector3(0,r,0));
            im->surface_set_color(col); im->surface_add_vertex(c + Vector3(0,r,0));
            im->surface_set_color(col); im->surface_add_vertex(c - Vector3(0,0,r));
            im->surface_set_color(col); im->surface_add_vertex(c + Vector3(0,0,r));
        }
    }
    im->surface_end();

    Ref<StandardMaterial3D> mat;
    mat.instantiate();
    mat->set_shading_mode(BaseMaterial3D::SHADING_MODE_UNSHADED);
    mat->set_flag(BaseMaterial3D::FLAG_ALBEDO_FROM_VERTEX_COLOR, true);
    mat->set_transparency(BaseMaterial3D::TRANSPARENCY_DISABLED);
    mat->set_flag(BaseMaterial3D::FLAG_DISABLE_DEPTH_TEST, true);
    im->surface_set_material(0, mat);
    debug_edits_node_->set_mesh(im);
}

int DCTerrain::get_chunk_count() const {
    return octree_clipmap_.get_stats().leaf_count;
}

int DCTerrain::get_active_chunk_count() const {
    // Count ACTIVE leaves by traversing. For a lightweight stats call,
    // we use the stats from the last traverse which tracks leaf_count.
    // A more accurate count would traverse all leaves, but this is called
    // per-frame from GDScript HUD so must be cheap.
    // For now return leaf_count minus pending_count as approximation.
    auto& stats = octree_clipmap_.get_stats();
    return stats.leaf_count - stats.pending_count;
}

int DCTerrain::get_pending_count() const {
    return octree_clipmap_.get_stats().pending_count;
}

int DCTerrain::get_collision_queue_size() const { return collision_trimesh_.queue_size(); }

int DCTerrain::get_splits_this_frame() const {
    return octree_clipmap_.get_stats().splits_this_frame;
}

// ---------------------------------------------------------------------------
// Density sampling
// ---------------------------------------------------------------------------
PackedVector3Array DCTerrain::debug_collision_faces(const Vector3 &center, float radius) const {
    PackedVector3Array out;
    PhysicsServer3D *ps = PhysicsServer3D::get_singleton();
    if (!ps) return out;
    const float r2 = radius * radius;
    std::vector<OctreeClipmapNode *> leaves =
            const_cast<OctreeClipmap &>(octree_clipmap_).get_leaves();
    for (OctreeClipmapNode *n : leaves) {
        if (!n || !n->collision_shape.is_valid() || !n->collision_body.is_valid()) continue;
        Transform3D xf = ps->body_get_state(n->collision_body,
                PhysicsServer3D::BODY_STATE_TRANSFORM);

        // CULL BY THE CHUNK'S BOX, NOT BY ITS CORNER. The body origin is the
        // chunk's minimum corner and a chunk spans 32 voxels -- 32 m at LOD 0,
        // 1024 m at LOD 5. Testing the corner against the probe radius rejects
        // the very chunk the player is standing in whenever the probe is
        // smaller than the chunk, and an empty return reads as "there is no
        // collision here" rather than "the cull threw it away".
        const float cs = 32.0f * lod_voxel_size(voxel_size_, n->lod_level);
        const Vector3 lo = xf.origin;
        const Vector3 hi = xf.origin + Vector3(cs, cs, cs);
        const Vector3 q(
                std::min(std::max(center.x, lo.x), hi.x),
                std::min(std::max(center.y, lo.y), hi.y),
                std::min(std::max(center.z, lo.z), hi.z));
        if (q.distance_squared_to(center) > r2) continue;

        Variant data = ps->shape_get_data(n->collision_shape);
        if (data.get_type() != Variant::DICTIONARY) continue;
        PackedVector3Array faces = ((Dictionary)data).get("faces", PackedVector3Array());

        // WHOLE TRIANGLES, ONE DECISION PER TRIANGLE. The previous form walked
        // vertices: an in-range vertex was pushed on its own AND its triangle
        // was pushed again by the i%3 branch, so the array was no longer a
        // triangle list at all. Every inclination or step computed from it read
        // a triangle made of three vertices that were never one.
        for (int t = 0; t + 2 < faces.size(); t += 3) {
            const Vector3 a = xf.xform(faces[t]);
            const Vector3 b = xf.xform(faces[t + 1]);
            const Vector3 c = xf.xform(faces[t + 2]);
            if (a.distance_squared_to(center) <= r2 ||
                b.distance_squared_to(center) <= r2 ||
                c.distance_squared_to(center) <= r2) {
                out.push_back(a); out.push_back(b); out.push_back(c);
            }
        }
    }
    return out;
}

// THE CHUNK UNDER A POINT, as the streamer sees it. Answers the question every
// terrain-collision report needs first and no existing API could: what LOD is
// the ground here, how wide is one of its voxels, and does it have a collision
// body at all. A miss is reported as an EMPTY dictionary rather than as zeros,
// because "there is no leaf here" and "LOD 0" are different findings.
// THE VISUAL MESH, as triangles, in world space -- the counterpart to
// debug_collision_faces, so "is the collision where the terrain looks like it
// is" becomes a measurement instead of an argument from source.
//
// The two are supposed to be the same triangles: _upload_cell builds the
// collision faces from result.mesh_data, the same array it builds the ArrayMesh
// from, and positions both with the same origin expression. Supposed to is not
// measured, and this session has twice caught a difference between what the
// source says and what runs.
PackedVector3Array DCTerrain::debug_visual_faces(const Vector3 &center, float radius) const {
    PackedVector3Array out;
    const float r2 = radius * radius;
    std::vector<OctreeClipmapNode *> leaves =
            const_cast<OctreeClipmap &>(octree_clipmap_).get_leaves();
    for (OctreeClipmapNode *n : leaves) {
        if (!n || !n->mesh_instance.is_valid()) continue;
        auto it = node_meshes_.find(n);
        if (it == node_meshes_.end() || it->second.is_null()) continue;

        const float cs = 32.0f * lod_voxel_size(voxel_size_, n->lod_level);
        const Vector3 lo(n->mesh_origin[0], n->mesh_origin[1], n->mesh_origin[2]);
        const Vector3 hi = lo + Vector3(cs, cs, cs);
        const Vector3 q(
                std::min(std::max(center.x, lo.x), hi.x),
                std::min(std::max(center.y, lo.y), hi.y),
                std::min(std::max(center.z, lo.z), hi.z));
        if (q.distance_squared_to(center) > r2) continue;

        Ref<ArrayMesh> mesh = it->second;
        for (int si = 0; si < mesh->get_surface_count(); si++) {
            Array arrays = mesh->surface_get_arrays(si);
            if (arrays.size() <= Mesh::ARRAY_INDEX) continue;
            PackedVector3Array verts = arrays[Mesh::ARRAY_VERTEX];
            PackedInt32Array idx = arrays[Mesh::ARRAY_INDEX];
            if (verts.is_empty() || idx.is_empty()) continue;
            for (int t = 0; t + 2 < idx.size(); t += 3) {
                const Vector3 a = verts[idx[t]] + lo;
                const Vector3 b = verts[idx[t + 1]] + lo;
                const Vector3 c = verts[idx[t + 2]] + lo;
                if (a.distance_squared_to(center) <= r2 ||
                    b.distance_squared_to(center) <= r2 ||
                    c.distance_squared_to(center) <= r2) {
                    out.push_back(a); out.push_back(b); out.push_back(c);
                }
            }
        }
    }
    return out;
}

String DCTerrain::build_stamp() const {
    return String("dc-terrain built ") + __DATE__ + " " + __TIME__;
}

Dictionary DCTerrain::debug_chunk_info(const Vector3 &position) const {
    Dictionary d;
    if (voxel_size_ <= 0.0f) return d;
    const int vx = (int)Math::floor(position.x / voxel_size_);
    const int vy = (int)Math::floor(position.y / voxel_size_);
    const int vz = (int)Math::floor(position.z / voxel_size_);
    OctreeClipmapNode *n = octree_clipmap_.find_leaf_containing(vx, vy, vz);
    if (!n) return d;
    const float lv = lod_voxel_size(voxel_size_, n->lod_level);
    d["lod"] = n->lod_level;
    d["voxel_size"] = lv;
    d["chunk_size"] = 32.0f * lv;
    d["state"] = (int)n->state;
    // MESH_NEED_UPDATE (4) is the answer to "did my carve reach this chunk":
    // an edit marks the chunk dirty, and a chunk still UP_TO_DATE after a carve
    // never heard about it.
    d["mesh_state"] = (int)n->mesh_state;
    d["triangles"] = (int)n->triangle_count;
    d["has_collision"] = n->collision_body.is_valid();
    d["collision_eligible"] = collision_trimesh_.should_enqueue(n->lod_level, n->triangle_count > 0);
    d["origin"] = Vector3(n->position.x, n->position.y, n->position.z) * voxel_size_;
    d["parent_collision_stale"] = n->parent_collision_stale;
    return d;
}

PackedFloat32Array DCTerrain::debug_gpu_density(const Vector3 &origin, float voxel_size, int cells) {
    PackedFloat32Array out;
    if (!gpu_thread_ || !generator_ptr_) return out;
    if (cells < 1) cells = 1;

    DensityGrid grid;
    float org[3] = { (float)origin.x, (float)origin.y, (float)origin.z };
    grid.allocate(cells, voxel_size, org);

    const char* glsl = generator_ptr_->has_gpu_shader()
            ? generator_ptr_->gpu_shader_source() : nullptr;
    const float* hb = generator_ptr_->gpu_custom_data();
    int hbn = generator_ptr_->gpu_custom_data_count();
    uint32_t hbv = generator_ptr_->gpu_custom_data_version();
    const void* const* nv = generator_ptr_->gpu_noise_volumes();
    int nvd = generator_ptr_->gpu_noise_dim();
    uint32_t nvv = generator_ptr_->gpu_noise_version();

    if (!gpu_thread_->submit(grid, density_, glsl, nullptr, nullptr,
                             nullptr, 0.0f, 0u, hb, hbn, hbv, nv, nvd, nvv)) {
        return out;   // empty: the GPU declined, and that is the finding
    }
    out.resize((int64_t)grid.data.size());
    memcpy(out.ptrw(), grid.data.data(), grid.data.size() * sizeof(float));
    return out;
}

Ref<ArrayMesh> DCTerrain::bake_volume_mesh(const String &p_glsl, const Vector3 &p_origin,
                                           float p_voxel_size, int p_cells) {
    Ref<ArrayMesh> out;
    if (p_glsl.is_empty()) {
        UtilityFunctions::push_error(
            "DCTerrain.bake_volume_mesh: no GLSL. A ProcVolume carries one only "
            "after its graph compiles -- check ProcVolume.get_glsl_source().");
        return out;
    }
    // BRING THE THREAD UP IF IT IS NOT, into the SAME SLOT the terrain uses.
    //
    // _notification(ENTER_TREE) only creates it on the game-mode path:
    // force_shared = is_editor_hint() && use_gpu, and an editor or
    // script-launched instance takes the shared-worker branch and creates no
    // GPU thread at all. A bake should not require the terrain to be streaming.
    //
    // Creating it HERE is safe precisely because it is the same member: if the
    // terrain later enters game mode it finds gpu_thread_ non-null and reuses
    // it. One device, ever. A second VkDevice is the 30.2 s starvation that
    // left two meshers in one world (hyper-games #58).
    bool made_it_here = false;
    if (!gpu_thread_) {
        gpu_thread_ = std::make_unique<DCGpuThread>();
        gpu_thread_->start();
        made_it_here = true;
        UtilityFunctions::print(
            "DCTerrain.bake_volume_mesh: no GPU thread existed, starting one "
            "(this instance was not streaming).");
    }
    (void)made_it_here;
    if (!gpu_thread_->is_available()) {
        // NON-BLOCKING ON PURPOSE. Bring-up takes seconds, and blocking a bound
        // call for that freezes the frame -- the same "right total, wrong shape"
        // mistake the worker deferral records. Return null and let the caller
        // ask again.
        static uint64_t s_waits = 0;
        if ((s_waits++ % 600) == 0) {
            // THROTTLED. Polling this every frame is normal -- the caller
            // cannot know when the device is up -- but a warning per frame
            // buries the log and adds load to the very bring-up it is waiting
            // for. Measured in the full map: ready at 6.2 s, and the poll had
            // already logged 9601 times by then.
            UtilityFunctions::push_warning(
                "DCTerrain.bake_volume_mesh: GPU still coming up (poll #",
                (int64_t)s_waits, "). Returning null rather than blocking.");
        }
        return out;
    }
    if (p_cells < 1) p_cells = 1;
    if (p_voxel_size <= 0.0f) p_voxel_size = 1.0f;

    // ⛔ THE ENTRY POINT IS evaluate_density(float,float,float), NOT
    // proc_density(vec3). DCCompute::set_custom_shader's own comment says
    // "Custom GLSL provides evaluate_density() + helpers", and the template
    // calls that name with three floats.
    //
    // Handing it the graph's text unadapted does NOT fail loudly: the shader
    // compiles (proc_density is simply an unused function) and the template's
    // own default density runs instead. Measured 2026-09-06 -- a unit-sphere
    // volume baked to 225 verts and 392 triangles with EVERY VERTEX AT y = 0.
    // A flat plane, produced without one error line.
    //
    // DCTerrainGeneratorProcNodes writes this same adapter at
    // dc_terrain_generator_procnodes.cpp:860; a volume needs only the trivial
    // form, with no terrain height mapping, because a ProcVolume is already in
    // world space.
    baked_volume_glsl_ = std::string(p_glsl.utf8().get_data());
    baked_volume_glsl_ +=
        "\n\nfloat evaluate_density(float x, float y, float z) {\n"
        "\treturn proc_density(vec3(x, y, z));\n"
        "}\n";

    DensityGrid grid;
    float org[3] = { (float)p_origin.x, (float)p_origin.y, (float)p_origin.z };
    grid.allocate(p_cells, p_voxel_size, org);

    DCMeshData md;
    const uint32_t quality = (get_use_adaptive_qef() ? 2u : 0u)
                           | (get_use_shortest_diagonal() ? 1u : 0u);
    if (!gpu_thread_->submit(grid, density_, baked_volume_glsl_.c_str(), nullptr, &md,
                             org, p_voxel_size, quality, nullptr, 0, 0u)) {
        UtilityFunctions::push_error(
            "DCTerrain.bake_volume_mesh: the GPU declined the submit. If the "
            "custom shader failed to compile, DCGpuThread prints that above.");
        return out;
    }

    const int vcount = (int)md.positions.size() / 3;
    if (vcount == 0 || md.indices.empty()) {
        // An empty result is a real answer -- a density with no zero crossing
        // in these bounds -- and it is worth distinguishing from a failure.
        UtilityFunctions::push_warning(
            "DCTerrain.bake_volume_mesh: the GPU meshed no surface. The density "
            "has no zero crossing inside the bounds given, so either the volume "
            "is empty there or the bounds miss it.");
        return out;
    }

    PackedVector3Array pos;
    pos.resize(vcount);
    Vector3 *pw = pos.ptrw();
    for (int i = 0; i < vcount; i++) {
        pw[i] = Vector3(md.positions[i * 3], md.positions[i * 3 + 1], md.positions[i * 3 + 2]);
    }
    PackedInt32Array idx;
    idx.resize((int)md.indices.size());
    int32_t *iw = idx.ptrw();
    for (size_t i = 0; i < md.indices.size(); i++) {
        iw[i] = md.indices[i];
    }

    Array arrays;
    arrays.resize(Mesh::ARRAY_MAX);
    arrays[Mesh::ARRAY_VERTEX] = pos;
    arrays[Mesh::ARRAY_INDEX] = idx;
    if ((int)md.normals.size() == vcount * 3) {
        PackedVector3Array nrm;
        nrm.resize(vcount);
        Vector3 *nw = nrm.ptrw();
        for (int i = 0; i < vcount; i++) {
            nw[i] = Vector3(md.normals[i * 3], md.normals[i * 3 + 1], md.normals[i * 3 + 2]);
        }
        arrays[Mesh::ARRAY_NORMAL] = nrm;
    }
    out.instantiate();
    out->add_surface_from_arrays(Mesh::PRIMITIVE_TRIANGLES, arrays);
    return out;
}

float DCTerrain::sample_density(const Vector3 &position) const {
    n_sample_density_.fetch_add(1, std::memory_order_relaxed);
    float d = generator_ptr_->evaluate(position.x, position.y, position.z, 0.0f);
    if (config_.edits && !config_.edits->empty()) {
        d = DCEditList::apply(*config_.edits, position.x, position.y, position.z, d);
    }
    return d;
}

// ---------------------------------------------------------------------------
// SDF collision — evaluate density with edits for correct collision.
// Generator no longer applies edits internally (they're per-chunk culled
// for workers), so collision queries apply the full edit list here.
// Single-point queries: O(all_edits) is fine (one call, not 32K).
// ---------------------------------------------------------------------------
float DCTerrain::_eval_with_edits(float x, float y, float z) const {
    n_eval_with_edits_.fetch_add(1, std::memory_order_relaxed);
    float d = generator_ptr_->evaluate(x, y, z, 0.0f);
    if (config_.edits && !config_.edits->empty()) {
        d = DCEditList::apply(*config_.edits, x, y, z, d);
    }
    return d;
}

float DCTerrain::get_distance(const Vector3 &position) const {
    return -_eval_with_edits(position.x, position.y, position.z);
}

Vector3 DCTerrain::get_surface_normal(const Vector3 &position) const {
    float h = voxel_size_ * 0.01f;
    float x = position.x, y = position.y, z = position.z;
    float gx = _eval_with_edits(x+h,y,z) - _eval_with_edits(x-h,y,z);
    float gy = _eval_with_edits(x,y+h,z) - _eval_with_edits(x,y-h,z);
    float gz = _eval_with_edits(x,y,z+h) - _eval_with_edits(x,y,z-h);
    float len = std::sqrt(gx*gx + gy*gy + gz*gz);
    if (len < 1e-8f) return Vector3(0, 1, 0);
    float inv = -1.0f / len;
    return Vector3(gx * inv, gy * inv, gz * inv);
}

Vector3 DCTerrain::get_capsule_push(const Vector3 &position, float radius, float height) const {
    float half_h = height * 0.5f;
    Vector3 points[3] = {
        Vector3(position.x, position.y - half_h + radius, position.z),
        position,
        Vector3(position.x, position.y + half_h - radius, position.z),
    };
    Vector3 total_push(0, 0, 0);
    for (int i = 0; i < 3; i++) {
        float dist = -_eval_with_edits(points[i].x, points[i].y, points[i].z);
        if (dist < radius) {
            Vector3 normal = get_surface_normal(points[i]);
            total_push += normal * (radius - dist);
        }
    }
    return total_push;
}

// Terrain editing
// ---------------------------------------------------------------------------
void DCTerrain::edit_terrain(const Vector3 &position, float radius, float strength, int op, bool interactive, int material_id) {
    // op: 0 = OP_SMOOTH (legacy metaball blend), 1 = OP_HARD (sharp SDF CSG,
    // union/difference by strength sign). Lets a sphere do the same hard CSG
    // ops as a box.
    TerrainEdit::CsgOp csg_op = (op == 1) ? TerrainEdit::OP_HARD : TerrainEdit::OP_SMOOTH;
    edit_list_.add_sphere(position.x, position.y, position.z, radius, strength, csg_op, (uint32_t)material_id);
    config_.edits = std::make_shared<const std::vector<TerrainEdit>>(edit_list_.snapshot());
    _propagate_config();

    // Incremental regeneration: find leaf cells that overlap the edit sphere
    // and re-queue them for generation instead of rebuilding the entire octree.
    auto leaves = octree_clipmap_.get_leaves();
    int requeued = 0;
    for (auto* node : leaves) {
        if (node->state != OctreeClipmapNode::ACTIVE &&
            node->state != OctreeClipmapNode::READY) continue;

        // Compute cell AABB center and half-extent (28 nominal voxels for 32-cell grid)
        float lv = lod_voxel_size(voxel_size_, node->lod_level);
        float cs = 28.0f * lv;
        float cx = node->position.x * voxel_size_ + cs * 0.5f;
        float cy = node->position.y * voxel_size_ + cs * 0.5f;
        float cz = node->position.z * voxel_size_ + cs * 0.5f;
        float half = cs * 0.5f;

        // Sphere-AABB overlap test
        float dx = std::max(0.0f, std::fabs(position.x - cx) - half);
        float dy = std::max(0.0f, std::fabs(position.y - cy) - half);
        float dz = std::max(0.0f, std::fabs(position.z - cz) - half);
        float dist_sq = dx * dx + dy * dy + dz * dz;

        if (dist_sq <= radius * radius) {
            // Keep old mesh visible — _upload_cell replaces atomically.
            // Only free collision (needs rebuilding with new density).
            if (node->collision_body.is_valid()) {
                PhysicsServer3D::get_singleton()->free_rid(node->collision_shape);
                PhysicsServer3D::get_singleton()->free_rid(node->collision_body);
                node->collision_body = RID();
                node->collision_shape = RID();
            }
            if (interactive) node->edit_dirty = true;  // LIVE edit only — priority lane (1b)
            octree_clipmap_.mark_pending(node);
            requeued++;
        }
    }

    if (requeued > 0) {
        UtilityFunctions::print("[EDIT] requeued ", requeued,
            " cells (pos=", position.x, ",", position.y, ",", position.z,
            " r=", radius, " s=", strength, ")");
    }
}

void DCTerrain::edit_terrain_box(const Vector3 &position, const Vector3 &half_extents, float strength,
                                 bool interactive, int material_id, bool persistent) {
    edit_list_.add_box(position.x, position.y, position.z,
                       half_extents.x, half_extents.y, half_extents.z, strength,
                       (uint32_t)material_id, persistent);
    // No edit focus point — edits are baked into the density function at all
    // LOD levels, so the correct shape appears at whatever resolution the camera
    // distance naturally provides. Focus points forced sub-LOD splitting (min_lod=-2)
    // which created thousands of tiny chunks per edit, overwhelming workers and
    // causing subsequent edits to never complete.
    config_.edits = std::make_shared<const std::vector<TerrainEdit>>(edit_list_.snapshot());
    _propagate_config();

    // Incremental regeneration: AABB-AABB overlap test.
    // No global generation bump — that strands non-overlapping GENERATING chunks
    // (their stale results get discarded but they're never re-queued).
    // Instead, overlapping GENERATING chunks get their node generation bumped
    // so their stale in-flight result is dropped, then re-queued.
    auto leaves = octree_clipmap_.get_leaves();
    int requeued = 0;
    for (auto* node : leaves) {
        if (node->state != OctreeClipmapNode::ACTIVE &&
            node->state != OctreeClipmapNode::READY &&
            node->state != OctreeClipmapNode::GENERATING) continue;

        float lv = lod_voxel_size(voxel_size_, node->lod_level);
        float cs = 28.0f * lv;
        float cx = node->position.x * voxel_size_ + cs * 0.5f;
        float cy = node->position.y * voxel_size_ + cs * 0.5f;
        float cz = node->position.z * voxel_size_ + cs * 0.5f;
        float half = cs * 0.5f;

        // AABB-AABB overlap — generous padding (2 voxels overlap + half chunk)
        // to catch boundary chunks that need the edit for correct seaming.
        float pad = lv * 4.0f;
        if (std::fabs(position.x - cx) <= half + half_extents.x + pad &&
            std::fabs(position.y - cy) <= half + half_extents.y + pad &&
            std::fabs(position.z - cz) <= half + half_extents.z + pad) {
            if (node->collision_body.is_valid()) {
                PhysicsServer3D::get_singleton()->free_rid(node->collision_shape);
                PhysicsServer3D::get_singleton()->free_rid(node->collision_body);
                node->collision_body = RID();
                node->collision_shape = RID();
            }
            // 1c: do NOT bump generation to drop the in-flight result. Under
            // rapid carving that livelocks — every new edit strands the just-
            // dispatched remesh, so nothing ever uploads ("edits work for the
            // first few then stop"). Instead let the in-flight result upload (it
            // carries a prefix of the current edits — a one-frame transient, far
            // better than showing nothing); edit_dirty (set below) + the
            // post-upload re-queue in _process guarantee exactly one follow-up
            // remesh with the latest edits. mark_pending() no-ops on a GENERATING
            // node, so the in-flight request is left undisturbed.
            // Invalidate pre-mesh cache for this node and its children.
            // BLOCKING_SWAP serves cached pre-meshes when splitting; if those
            // were generated before this edit, the post-split children render
            // without the edit baked in. Mark stale so the worker re-meshes
            // with the current edit list on the next pre-mesh request.
            {
                auto& cache = octree_clipmap_.pre_mesh_cache_ref();
                auto it = cache.find(node->morton_code);
                if (it != cache.end()) {
                    it->second.state = OctreeClipmapNode::MESH_NEVER_UPDATED;
                }
                for (int ci = 0; ci < 8; ci++) {
                    if (node->children[ci]) {
                        auto cit = cache.find(node->children[ci]->morton_code);
                        if (cit != cache.end()) {
                            cit->second.state = OctreeClipmapNode::MESH_NEVER_UPDATED;
                        }
                    }
                }
            }
            int prev_state = (int)node->state;
            if (interactive) node->edit_dirty = true;  // LIVE edit only — priority lane (1b)
            octree_clipmap_.mark_pending(node);
            requeued++;
            if (debug_logging_) {
                UtilityFunctions::print("[LIFE] EDIT_HIT chunk=(",
                    node->position.x, ",", node->position.y, ",", node->position.z,
                    ") lod=", node->lod_level, " prev_state=", prev_state,
                    " new_state=", (int)node->state, " node_gen=", node->generation,
                    " edit_pos=(", position.x, ",", position.y, ",", position.z, ") s=", strength);
            }
        }
    }

    if (requeued > 0 && debug_logging_) {
        UtilityFunctions::print("[EDIT-BOX] requeued ", requeued,
            " cells (pos=", position.x, ",", position.y, ",", position.z,
            " ext=", half_extents.x, ",", half_extents.y, ",", half_extents.z,
            " s=", strength, ")");
    }
    _update_debug_edits();
}

void DCTerrain::edit_terrain_convex(const PackedFloat32Array &planes, const Vector3 &center,
                                     float radius, float strength, bool interactive, int material_id,
                                     const PackedInt32Array &face_proj) {
    int nplanes = planes.size() / 4;
    if (nplanes < 4) {
        UtilityFunctions::printerr("[CONVEX] need >= 4 planes, got ", nplanes);
        return;
    }
    std::vector<ConvexPlane> pl;
    pl.reserve(nplanes);
    for (int i = 0; i < nplanes; i++) {
        ConvexPlane p;
        p.nx = planes[i * 4 + 0]; p.ny = planes[i * 4 + 1];
        p.nz = planes[i * 4 + 2]; p.d  = planes[i * 4 + 3];
        pl.push_back(p);
    }
    // Per-face projection indices (Stage-2 texturing). Parallel to planes; only kept when
    // the caller supplies one per plane (importer path) — else empty (bake uses no proj).
    std::vector<uint32_t> proj;
    if (face_proj.size() == nplanes) {
        proj.reserve(nplanes);
        for (int i = 0; i < nplanes; i++) proj.push_back((uint32_t)face_proj[i]);
    }
    edit_list_.add_convex(center.x, center.y, center.z, pl, strength, (uint32_t)material_id, proj);
    config_.edits = std::make_shared<const std::vector<TerrainEdit>>(edit_list_.snapshot());
    _propagate_config();

    // Requeue affected chunks — bounding-sphere overlap (like edit_terrain sphere).
    auto leaves = octree_clipmap_.get_leaves();
    int requeued = 0;
    for (auto* node : leaves) {
        if (node->state != OctreeClipmapNode::ACTIVE &&
            node->state != OctreeClipmapNode::READY) continue;
        float lv = lod_voxel_size(voxel_size_, node->lod_level);
        float cs = 28.0f * lv;
        float cx = node->position.x * voxel_size_ + cs * 0.5f;
        float cy = node->position.y * voxel_size_ + cs * 0.5f;
        float cz = node->position.z * voxel_size_ + cs * 0.5f;
        float half = cs * 0.5f;
        float dx = std::max(0.0f, std::fabs(center.x - cx) - half);
        float dy = std::max(0.0f, std::fabs(center.y - cy) - half);
        float dz = std::max(0.0f, std::fabs(center.z - cz) - half);
        if (dx * dx + dy * dy + dz * dz <= radius * radius) {
            if (node->collision_body.is_valid()) {
                PhysicsServer3D::get_singleton()->free_rid(node->collision_shape);
                PhysicsServer3D::get_singleton()->free_rid(node->collision_body);
                node->collision_body = RID();
                node->collision_shape = RID();
            }
            if (interactive) node->edit_dirty = true;  // LIVE edit only — priority lane
            octree_clipmap_.mark_pending(node);
            requeued++;
        }
    }
    if (debug_logging_) {
        UtilityFunctions::print("[CONVEX] ", nplanes, " planes at ", center,
            " r=", radius, " requeued ", requeued, " cells");
    }
}

// Re-queue every chunk a mesh stamp overlaps, on the terms edit_terrain_box
// established. Both stamp_mesh_* entry points had their own copy of this loop and
// both copies were the PRE-hardening version, so a mesh stamp silently failed to
// reach three classes of chunk:
//
//   1. chunks in GENERATING. The old filter took ACTIVE/READY only. A chunk that
//      was mid-mesh when the stamp landed had already been dispatched with a
//      pre-stamp edit list (cull_edits_for_chunk snapshots config_.edits AT
//      DISPATCH), and the stamp does not bump generation_, so its stale result is
//      ACCEPTED at the poll site and uploaded. Skipping it here meant nothing ever
//      asked it to try again: it kept pre-stamp geometry for the whole session.
//      That is terrain standing inside the level's rooms while debug_sdf_at at
//      those same points correctly reports air — the field was never the problem.
//
//   2. cached pre-meshes. BLOCKING_SWAP serves children from the pre-mesh cache on
//      an LOD split. Entries generated before the stamp stay MESH_UP_TO_DATE and
//      get swapped in wholesale later, re-introducing pre-stamp terrain as the
//      camera moves — the "it looks right from here, wrong from there" signature.
//
//   3. the follow-up remesh for a NON-interactive stamp. mark_pending() no-ops on
//      a GENERATING node by design, so the post-upload re-queue is the only thing
//      that can remesh case 1 — and it was gated on edit_dirty, which a
//      non-interactive stamp must not set (priority lane). remesh_after_upload
//      carries that guarantee without the lane. See OctreeClipmapNode.
//
// The sphere-AABB test and the 28-voxel cell span are unchanged from the copies
// this replaces; only the reach is fixed.
int DCTerrain::_requeue_chunks_for_mesh_stamp(const Vector3 &center, float radius, bool interactive) {
    auto leaves = octree_clipmap_.get_leaves();
    int requeued = 0;
    int deferred = 0;   // in flight — can only be reached by the post-upload re-queue
    for (auto* node : leaves) {
        if (node->state != OctreeClipmapNode::ACTIVE &&
            node->state != OctreeClipmapNode::READY &&
            node->state != OctreeClipmapNode::GENERATING) continue;

        float lv = lod_voxel_size(voxel_size_, node->lod_level);
        float cs = 28.0f * lv;
        float ncx = node->position.x * voxel_size_ + cs * 0.5f;
        float ncy = node->position.y * voxel_size_ + cs * 0.5f;
        float ncz = node->position.z * voxel_size_ + cs * 0.5f;
        float half = cs * 0.5f;
        float dx = std::max(0.0f, std::fabs(center.x - ncx) - half);
        float dy = std::max(0.0f, std::fabs(center.y - ncy) - half);
        float dz = std::max(0.0f, std::fabs(center.z - ncz) - half);
        if (dx*dx + dy*dy + dz*dz > radius * radius) continue;

        if (node->collision_body.is_valid()) {
            PhysicsServer3D::get_singleton()->free_rid(node->collision_shape);
            PhysicsServer3D::get_singleton()->free_rid(node->collision_body);
            node->collision_body = RID();
            node->collision_shape = RID();
        }

        // (2) Stale pre-meshes for this node and its children — mirrors
        // edit_terrain_box exactly.
        {
            auto& cache = octree_clipmap_.pre_mesh_cache_ref();
            auto it = cache.find(node->morton_code);
            if (it != cache.end()) {
                it->second.state = OctreeClipmapNode::MESH_NEVER_UPDATED;
            }
            for (int ci = 0; ci < 8; ci++) {
                if (node->children[ci]) {
                    auto cit = cache.find(node->children[ci]->morton_code);
                    if (cit != cache.end()) {
                        cit->second.state = OctreeClipmapNode::MESH_NEVER_UPDATED;
                    }
                }
            }
        }

        if (node->state == OctreeClipmapNode::GENERATING) deferred++;
        // (3) Always ask for the follow-up; consumed on dispatch, so a chunk that
        // mark_pending() takes right now clears it without a second remesh.
        node->remesh_after_upload = true;
        if (interactive) node->edit_dirty = true;  // LIVE stamp only — priority lane (1b)
        octree_clipmap_.mark_pending(node);
        requeued++;
    }
    // UNCONDITIONAL, like the shell's own seal proof. Once per stamp, not per
    // frame, and it is the one number that says whether the subtract actually
    // reached the geometry: `deferred` chunks are exactly the ones the pre-fix
    // code dropped on the floor, and `requeued == 0` means the stamp changed the
    // field and nothing that renders it.
    UtilityFunctions::print("[STAMP] requeued ", requeued, " cells (",
        deferred, " in flight -> post-upload remesh)");
    return requeued;
}

// THE CULL SPHERE IS ABOUT THE STAMP POSITION, WHICH IS THE MESH'S CENTROID —
// NOT THE CENTRE OF ITS BOUNDS. Those coincide only for a mesh whose vertices are
// symmetric about its box, and the level shell is the opposite of that: 284k
// vertices in the level plus ~2.5k in an arch 95 m away leaves the centroid
// essentially on the level while the AABB reaches all the way out to the arch.
//
// Both callers used half the AABB DIAGONAL, which is the radius of a sphere
// centred IN THE BOX. Off-centre, the far side of the box sticks out of it — and
// dc_edit.h's `if (dist2 > radius*radius) continue` then silently drops the edit
// at those points. MEASURED with debug_sdf_at: a point in an arch's bore at
// dist 94.57 against radius 82.52, mesh_d +0.300 (genuinely inside the shell),
// and a combined density identical to the base. The cut was correct and was
// being thrown away.
//
// It hid behind something unrelated. While that arch's clearance was
// terrain-measured it ran up to 64 m, which stretched the AABB enough that the
// half-diagonal happened to exceed 94.6 and the arch fell inside the sphere.
// Shortening the clearance shrank the sphere and the cut vanished — reported as
// "it worked one version ago", with nothing in the subtract path having changed.
//
// The correct radius is the FURTHEST AABB CORNER FROM THE STAMP POINT. The mesh
// is stored centred on its centroid, so its local bounds are already relative to
// that point and the furthest corner is the per-axis maximum |extent|.
// Was a second copy of the formula living beside DCEditList's. It is libdc's
// mesh_bounding_radius() now — the list and the focus point cannot disagree.

void DCTerrain::stamp_mesh_obj(const String &obj_path, const Vector3 &position, float scale, float strength, bool interactive, int material_id) {
    auto mesh_sdf = std::make_shared<DCGeneratorMeshSDF>();
    std::string path = obj_path.utf8().get_data();
    if (!mesh_sdf->load_obj(path)) {
        UtilityFunctions::printerr("[STAMP] Failed to load OBJ: ", obj_path);
        return;
    }
    if (debug_logging_) {
        UtilityFunctions::print("[STAMP] Loaded ", mesh_sdf->triangle_count(), " triangles from ", obj_path);
    }

    float bounding_radius = mesh_bounding_radius(
        std::static_pointer_cast<DCGenerator>(mesh_sdf), scale);

    if (debug_logging_) {
        UtilityFunctions::print("[STAMP] mesh edit radius=", bounding_radius, " at ", position);
    }
    // persistent=false: stamp_mesh_obj is the F2/dev stamp, a PLAYER edit — it
    // belongs to the journal and a map change should drop it. Only map-authored
    // landscape (the level shell, via stamp_mesh_arrays) survives clear_edits().
    edit_list_.add_mesh(position.x, position.y, position.z, scale, strength,
                        std::static_pointer_cast<DCGenerator>(mesh_sdf),
                        (uint32_t)material_id, false);
    float nominal_ws2 = 28.0f * lod_voxel_size(voxel_size_, 0);
    // LIVE stamp only: force fine sub-LOD near the stamp. A replayed stamp is
    // landscape (already baked into the edit list) — a persistent focus point there
    // would force min_lod=-2 forever and over-refine, so gate it out.
    if (interactive) octree_clipmap_.edit_focus_points.push_back({position.x, position.y, position.z, bounding_radius + nominal_ws2});

    config_.edits = std::make_shared<const std::vector<TerrainEdit>>(edit_list_.snapshot());
    _propagate_config();

    // Requeue affected chunks — WITH THE CULL RADIUS, which is what
    // stamp_mesh_arrays already passes. This was a THIRD formula: half the largest
    // AABB extent, i.e. a sphere about the BOX CENTRE, which is the same mistake
    // #41 fixed in the cull path and missed here. For an asymmetric mesh it
    // under-requeues, so chunks the edit really touches are never re-meshed. The
    // two stamp entry points now reach identically.
    _requeue_chunks_for_mesh_stamp(position, bounding_radius, interactive);
}

void DCTerrain::stamp_mesh_arrays(const PackedVector3Array &vertices, const PackedInt32Array &indices,
                                   const Vector3 &position, float scale, float strength, bool interactive,
                                   int material_id, bool persistent) {
    int num_verts = vertices.size();
    int num_tris = indices.size() / 3;
    if (num_verts < 3 || num_tris < 1) {
        UtilityFunctions::printerr("[STAMP] Invalid mesh: ", num_verts, " verts, ", num_tris, " tris");
        return;
    }

    auto mesh_sdf = std::make_shared<DCGeneratorMeshSDF>();
    // Center the mesh at origin before building SDF
    Vector3 center = Vector3(0, 0, 0);
    for (int i = 0; i < num_verts; i++) center += vertices[i];
    center /= num_verts;

    std::vector<float> pos_data(num_verts * 3);
    for (int i = 0; i < num_verts; i++) {
        pos_data[i*3+0] = vertices[i].x - center.x;
        pos_data[i*3+1] = vertices[i].y - center.y;
        pos_data[i*3+2] = vertices[i].z - center.z;
    }
    std::vector<int> idx_data(indices.size());
    for (int i = 0; i < (int)indices.size(); i++) {
        idx_data[i] = indices[i];
    }

    if (!mesh_sdf->load_arrays(pos_data.data(), num_verts, idx_data.data(), num_tris)) {
        UtilityFunctions::printerr("[STAMP] Failed to build mesh SDF from arrays");
        return;
    }
    if (debug_logging_) {
        UtilityFunctions::print("[STAMP] Built mesh SDF: ", mesh_sdf->triangle_count(), " tris from ", num_verts, " verts");
    }

    float bounding_radius = mesh_bounding_radius(
        std::static_pointer_cast<DCGenerator>(mesh_sdf), scale);

    edit_list_.add_mesh(position.x, position.y, position.z, scale, strength,
                        std::static_pointer_cast<DCGenerator>(mesh_sdf),
                        (uint32_t)material_id, persistent);
    // Register edit focus point for sub-LOD splitting
    float nominal_ws = 28.0f * lod_voxel_size(voxel_size_, 0);
    // LIVE stamp only (see stamp_mesh_obj) — a replayed stamp is landscape.
    if (interactive) octree_clipmap_.edit_focus_points.push_back({position.x, position.y, position.z, bounding_radius + nominal_ws});
    config_.edits = std::make_shared<const std::vector<TerrainEdit>>(edit_list_.snapshot());
    _propagate_config();

    // Requeue affected chunks
    _requeue_chunks_for_mesh_stamp(position, bounding_radius, interactive);
}

void DCTerrain::debug_sdf_at(const Vector3 &position) {
    auto edits = edit_list_.snapshot();
    UtilityFunctions::print("[SDF-DEBUG] World pos: (", position.x, ",", position.y, ",", position.z, ")");

    // Evaluate base terrain density
    float base_d = density_.evaluate(position.x, position.y, position.z, voxel_size_);
    UtilityFunctions::print("[SDF-DEBUG] Base terrain density: ", base_d);

    for (int i = 0; i < (int)edits.size(); i++) {
        const auto& e = edits[i];
        if (e.shape == TerrainEdit::MESH && e.mesh_sdf) {
            float mdx = position.x - e.x, mdy = position.y - e.y, mdz = position.z - e.z;
            float dist_to_center = std::sqrt(mdx*mdx + mdy*mdy + mdz*mdz);
            float lx = mdx / e.scale, ly = mdy / e.scale, lz = mdz / e.scale;
            float mesh_d = e.mesh_sdf->evaluate(lx, ly, lz, 0.0f);

            float bmin[3], bmax[3];
            e.mesh_sdf->get_bounds(bmin, bmax);
            bool in_aabb = lx >= bmin[0] && lx <= bmax[0] &&
                           ly >= bmin[1] && ly <= bmax[1] &&
                           lz >= bmin[2] && lz <= bmax[2];

            UtilityFunctions::print("[SDF-DEBUG] Edit ", i, " MESH: stamp=(",
                e.x, ",", e.y, ",", e.z, ") dist=", dist_to_center,
                " local=(", lx, ",", ly, ",", lz, ") mesh_d=", mesh_d,
                " in_aabb=", in_aabb, " radius=", e.radius, " scale=", e.scale);
        } else if (e.shape == TerrainEdit::BOX) {
            UtilityFunctions::print("[SDF-DEBUG] Edit ", i, " BOX at (",
                e.x, ",", e.y, ",", e.z, ")");
        }
    }

    // Evaluate combined density (terrain + all edits)
    float combined = DCEditList::apply(edits, position.x, position.y, position.z, base_d);
    UtilityFunctions::print("[SDF-DEBUG] Combined density: ", combined,
        " (positive=solid, negative=air)");
}

void DCTerrain::clear_edits() {
    // Drops the PLAYER journal only. Map-authored landscape survives — the §5a.2
    // level shell is one MESH edit stamped by the map during import, and this is
    // called on every map change by the game's terrain-edit manager, AFTER that
    // import has already run. Resetting config_.edits unconditionally here is what
    // put terrain back inside START.MAP's rooms: the shell was stamped, wiped a
    // few hundred ms later, and the rebuild() below regenerated every chunk from a
    // field with no subtract in it.
    int kept = edit_list_.clear();
    if (kept > 0) {
        config_.edits = std::make_shared<const std::vector<TerrainEdit>>(edit_list_.snapshot());
    } else {
        config_.edits.reset();
    }
    octree_clipmap_.edit_focus_points.clear();
    _propagate_config();
    UtilityFunctions::print("[DCTerrain] clear_edits() — player journal dropped, ",
        kept, " landscape edit(s) kept");
    rebuild();
}

int DCTerrain::get_edit_count() const {
    return edit_list_.count();
}

// --- Interactive brush editing (test mutability): pick / move / query a brush ---
// pick_brush: return the index of the convex/box/sphere edit containing world point p
// (nearest centre among containers). -1 if none. Point must be in terrain-local space.
int DCTerrain::pick_brush(const Vector3 &p) const {
    auto edits = edit_list_.snapshot();
    // v4 — rank brushes by distance from the hit to the brush SURFACE (|SDF|), inside OR
    // outside. The hit rides the *rendered* DC surface (which carries ~1 voxel of mesh
    // offset), so the brush that formed it sits ~0 from its surface. A strict "contains
    // the point" test wrongly drops thin walls and leaves only the big subtractive carve
    // void (test 2026-07-14: the only CONTAINING brush was the carve box, 13m deep). We
    // prefer ADDITIVE brushes (strength > 0): the carve void (strength < 0) and huge
    // enclosing brushes are never what you want to grab.
    std::vector<std::pair<float, int>> cands;  // (surface distance, edit index)
    for (int i = 0; i < (int)edits.size(); i++) {
        const TerrainEdit &e = edits[i];
        float sd;  // magnitude of distance from p to the brush surface
        if (e.shape == TerrainEdit::CONVEX) {
            if (e.planes.empty()) continue;
            float mx = -1e30f;  // convex SDF = max over outward half-spaces of (n·p - d)
            for (const auto &pl : e.planes)
                mx = std::max(mx, pl.nx * p.x + pl.ny * p.y + pl.nz * p.z - pl.d);
            sd = std::fabs(mx);
        } else if (e.shape == TerrainEdit::BOX) {
            float qx = std::fabs(p.x - e.x) - e.half_ext[0];
            float qy = std::fabs(p.y - e.y) - e.half_ext[1];
            float qz = std::fabs(p.z - e.z) - e.half_ext[2];
            float ox = std::max(qx, 0.0f), oy = std::max(qy, 0.0f), oz = std::max(qz, 0.0f);
            float outside = std::sqrt(ox * ox + oy * oy + oz * oz);   // box SDF (exact outside)
            float inside = std::min(std::max(qx, std::max(qy, qz)), 0.0f);
            sd = std::fabs(outside + inside);
        } else if (e.shape == TerrainEdit::SPHERE) {
            float dx = p.x - e.x, dy = p.y - e.y, dz = p.z - e.z;
            sd = std::fabs(std::sqrt(dx * dx + dy * dy + dz * dz) - e.radius);
        } else {
            continue;  // MESH: no cheap surface metric — skip for picking
        }
        cands.push_back(std::make_pair(sd, i));
    }
    std::sort(cands.begin(), cands.end());
    // Pick the nearest-surface ADDITIVE brush; fall back to nearest of any kind.
    int best = -1;
    for (const auto &c : cands) {
        if (edits[c.second].strength > 0.0f) { best = c.second; break; }
    }
    if (best < 0 && !cands.empty()) best = cands[0].second;
    // Ground-truth diagnostic: top candidates by surface distance (H press only, not
    // per-frame). ADD/SUB shows the additive-vs-carve split; surfdist ~0 = the surface
    // you're aiming at. Lets us confirm the pick from data instead of guessing.
    const char *shape_name[] = {"SPHERE", "BOX", "MESH", "CONVEX"};
    UtilityFunctions::print("[pick_brush] hit=", p, "  ", (int)cands.size(),
                            " brush(es); picked #", best);
    for (int i = 0; i < (int)cands.size() && i < 6; i++) {
        int idx = cands[i].second;
        const TerrainEdit &e = edits[idx];
        int sh = (int)e.shape;
        const char *sn = (sh >= 0 && sh < 4) ? shape_name[sh] : "?";
        UtilityFunctions::print("    cand #", idx, "  ", sn,
                                (e.strength > 0.0f ? " ADD " : " SUB "),
                                " surfdist=", cands[i].first,
                                "  center=", Vector3(e.x, e.y, e.z),
                                (idx == best) ? "   <== PICKED" : "");
    }
    return best;
}

Vector3 DCTerrain::get_brush_center(int idx) const {
    auto edits = edit_list_.snapshot();
    if (idx < 0 || idx >= (int)edits.size()) return Vector3();
    return Vector3(edits[idx].x, edits[idx].y, edits[idx].z);
}

// get_brush_radius: bounding radius of edit `idx` (sphere/convex radius; box half-extent
// diagonal). Lets the GDScript marker size itself to the brush for clear feedback.
float DCTerrain::get_brush_radius(int idx) const {
    auto edits = edit_list_.snapshot();
    if (idx < 0 || idx >= (int)edits.size()) return 0.0f;
    const TerrainEdit &e = edits[idx];
    if (e.shape == TerrainEdit::BOX)
        return std::sqrt(e.half_ext[0] * e.half_ext[0] + e.half_ext[1] * e.half_ext[1] +
                         e.half_ext[2] * e.half_ext[2]);
    return e.radius;
}

// get_brush_hull_triangles: triangulated convex hull of brush `idx` as a flat triangle
// soup (3 verts/tri) in coordinates RELATIVE TO THE BRUSH CENTER. Lets the GDScript brush
// editor build a faithful preview mesh it moves live, committing the real terrain re-mesh
// only on release (H). Empty for shapes with no cheap hull (sphere → GDScript falls back).
PackedVector3Array DCTerrain::get_brush_hull_triangles(int idx) const {
    PackedVector3Array out;
    auto edits = edit_list_.snapshot();
    if (idx < 0 || idx >= (int)edits.size()) return out;
    const TerrainEdit &e = edits[idx];
    Vector3 c(e.x, e.y, e.z);

    if (e.shape == TerrainEdit::BOX) {
        float hx = e.half_ext[0], hy = e.half_ext[1], hz = e.half_ext[2];
        Vector3 v[8] = {
            Vector3(-hx, -hy, -hz), Vector3(hx, -hy, -hz), Vector3(hx, hy, -hz), Vector3(-hx, hy, -hz),
            Vector3(-hx, -hy, hz),  Vector3(hx, -hy, hz),  Vector3(hx, hy, hz),  Vector3(-hx, hy, hz)
        };
        static const int quad[6][4] = {
            {0, 1, 2, 3}, {5, 4, 7, 6}, {4, 5, 1, 0}, {3, 2, 6, 7}, {1, 5, 6, 2}, {4, 0, 3, 7}
        };
        for (int f = 0; f < 6; f++) {
            const int *q = quad[f];
            out.push_back(v[q[0]]); out.push_back(v[q[1]]); out.push_back(v[q[2]]);
            out.push_back(v[q[0]]); out.push_back(v[q[2]]); out.push_back(v[q[3]]);
        }
        return out;
    }

    if (e.shape != TerrainEdit::CONVEX || e.planes.empty()) return out;
    int n = (int)e.planes.size();

    // 1. Hull vertices = plane-triple intersections lying inside every plane (dedup'd).
    std::vector<Vector3> verts;
    for (int i = 0; i < n; i++)
        for (int j = i + 1; j < n; j++)
            for (int k = j + 1; k < n; k++) {
                Vector3 ni(e.planes[i].nx, e.planes[i].ny, e.planes[i].nz);
                Vector3 nj(e.planes[j].nx, e.planes[j].ny, e.planes[j].nz);
                Vector3 nk(e.planes[k].nx, e.planes[k].ny, e.planes[k].nz);
                float denom = ni.dot(nj.cross(nk));
                if (std::fabs(denom) < 1e-6f) continue;
                Vector3 p = (nj.cross(nk) * e.planes[i].d + nk.cross(ni) * e.planes[j].d +
                             ni.cross(nj) * e.planes[k].d) / denom;
                bool inside = true;
                for (int m = 0; m < n; m++)
                    if (e.planes[m].nx * p.x + e.planes[m].ny * p.y + e.planes[m].nz * p.z -
                        e.planes[m].d > 1e-3f) { inside = false; break; }
                if (!inside) continue;
                bool dup = false;
                for (const auto &q : verts) if ((q - p).length_squared() < 1e-6f) { dup = true; break; }
                if (!dup) verts.push_back(p);
            }
    if (verts.size() < 4) return out;

    // 2. Per plane: gather its vertices, sort CCW around the normal, fan-triangulate.
    for (int pi = 0; pi < n; pi++) {
        Vector3 nrm(e.planes[pi].nx, e.planes[pi].ny, e.planes[pi].nz);
        float d = e.planes[pi].d;
        std::vector<Vector3> face;
        for (const auto &v : verts)
            if (std::fabs(nrm.dot(v) - d) < 1e-3f) face.push_back(v);
        if (face.size() < 3) continue;
        Vector3 cen(0, 0, 0);
        for (const auto &v : face) cen += v;
        cen /= (float)face.size();
        Vector3 t = std::fabs(nrm.x) < 0.9f ? Vector3(1, 0, 0) : Vector3(0, 1, 0);
        Vector3 b = nrm.cross(t).normalized();
        t = b.cross(nrm).normalized();
        std::sort(face.begin(), face.end(), [&](const Vector3 &a, const Vector3 &bb) {
            return std::atan2(b.dot(a - cen), t.dot(a - cen)) <
                   std::atan2(b.dot(bb - cen), t.dot(bb - cen));
        });
        for (int m = 1; m + 1 < (int)face.size(); m++) {
            out.push_back(face[0] - c);
            out.push_back(face[m] - c);
            out.push_back(face[m + 1] - c);
        }
    }
    return out;
}

// move_brush: translate edit `idx` by delta, then re-mesh the affected region. The old
// invalidate_settled() only unsettled the traversal — it never dirtied any chunk, so the
// brush lingered at its old cell (ghost "copies"), the new cell re-meshed late or not at
// all (invisible mid-move), and nothing rode the interactive priority lane (no realtime
// feedback). Fix = targeted requeue of the chunks overlapping the OLD and NEW brush bounds
// on the edit_dirty priority lane, mirroring edit_terrain_convex's proven path: the old
// cell re-meshes (brush removed → no copies) and the new cell re-meshes fast (visible).
void DCTerrain::move_brush(int idx, const Vector3 &delta) {
    auto before = edit_list_.snapshot();
    if (idx < 0 || idx >= (int)before.size()) return;
    const TerrainEdit &e0 = before[idx];
    float ox = e0.x, oy = e0.y, oz = e0.z;
    float r = e0.radius;
    if (e0.shape == TerrainEdit::BOX)
        r = std::sqrt(e0.half_ext[0] * e0.half_ext[0] + e0.half_ext[1] * e0.half_ext[1] +
                      e0.half_ext[2] * e0.half_ext[2]);
    if (r <= 0.0f) r = voxel_size_ * 2.0f;  // fallback for a degenerate bound

    if (!edit_list_.move_edit(idx, delta.x, delta.y, delta.z)) return;
    config_.edits = std::make_shared<const std::vector<TerrainEdit>>(edit_list_.snapshot());
    _propagate_config();

    // Requeue every ACTIVE/READY leaf whose cell overlaps the brush bounding sphere,
    // freeing stale collision and marking edit_dirty (priority lane) + pending.
    auto requeue_sphere = [&](float cx, float cy, float cz, float rad) {
        auto leaves = octree_clipmap_.get_leaves();
        for (auto *node : leaves) {
            if (node->state != OctreeClipmapNode::ACTIVE &&
                node->state != OctreeClipmapNode::READY) continue;
            float lv = lod_voxel_size(voxel_size_, node->lod_level);
            float cs = 28.0f * lv;
            float ncx = node->position.x * voxel_size_ + cs * 0.5f;
            float ncy = node->position.y * voxel_size_ + cs * 0.5f;
            float ncz = node->position.z * voxel_size_ + cs * 0.5f;
            float half = cs * 0.5f;
            float dx = std::max(0.0f, std::fabs(cx - ncx) - half);
            float dy = std::max(0.0f, std::fabs(cy - ncy) - half);
            float dz = std::max(0.0f, std::fabs(cz - ncz) - half);
            if (dx * dx + dy * dy + dz * dz <= rad * rad) {
                if (node->collision_body.is_valid()) {
                    PhysicsServer3D::get_singleton()->free_rid(node->collision_shape);
                    PhysicsServer3D::get_singleton()->free_rid(node->collision_body);
                    node->collision_body = RID();
                    node->collision_shape = RID();
                }
                node->edit_dirty = true;
                octree_clipmap_.mark_pending(node);
            }
        }
    };
    requeue_sphere(ox, oy, oz, r);                                 // clear the old cell
    requeue_sphere(ox + delta.x, oy + delta.y, oz + delta.z, r);  // show the new cell
}

// lift_brush: remove convex brush `idx` from the terrain (re-mesh → it disappears) and
// return its full definition, so the editor can show only the ghost preview while moving
// and re-add the real brush at the drop position on commit. Returns an empty Dictionary
// for non-convex brushes (caller keeps the brush in place and previews without hiding).
Dictionary DCTerrain::lift_brush(int idx) {
    Dictionary out;
    auto edits = edit_list_.snapshot();
    if (idx < 0 || idx >= (int)edits.size()) return out;
    const TerrainEdit &e = edits[idx];
    if (e.shape != TerrainEdit::CONVEX || e.planes.empty()) return out;

    PackedFloat32Array planes;
    for (const auto &pl : e.planes) {
        planes.push_back(pl.nx); planes.push_back(pl.ny);
        planes.push_back(pl.nz); planes.push_back(pl.d);
    }
    out["planes"] = planes;
    out["center"] = Vector3(e.x, e.y, e.z);
    out["radius"] = e.radius;
    out["strength"] = e.strength;
    out["material"] = (int)e.material_id;

    float cx = e.x, cy = e.y, cz = e.z;
    float r = e.radius > 0.0f ? e.radius : voxel_size_ * 2.0f;

    if (!edit_list_.remove_edit(idx)) return Dictionary();
    config_.edits = std::make_shared<const std::vector<TerrainEdit>>(edit_list_.snapshot());
    _propagate_config();

    // Re-mesh the region so the lifted brush disappears (same targeted requeue as move).
    auto leaves = octree_clipmap_.get_leaves();
    for (auto *node : leaves) {
        if (node->state != OctreeClipmapNode::ACTIVE &&
            node->state != OctreeClipmapNode::READY) continue;
        float lv = lod_voxel_size(voxel_size_, node->lod_level);
        float cs = 28.0f * lv;
        float ncx = node->position.x * voxel_size_ + cs * 0.5f;
        float ncy = node->position.y * voxel_size_ + cs * 0.5f;
        float ncz = node->position.z * voxel_size_ + cs * 0.5f;
        float half = cs * 0.5f;
        float dx = std::max(0.0f, std::fabs(cx - ncx) - half);
        float dy = std::max(0.0f, std::fabs(cy - ncy) - half);
        float dz = std::max(0.0f, std::fabs(cz - ncz) - half);
        if (dx * dx + dy * dy + dz * dz <= r * r) {
            if (node->collision_body.is_valid()) {
                PhysicsServer3D::get_singleton()->free_rid(node->collision_shape);
                PhysicsServer3D::get_singleton()->free_rid(node->collision_body);
                node->collision_body = RID();
                node->collision_shape = RID();
            }
            node->edit_dirty = true;
            octree_clipmap_.mark_pending(node);
        }
    }
    return out;
}

Array DCTerrain::get_edits_as_array() const {
    Array result;
    auto edits = edit_list_.snapshot();
    for (const auto& e : edits) {
        Dictionary d;
        if (e.shape == TerrainEdit::SPHERE) {
            d["type"] = "sphere";
            d["pos"] = Vector3(e.x, e.y, e.z);
            d["radius"] = e.radius;
            d["strength"] = e.strength;
        } else if (e.shape == TerrainEdit::BOX) {
            d["type"] = "box";
            d["pos"] = Vector3(e.x, e.y, e.z);
            d["ext"] = Vector3(e.half_ext[0], e.half_ext[1], e.half_ext[2]);
            d["strength"] = e.strength;
        }
        // Mesh edits not serializable (contain shared_ptr to SDF)
        if (!d.is_empty()) result.push_back(d);
    }
    return result;
}

// Drop map-authored landscape, keep the player journal. Lets a map RE-DERIVE its
// landscape (the level shell) at runtime: clear, rebuild the mesh, stamp again.
// Without it a re-stamp is additive, and two overlapping mesh SDFs in one chunk
// evaluate against a single BVH — see DCEditList::clear_landscape.
//
// Deliberately does NOT rebuild(): the caller stamps immediately afterwards, and
// rebuilding between the two would tear down the octree only to tear it down
// again one call later.
void DCTerrain::clear_landscape_edits() {
    int dropped = edit_list_.clear_landscape();
    config_.edits = std::make_shared<const std::vector<TerrainEdit>>(edit_list_.snapshot());
    _propagate_config();
    UtilityFunctions::print("[DCTerrain] clear_landscape_edits() — dropped ", dropped,
        " landscape edit(s), player journal kept");
}

void DCTerrain::load_edits_from_array(const Array &edits_array) {
    // clear(), NOT clear_all(): mesh edits are not serializable (see the
    // serializer above), so landscape stamped by the map could never be restored
    // from this array. Keeping it is the only way it survives a journal reload.
    edit_list_.clear();
    for (int i = 0; i < edits_array.size(); i++) {
        Dictionary d = edits_array[i];
        String type = d.get("type", "");
        Vector3 pos = d.get("pos", Vector3());
        float strength = d.get("strength", 1.0f);

        if (type == "sphere") {
            float radius = d.get("radius", 1.0f);
            edit_list_.add_sphere(pos.x, pos.y, pos.z, radius, strength);
        } else if (type == "box") {
            Vector3 ext = d.get("ext", Vector3(1, 1, 1));
            edit_list_.add_box(pos.x, pos.y, pos.z, ext.x, ext.y, ext.z, strength);
        }
    }
    config_.edits = std::make_shared<const std::vector<TerrainEdit>>(edit_list_.snapshot());
    _propagate_config();
    rebuild();
    UtilityFunctions::print("[DCTerrain] Loaded ", edits_array.size(), " edits");
}

void DCTerrain::add_csg_primitive(int type, int op, int group,
                                   const Vector3 &position, const Vector3 &size) {
    CSGPrimitive prim;
    prim.type = static_cast<CSGPrimitive::Type>(type);
    prim.op = static_cast<CSGPrimitive::Op>(op);
    prim.group = group;
    prim.center[0] = position.x;
    prim.center[1] = position.y;
    prim.center[2] = position.z;
    prim.size[0] = size.x;
    prim.size[1] = size.y;
    prim.size[2] = size.z;
    config_.csg_primitives.push_back(prim);
    // Keep sorted by group for sequential evaluation
    std::sort(config_.csg_primitives.begin(), config_.csg_primitives.end(),
              [](const CSGPrimitive& a, const CSGPrimitive& b) { return a.group < b.group; });
}

void DCTerrain::clear_csg_primitives() {
    config_.csg_primitives.clear();
}

int DCTerrain::get_csg_primitive_count() const {
    return static_cast<int>(config_.csg_primitives.size());
}

// Requeue all chunks that overlap a sphere centered at `center` with `radius`.
// Used for incremental CSG updates — only regenerate affected chunks.
void DCTerrain::_requeue_affected_chunks(const float center[3], float radius) {
    auto leaves = octree_clipmap_.get_leaves();
    for (auto* node : leaves) {
        if (node->state != OctreeClipmapNode::ACTIVE &&
            node->state != OctreeClipmapNode::READY) continue;

        // Cell AABB in world space (use 14 nominal voxels for cell size)
        float lv = lod_voxel_size(voxel_size_, node->lod_level);
        float cs = 14.0f * lv;
        float cx = node->position.x * voxel_size_ + cs * 0.5f;
        float cy = node->position.y * voxel_size_ + cs * 0.5f;
        float cz = node->position.z * voxel_size_ + cs * 0.5f;
        float half = cs * 0.5f;

        // Sphere-AABB overlap test
        float dx = std::max(0.0f, std::fabs(center[0] - cx) - half);
        float dy = std::max(0.0f, std::fabs(center[1] - cy) - half);
        float dz = std::max(0.0f, std::fabs(center[2] - cz) - half);
        if (dx * dx + dy * dy + dz * dz <= radius * radius) {
            _on_node_free(node, this);
            node->edit_dirty = true;  // user edit — worker priority lane (1b)
            octree_clipmap_.mark_pending(node);
        }
    }
}

void DCTerrain::set_csg_primitive_position(int index, const Vector3 &position) {
    if (index < 0 || index >= static_cast<int>(config_.csg_primitives.size())) return;

    _drain_workers();

    auto& prim = config_.csg_primitives[index];

    // Compute bounding radius of the old and new positions for incremental rebuild.
    // Use the primitive's maximum extent as radius.
    float extent = std::max({prim.size[0], prim.size[1], prim.size[2]});

    // Requeue chunks affected by the OLD position
    float old_center[3] = { prim.center[0], prim.center[1], prim.center[2] };
    _requeue_affected_chunks(old_center, extent + voxel_size_ * 4.0f);

    // Update position
    prim.center[0] = position.x;
    prim.center[1] = position.y;
    prim.center[2] = position.z;

    // Requeue chunks affected by the NEW position
    float new_center[3] = { position.x, position.y, position.z };
    _requeue_affected_chunks(new_center, extent + voxel_size_ * 4.0f);

    _propagate_config();
}

void DCTerrain::set_csg_primitive_size(int index, const Vector3 &size) {
    if (index < 0 || index >= static_cast<int>(config_.csg_primitives.size())) return;

    _drain_workers();

    auto& prim = config_.csg_primitives[index];

    // Requeue with the larger of old and new extents
    float old_extent = std::max({prim.size[0], prim.size[1], prim.size[2]});
    float new_extent = std::max({size.x, size.y, size.z});
    float max_extent = std::max(old_extent, new_extent);

    float center[3] = { prim.center[0], prim.center[1], prim.center[2] };
    _requeue_affected_chunks(center, max_extent + voxel_size_ * 4.0f);

    prim.size[0] = size.x;
    prim.size[1] = size.y;
    prim.size[2] = size.z;

    _propagate_config();
}

void DCTerrain::remove_csg_primitive(int index) {
    if (index < 0 || index >= static_cast<int>(config_.csg_primitives.size())) return;

    _drain_workers();

    auto& prim = config_.csg_primitives[index];
    float extent = std::max({prim.size[0], prim.size[1], prim.size[2]});
    float center[3] = { prim.center[0], prim.center[1], prim.center[2] };

    config_.csg_primitives.erase(config_.csg_primitives.begin() + index);

    _requeue_affected_chunks(center, extent + voxel_size_ * 4.0f);
    _propagate_config();
}

// ---------------------------------------------------------------------------
// Generator resource property
// ---------------------------------------------------------------------------
void DCTerrain::set_generator(const Ref<DCTerrainGenerator> &p_generator) {
    // Disconnect from old generator's changed signal
    if (generator_resource_.is_valid() && generator_resource_->is_connected("changed", callable_mp(this, &DCTerrain::_on_generator_changed))) {
        generator_resource_->disconnect("changed", callable_mp(this, &DCTerrain::_on_generator_changed));
    }

    generator_resource_ = p_generator;
    if (generator_resource_.is_valid()) {
        DCGenerator* native = generator_resource_->get_native_generator();
        if (native) {
            generator_ptr_ = native;
            warned_generator_fallback_ = false;
            using_builtin_generator_ = false;
        } else {
            _fallback_to_builtin("set_generator");
        }
        // Connect to generator's changed signal so parameter edits trigger rebuild
        generator_resource_->connect("changed", callable_mp(this, &DCTerrain::_on_generator_changed));
    } else {
        // Null resource — fall back to built-in noise generator
        generator_ptr_ = &noise_generator_;
    }
    if (is_inside_tree()) {
        _propagate_config();
        rebuild();
    }
}

Ref<DCTerrainGenerator> DCTerrain::get_generator() const {
    return generator_resource_;
}

void DCTerrain::_on_generator_changed() {
    if (is_inside_tree()) {
        rebuild();
    }
}

void DCTerrain::set_spherical(bool p_val) {
    config_.spherical = p_val;
    if (is_inside_tree()) { _propagate_config(); }
}
bool DCTerrain::get_spherical() const { return config_.spherical; }
void DCTerrain::set_floating_islands(bool p_val) {
    config_.floating_islands = p_val;
    if (is_inside_tree()) { _propagate_config(); }
}
bool DCTerrain::get_floating_islands() const { return config_.floating_islands; }
void DCTerrain::set_island_radius(float p_val) {
    config_.island_radius = p_val;
    if (is_inside_tree()) { _propagate_config(); }
}
float DCTerrain::get_island_radius() const { return config_.island_radius; }
void DCTerrain::set_island_depth_ratio(float p_val) {
    config_.island_depth_ratio = p_val;
    if (is_inside_tree()) { _propagate_config(); }
}
float DCTerrain::get_island_depth_ratio() const { return config_.island_depth_ratio; }
void DCTerrain::set_island_spacing(float p_val) {
    config_.island_spacing = p_val;
    if (is_inside_tree()) { _propagate_config(); }
}
float DCTerrain::get_island_spacing() const { return config_.island_spacing; }
void DCTerrain::set_planet_radius(float p_val) {
    config_.planet_radius = p_val;
    if (is_inside_tree()) { _propagate_config(); }
}
float DCTerrain::get_planet_radius() const { return config_.planet_radius; }

void DCTerrain::set_screen_split_threshold(float p_val) {
    octree_clipmap_.screen_split_threshold = p_val;
    octree_clipmap_.screen_merge_threshold = p_val * 0.5f;  // hysteresis
}
float DCTerrain::get_screen_split_threshold() const {
    return octree_clipmap_.screen_split_threshold;
}
void DCTerrain::set_use_screen_space_error(bool p_val) {
    octree_clipmap_.use_screen_space_error = p_val;
}
bool DCTerrain::get_use_screen_space_error() const {
    return octree_clipmap_.use_screen_space_error;
}

void DCTerrain::set_lod_factor(float p_val) {
    lod_factor_ = p_val;
    octree_clipmap_.lod_factor = p_val;
}
float DCTerrain::get_lod_factor() const { return lod_factor_; }

void DCTerrain::set_hysteresis(float p_val) {
    hysteresis_ = p_val;
    octree_clipmap_.hysteresis = p_val;
}
float DCTerrain::get_hysteresis() const { return hysteresis_; }

void DCTerrain::set_fade_speed(float p_val) { fade_speed_ = p_val; }
float DCTerrain::get_fade_speed() const { return fade_speed_; }

void DCTerrain::set_normal_perturbation(float p_val) {
    normal_perturbation_ = p_val;
    // Update all existing lod materials
    for (int i = 0; i < MAX_LOD_MATERIALS; i++) {
        if (lod_materials_[i].is_valid()) {
            lod_materials_[i]->set_shader_parameter("normal_perturbation", p_val);
        }
    }
    // Update active fade overrides
    for (auto& [node, mat] : fade_overrides_) {
        if (mat.is_valid()) {
            mat->set_shader_parameter("normal_perturbation", p_val);
        }
    }
}
float DCTerrain::get_normal_perturbation() const { return normal_perturbation_; }

void DCTerrain::set_debug_lod_colors(bool p_val) {
    debug_lod_colors_ = p_val;
    // Invalidate cached materials so they rebuild with new colors
    for (int i = 0; i < MAX_LOD_MATERIALS; i++) {
        lod_materials_[i].unref();
    }
    // LOD_COLORS table (same as _upload_cell)
    static const Color LOD_COLORS[] = {
        Color(0.2f, 0.8f, 0.2f), Color(0.2f, 0.6f, 0.8f),
        Color(0.8f, 0.8f, 0.2f), Color(0.8f, 0.4f, 0.1f),
        Color(0.8f, 0.2f, 0.2f), Color(0.6f, 0.2f, 0.8f),
        Color(0.2f, 0.2f, 0.8f), Color(0.8f, 0.6f, 0.8f),
        Color(0.4f, 0.4f, 0.4f), Color(1.0f, 1.0f, 1.0f),
        Color(0.5f, 0.3f, 0.1f), Color(0.1f, 0.5f, 0.5f),
    };
    // Recolor all existing chunk meshes
    for (auto& [node, mesh] : node_meshes_) {
        if (!mesh.is_valid() || mesh->get_surface_count() == 0) continue;
        int lod = node->lod_level;
        if (lod < 0) lod = 0;
        if (lod >= MAX_LOD_MATERIALS) lod = MAX_LOD_MATERIALS - 1;
        // Lazy-init material (unified ShaderMaterial)
        if (lod_materials_[lod].is_null()) {
            lod_materials_[lod].instantiate();
            lod_materials_[lod]->set_shader(terrain_shader_);
            _apply_texture_params(lod_materials_[lod]);
            Color c = debug_lod_colors_ ? LOD_COLORS[lod < (int)(sizeof(LOD_COLORS)/sizeof(LOD_COLORS[0]))
                        ? lod : (int)(sizeof(LOD_COLORS)/sizeof(LOD_COLORS[0])) - 1] : terrain_color_;
            lod_materials_[lod]->set_shader_parameter("use_lod_color", debug_lod_colors_);
            lod_materials_[lod]->set_shader_parameter("lod_color", Vector3(c.r, c.g, c.b));
            lod_materials_[lod]->set_shader_parameter("fade_t", 1.0f);
            lod_materials_[lod]->set_shader_parameter("normal_perturbation", normal_perturbation_);
        }
        // Skip chunks with active fade override
        if (fade_overrides_.count(node)) continue;
        mesh->surface_set_material(0, lod_materials_[lod]);
    }
}
bool DCTerrain::get_debug_lod_colors() const { return debug_lod_colors_; }

void DCTerrain::set_debug_wireframe(bool p_val) {
    debug_wireframe_ = p_val;

    if (!is_inside_tree()) return;

    Viewport* vp = get_viewport();
    if (!vp) return;

    if (p_val) {
        vp->set_debug_draw(Viewport::DEBUG_DRAW_WIREFRAME);
    } else {
        vp->set_debug_draw(Viewport::DEBUG_DRAW_DISABLED);
    }
}

bool DCTerrain::get_debug_wireframe() const { return debug_wireframe_; }

void DCTerrain::set_lod_mode(int p_mode) {
    LodMode new_mode = (p_mode == 0) ? LOD_EAGER_FADE : LOD_BLOCKING_SWAP;
    if (new_mode == lod_mode_) return;

    lod_mode_ = new_mode;
    octree_clipmap_.lod_mode = new_mode;
    octree_clipmap_.clear_pre_mesh_cache();
    rebuild();
}

int DCTerrain::get_lod_mode() const {
    return static_cast<int>(lod_mode_);
}

void DCTerrain::set_lod_frozen(bool p_frozen) {
    lod_frozen_ = p_frozen;
}

bool DCTerrain::get_lod_frozen() const {
    return lod_frozen_;
}

void DCTerrain::debug_kick_lod() {
    // Diagnostic: force ONE LOD re-traversal without freezing or moving the camera.
    // Isolates whether a pure invalidate_settled() advances refinement (the way the
    // wall-climb/lizard un-freeze does via its camera-snap dead-zone trip). Also
    // prints a fresh octree snapshot so the effect of each press is visible.
    const ClipmapStats& st = octree_clipmap_.get_stats();
    octree_clipmap_.invalidate_settled();
    UtilityFunctions::print("[LODKICK] invalidated — before: settled=", st.settled,
        " leaves=", st.leaf_count, " pending=", st.pending_count,
        " blocked=", st.blocked_count, " frozen=", lod_frozen_);
}

// ---------------------------------------------------------------------------
// _bind_methods — expose everything to Godot
// ---------------------------------------------------------------------------
void DCTerrain::_bind_methods() {
    // rebuild()
    ClassDB::bind_method(D_METHOD("rebuild"), &DCTerrain::rebuild);
    ClassDB::bind_method(D_METHOD("warmup_shaders"), &DCTerrain::warmup_shaders);
    // ANSWERABLE, not just printed. A gate cannot see push_error, and the
    // built-in noise generator renders a plausible landscape -- so without this
    // there is no way for a test, or a map script, to ask whether the terrain
    // on screen is the one the assigned generator describes.
    ClassDB::bind_method(D_METHOD("is_using_builtin_generator"), &DCTerrain::is_using_builtin_generator);
    ClassDB::bind_method(D_METHOD("bake_volume_mesh", "glsl", "origin", "voxel_size", "cells"), &DCTerrain::bake_volume_mesh);
    ClassDB::bind_method(D_METHOD("get_scalar_query_counts"), &DCTerrain::get_scalar_query_counts);
    ClassDB::bind_method(D_METHOD("reset_scalar_query_counts"), &DCTerrain::reset_scalar_query_counts);
    ClassDB::bind_method(D_METHOD("sample_density", "position"), &DCTerrain::sample_density);
    ClassDB::bind_method(D_METHOD("debug_gpu_density", "origin", "voxel_size", "cells"), &DCTerrain::debug_gpu_density);
    ClassDB::bind_method(D_METHOD("debug_collision_faces", "center", "radius"), &DCTerrain::debug_collision_faces);
    ClassDB::bind_method(D_METHOD("debug_chunk_info", "position"), &DCTerrain::debug_chunk_info);
    ClassDB::bind_method(D_METHOD("debug_visual_faces", "center", "radius"), &DCTerrain::debug_visual_faces);
    ClassDB::bind_method(D_METHOD("build_stamp"), &DCTerrain::build_stamp);

    // SDF collision API
    ClassDB::bind_method(D_METHOD("get_distance", "position"), &DCTerrain::get_distance);
    ClassDB::bind_method(D_METHOD("get_surface_normal", "position"), &DCTerrain::get_surface_normal);
    ClassDB::bind_method(D_METHOD("get_capsule_push", "position", "radius", "height"), &DCTerrain::get_capsule_push);

    // -- Generator resource (optional override) --
    ADD_GROUP("Generator", "");
    ClassDB::bind_method(D_METHOD("set_generator", "generator"), &DCTerrain::set_generator);
    ClassDB::bind_method(D_METHOD("get_generator"), &DCTerrain::get_generator);
    ADD_PROPERTY(PropertyInfo(Variant::OBJECT, "generator", PROPERTY_HINT_RESOURCE_TYPE, "DCTerrainGenerator"),
                 "set_generator", "get_generator");

    // -- Noise group --
    ADD_GROUP("Noise", "");

    ClassDB::bind_method(D_METHOD("set_use_noise", "value"), &DCTerrain::set_use_noise);
    ClassDB::bind_method(D_METHOD("get_use_noise"), &DCTerrain::get_use_noise);
    ADD_PROPERTY(PropertyInfo(Variant::BOOL, "use_noise"), "set_use_noise", "get_use_noise");

    ClassDB::bind_method(D_METHOD("set_force_direct_eval", "value"), &DCTerrain::set_force_direct_eval);
    ClassDB::bind_method(D_METHOD("get_force_direct_eval"), &DCTerrain::get_force_direct_eval);
    ADD_PROPERTY(PropertyInfo(Variant::BOOL, "force_direct_eval"), "set_force_direct_eval", "get_force_direct_eval");

    ClassDB::bind_method(D_METHOD("set_csg_refinement", "value"), &DCTerrain::set_csg_refinement);
    ClassDB::bind_method(D_METHOD("get_csg_refinement"), &DCTerrain::get_csg_refinement);
    ADD_PROPERTY(PropertyInfo(Variant::INT, "csg_refinement", PROPERTY_HINT_RANGE, "1,4"), "set_csg_refinement", "get_csg_refinement");

    ClassDB::bind_method(D_METHOD("set_use_gpu", "value"), &DCTerrain::set_use_gpu);
    ClassDB::bind_method(D_METHOD("get_use_gpu"), &DCTerrain::get_use_gpu);
    ADD_PROPERTY(PropertyInfo(Variant::BOOL, "use_gpu"), "set_use_gpu", "get_use_gpu");

    ClassDB::bind_method(D_METHOD("set_seed", "value"), &DCTerrain::set_seed);
    ClassDB::bind_method(D_METHOD("get_seed"), &DCTerrain::get_seed);
    ADD_PROPERTY(PropertyInfo(Variant::INT, "seed"), "set_seed", "get_seed");

    ClassDB::bind_method(D_METHOD("set_primary_frequency", "value"), &DCTerrain::set_primary_frequency);
    ClassDB::bind_method(D_METHOD("get_primary_frequency"), &DCTerrain::get_primary_frequency);
    ADD_PROPERTY(PropertyInfo(Variant::FLOAT, "primary_frequency", PROPERTY_HINT_RANGE, "0.001,1.0,0.001"), "set_primary_frequency", "get_primary_frequency");

    ClassDB::bind_method(D_METHOD("set_primary_strength", "value"), &DCTerrain::set_primary_strength);
    ClassDB::bind_method(D_METHOD("get_primary_strength"), &DCTerrain::get_primary_strength);
    ADD_PROPERTY(PropertyInfo(Variant::FLOAT, "primary_strength", PROPERTY_HINT_RANGE, "0.0,5.0,0.01"), "set_primary_strength", "get_primary_strength");

    ClassDB::bind_method(D_METHOD("set_tunnel_frequency", "value"), &DCTerrain::set_tunnel_frequency);
    ClassDB::bind_method(D_METHOD("get_tunnel_frequency"), &DCTerrain::get_tunnel_frequency);
    ADD_PROPERTY(PropertyInfo(Variant::FLOAT, "tunnel_frequency", PROPERTY_HINT_RANGE, "0.001,1.0,0.001"), "set_tunnel_frequency", "get_tunnel_frequency");

    ClassDB::bind_method(D_METHOD("set_tunnel_strength", "value"), &DCTerrain::set_tunnel_strength);
    ClassDB::bind_method(D_METHOD("get_tunnel_strength"), &DCTerrain::get_tunnel_strength);
    ADD_PROPERTY(PropertyInfo(Variant::FLOAT, "tunnel_strength", PROPERTY_HINT_RANGE, "0.0,5.0,0.01"), "set_tunnel_strength", "get_tunnel_strength");

    ClassDB::bind_method(D_METHOD("set_tunnel_radius", "value"), &DCTerrain::set_tunnel_radius);
    ClassDB::bind_method(D_METHOD("get_tunnel_radius"), &DCTerrain::get_tunnel_radius);
    ADD_PROPERTY(PropertyInfo(Variant::FLOAT, "tunnel_radius", PROPERTY_HINT_RANGE, "0.0,2.0,0.01"), "set_tunnel_radius", "get_tunnel_radius");

    ClassDB::bind_method(D_METHOD("set_ridge_frequency", "value"), &DCTerrain::set_ridge_frequency);
    ClassDB::bind_method(D_METHOD("get_ridge_frequency"), &DCTerrain::get_ridge_frequency);
    ADD_PROPERTY(PropertyInfo(Variant::FLOAT, "ridge_frequency", PROPERTY_HINT_RANGE, "0.001,1.0,0.001"), "set_ridge_frequency", "get_ridge_frequency");

    ClassDB::bind_method(D_METHOD("set_ridge_strength", "value"), &DCTerrain::set_ridge_strength);
    ClassDB::bind_method(D_METHOD("get_ridge_strength"), &DCTerrain::get_ridge_strength);
    ADD_PROPERTY(PropertyInfo(Variant::FLOAT, "ridge_strength", PROPERTY_HINT_RANGE, "0.0,5.0,0.01"), "set_ridge_strength", "get_ridge_strength");

    ClassDB::bind_method(D_METHOD("set_warp_frequency", "value"), &DCTerrain::set_warp_frequency);
    ClassDB::bind_method(D_METHOD("get_warp_frequency"), &DCTerrain::get_warp_frequency);
    ADD_PROPERTY(PropertyInfo(Variant::FLOAT, "warp_frequency", PROPERTY_HINT_RANGE, "0.001,1.0,0.001"), "set_warp_frequency", "get_warp_frequency");

    ClassDB::bind_method(D_METHOD("set_warp_strength", "value"), &DCTerrain::set_warp_strength);
    ClassDB::bind_method(D_METHOD("get_warp_strength"), &DCTerrain::get_warp_strength);
    ADD_PROPERTY(PropertyInfo(Variant::FLOAT, "warp_strength", PROPERTY_HINT_RANGE, "0.0,30.0,0.1"), "set_warp_strength", "get_warp_strength");

    ClassDB::bind_method(D_METHOD("set_detail_frequency", "value"), &DCTerrain::set_detail_frequency);
    ClassDB::bind_method(D_METHOD("get_detail_frequency"), &DCTerrain::get_detail_frequency);
    ADD_PROPERTY(PropertyInfo(Variant::FLOAT, "detail_frequency", PROPERTY_HINT_RANGE, "0.001,1.0,0.001"), "set_detail_frequency", "get_detail_frequency");

    ClassDB::bind_method(D_METHOD("set_detail_strength", "value"), &DCTerrain::set_detail_strength);
    ClassDB::bind_method(D_METHOD("get_detail_strength"), &DCTerrain::get_detail_strength);
    ADD_PROPERTY(PropertyInfo(Variant::FLOAT, "detail_strength", PROPERTY_HINT_RANGE, "0.0,2.0,0.01"), "set_detail_strength", "get_detail_strength");

    // -- Terrain Shape group --
    ADD_GROUP("Terrain Shape", "");

    ClassDB::bind_method(D_METHOD("set_ground_bias", "value"), &DCTerrain::set_ground_bias);
    ClassDB::bind_method(D_METHOD("get_ground_bias"), &DCTerrain::get_ground_bias);
    ADD_PROPERTY(PropertyInfo(Variant::FLOAT, "ground_bias", PROPERTY_HINT_RANGE, "-1.0,1.0,0.001"), "set_ground_bias", "get_ground_bias");

    ClassDB::bind_method(D_METHOD("set_solid_floor_depth", "value"), &DCTerrain::set_solid_floor_depth);
    ClassDB::bind_method(D_METHOD("get_solid_floor_depth"), &DCTerrain::get_solid_floor_depth);
    ADD_PROPERTY(PropertyInfo(Variant::FLOAT, "solid_floor_depth", PROPERTY_HINT_RANGE, "0.0,100.0,0.5"), "set_solid_floor_depth", "get_solid_floor_depth");

    ClassDB::bind_method(D_METHOD("set_sky_height", "value"), &DCTerrain::set_sky_height);
    ClassDB::bind_method(D_METHOD("get_sky_height"), &DCTerrain::get_sky_height);
    ADD_PROPERTY(PropertyInfo(Variant::FLOAT, "sky_height", PROPERTY_HINT_RANGE, "0.0,100.0,0.5"), "set_sky_height", "get_sky_height");

    ClassDB::bind_method(D_METHOD("set_flat_center_radius", "value"), &DCTerrain::set_flat_center_radius);
    ClassDB::bind_method(D_METHOD("get_flat_center_radius"), &DCTerrain::get_flat_center_radius);
    ADD_PROPERTY(PropertyInfo(Variant::FLOAT, "flat_center_radius", PROPERTY_HINT_RANGE, "0.0,50.0,0.5"), "set_flat_center_radius", "get_flat_center_radius");

    ClassDB::bind_method(D_METHOD("set_terrain_size", "value"), &DCTerrain::set_terrain_size);
    ClassDB::bind_method(D_METHOD("get_terrain_size"), &DCTerrain::get_terrain_size);
    ADD_PROPERTY(PropertyInfo(Variant::FLOAT, "terrain_size", PROPERTY_HINT_RANGE, "10.0,100000.0,1.0"), "set_terrain_size", "get_terrain_size");

    // -- Voronoi group --
    ADD_GROUP("Voronoi Floors", "");

    ClassDB::bind_method(D_METHOD("set_voronoi_cell_count", "value"), &DCTerrain::set_voronoi_cell_count);
    ClassDB::bind_method(D_METHOD("get_voronoi_cell_count"), &DCTerrain::get_voronoi_cell_count);
    ADD_PROPERTY(PropertyInfo(Variant::INT, "voronoi_cell_count", PROPERTY_HINT_RANGE, "0,20,1"), "set_voronoi_cell_count", "get_voronoi_cell_count");

    ClassDB::bind_method(D_METHOD("set_floor_strength", "value"), &DCTerrain::set_floor_strength);
    ClassDB::bind_method(D_METHOD("get_floor_strength"), &DCTerrain::get_floor_strength);
    ADD_PROPERTY(PropertyInfo(Variant::FLOAT, "floor_strength", PROPERTY_HINT_RANGE, "0.0,3.0,0.01"), "set_floor_strength", "get_floor_strength");

    ClassDB::bind_method(D_METHOD("set_floor_height_min", "value"), &DCTerrain::set_floor_height_min);
    ClassDB::bind_method(D_METHOD("get_floor_height_min"), &DCTerrain::get_floor_height_min);
    ADD_PROPERTY(PropertyInfo(Variant::FLOAT, "floor_height_min", PROPERTY_HINT_RANGE, "-50.0,50.0,0.5"), "set_floor_height_min", "get_floor_height_min");

    ClassDB::bind_method(D_METHOD("set_floor_height_max", "value"), &DCTerrain::set_floor_height_max);
    ClassDB::bind_method(D_METHOD("get_floor_height_max"), &DCTerrain::get_floor_height_max);
    ADD_PROPERTY(PropertyInfo(Variant::FLOAT, "floor_height_max", PROPERTY_HINT_RANGE, "-50.0,50.0,0.5"), "set_floor_height_max", "get_floor_height_max");

    ClassDB::bind_method(D_METHOD("set_room_height", "value"), &DCTerrain::set_room_height);
    ClassDB::bind_method(D_METHOD("get_room_height"), &DCTerrain::get_room_height);
    ADD_PROPERTY(PropertyInfo(Variant::FLOAT, "room_height", PROPERTY_HINT_RANGE, "1.0,30.0,0.5"), "set_room_height", "get_room_height");

    // -- Display group --
    ADD_GROUP("Display", "");

    ClassDB::bind_method(D_METHOD("set_terrain_color", "color"), &DCTerrain::set_terrain_color);
    ClassDB::bind_method(D_METHOD("get_terrain_color"), &DCTerrain::get_terrain_color);
    ADD_PROPERTY(PropertyInfo(Variant::COLOR, "terrain_color"), "set_terrain_color", "get_terrain_color");

    ClassDB::bind_method(D_METHOD("set_custom_material", "material"), &DCTerrain::set_custom_material);
    ClassDB::bind_method(D_METHOD("get_custom_material"), &DCTerrain::get_custom_material);
    ADD_PROPERTY(PropertyInfo(Variant::OBJECT, "custom_material", PROPERTY_HINT_RESOURCE_TYPE, "Material"),
                 "set_custom_material", "get_custom_material");

    ClassDB::bind_method(D_METHOD("set_voxel_size", "value"), &DCTerrain::set_voxel_size);
    ClassDB::bind_method(D_METHOD("get_voxel_size"), &DCTerrain::get_voxel_size);
    ADD_PROPERTY(PropertyInfo(Variant::FLOAT, "voxel_size", PROPERTY_HINT_RANGE, "0.1,10.0,0.1"), "set_voxel_size", "get_voxel_size");

    ClassDB::bind_method(D_METHOD("set_octree_depth", "value"), &DCTerrain::set_octree_depth);
    ClassDB::bind_method(D_METHOD("get_octree_depth"), &DCTerrain::get_octree_depth);
    ADD_PROPERTY(PropertyInfo(Variant::INT, "octree_depth", PROPERTY_HINT_RANGE, "3,20,1"), "set_octree_depth", "get_octree_depth");

    ClassDB::bind_method(D_METHOD("set_seamless_lod", "value"), &DCTerrain::set_seamless_lod);
    ClassDB::bind_method(D_METHOD("get_seamless_lod"), &DCTerrain::get_seamless_lod);
    ADD_PROPERTY(PropertyInfo(Variant::BOOL, "seamless_lod"), "set_seamless_lod", "get_seamless_lod");

    ClassDB::bind_method(D_METHOD("set_spherical", "value"), &DCTerrain::set_spherical);
    ClassDB::bind_method(D_METHOD("get_spherical"), &DCTerrain::get_spherical);
    ADD_PROPERTY(PropertyInfo(Variant::BOOL, "spherical"), "set_spherical", "get_spherical");

    ClassDB::bind_method(D_METHOD("set_floating_islands", "value"), &DCTerrain::set_floating_islands);
    ClassDB::bind_method(D_METHOD("get_floating_islands"), &DCTerrain::get_floating_islands);
    ADD_PROPERTY(PropertyInfo(Variant::BOOL, "floating_islands"), "set_floating_islands", "get_floating_islands");
    ClassDB::bind_method(D_METHOD("set_island_radius", "value"), &DCTerrain::set_island_radius);
    ClassDB::bind_method(D_METHOD("get_island_radius"), &DCTerrain::get_island_radius);
    ADD_PROPERTY(PropertyInfo(Variant::FLOAT, "island_radius", PROPERTY_HINT_RANGE, "10.0,500.0,1.0"), "set_island_radius", "get_island_radius");
    ClassDB::bind_method(D_METHOD("set_island_depth_ratio", "value"), &DCTerrain::set_island_depth_ratio);
    ClassDB::bind_method(D_METHOD("get_island_depth_ratio"), &DCTerrain::get_island_depth_ratio);
    ADD_PROPERTY(PropertyInfo(Variant::FLOAT, "island_depth_ratio", PROPERTY_HINT_RANGE, "0.5,10.0,0.1"), "set_island_depth_ratio", "get_island_depth_ratio");
    ClassDB::bind_method(D_METHOD("set_island_spacing", "value"), &DCTerrain::set_island_spacing);
    ClassDB::bind_method(D_METHOD("get_island_spacing"), &DCTerrain::get_island_spacing);
    ADD_PROPERTY(PropertyInfo(Variant::FLOAT, "island_spacing", PROPERTY_HINT_RANGE, "50.0,1000.0,10.0"), "set_island_spacing", "get_island_spacing");

    ClassDB::bind_method(D_METHOD("set_planet_radius", "value"), &DCTerrain::set_planet_radius);
    ClassDB::bind_method(D_METHOD("get_planet_radius"), &DCTerrain::get_planet_radius);
    ADD_PROPERTY(PropertyInfo(Variant::FLOAT, "planet_radius", PROPERTY_HINT_RANGE, "10.0,1000000.0,100.0"), "set_planet_radius", "get_planet_radius");

    ClassDB::bind_method(D_METHOD("set_max_noise_octaves", "value"), &DCTerrain::set_max_noise_octaves);
    ClassDB::bind_method(D_METHOD("get_max_noise_octaves"), &DCTerrain::get_max_noise_octaves);
    ADD_PROPERTY(PropertyInfo(Variant::INT, "max_noise_octaves", PROPERTY_HINT_RANGE, "0,20,1"), "set_max_noise_octaves", "get_max_noise_octaves");

    ClassDB::bind_method(D_METHOD("set_max_ridge_octaves", "value"), &DCTerrain::set_max_ridge_octaves);
    ClassDB::bind_method(D_METHOD("get_max_ridge_octaves"), &DCTerrain::get_max_ridge_octaves);
    ADD_PROPERTY(PropertyInfo(Variant::INT, "max_ridge_octaves", PROPERTY_HINT_RANGE, "0,20,1"), "set_max_ridge_octaves", "get_max_ridge_octaves");

    ClassDB::bind_method(D_METHOD("set_collision_mode", "value"), &DCTerrain::set_collision_mode);
    ClassDB::bind_method(D_METHOD("get_collision_mode"), &DCTerrain::get_collision_mode);
    ADD_PROPERTY(PropertyInfo(Variant::INT, "collision_mode", PROPERTY_HINT_ENUM, "Trimesh,SDF,Both"), "set_collision_mode", "get_collision_mode");

    ClassDB::bind_method(D_METHOD("set_collision_lod_max", "value"), &DCTerrain::set_collision_lod_max);
    ClassDB::bind_method(D_METHOD("get_collision_lod_max"), &DCTerrain::get_collision_lod_max);
    ADD_PROPERTY(PropertyInfo(Variant::INT, "collision_lod_max", PROPERTY_HINT_RANGE, "0,4,1"), "set_collision_lod_max", "get_collision_lod_max");

    ClassDB::bind_method(D_METHOD("set_collision_distance", "value"), &DCTerrain::set_collision_distance);
    ClassDB::bind_method(D_METHOD("get_collision_distance"), &DCTerrain::get_collision_distance);
    ADD_PROPERTY(PropertyInfo(Variant::FLOAT, "collision_distance", PROPERTY_HINT_RANGE, "10.0,500.0,5.0"), "set_collision_distance", "get_collision_distance");

    // -- Stats (read-only methods for HUD) --
    ClassDB::bind_method(D_METHOD("get_chunk_count"), &DCTerrain::get_chunk_count);
    ClassDB::bind_method(D_METHOD("get_active_chunk_count"), &DCTerrain::get_active_chunk_count);
    ClassDB::bind_method(D_METHOD("get_pending_count"), &DCTerrain::get_pending_count);
    ClassDB::bind_method(D_METHOD("get_collision_queue_size"), &DCTerrain::get_collision_queue_size);
    ClassDB::bind_method(D_METHOD("get_splits_this_frame"), &DCTerrain::get_splits_this_frame);

    // -- Terrain editing --
    ClassDB::bind_method(D_METHOD("edit_terrain", "position", "radius", "strength", "op", "interactive", "material_id"), &DCTerrain::edit_terrain, DEFVAL(0), DEFVAL(true), DEFVAL(0));
    ClassDB::bind_method(D_METHOD("edit_terrain_box", "position", "half_extents", "strength", "interactive", "material_id", "persistent"), &DCTerrain::edit_terrain_box, DEFVAL(true), DEFVAL(0), DEFVAL(false));
    ClassDB::bind_method(D_METHOD("edit_terrain_convex", "planes", "center", "radius", "strength", "interactive", "material_id", "face_proj"), &DCTerrain::edit_terrain_convex, DEFVAL(true), DEFVAL(0), DEFVAL(PackedInt32Array()));
    ClassDB::bind_method(D_METHOD("stamp_mesh_obj", "obj_path", "position", "scale", "strength", "interactive", "material_id"), &DCTerrain::stamp_mesh_obj, DEFVAL(true), DEFVAL(0));
    ClassDB::bind_method(D_METHOD("stamp_mesh_arrays", "vertices", "indices", "position", "scale", "strength", "interactive", "material_id", "persistent"), &DCTerrain::stamp_mesh_arrays, DEFVAL(true), DEFVAL(0), DEFVAL(false));
    ClassDB::bind_method(D_METHOD("debug_sdf_at", "position"), &DCTerrain::debug_sdf_at);
    ClassDB::bind_method(D_METHOD("clear_edits"), &DCTerrain::clear_edits);
    ClassDB::bind_method(D_METHOD("clear_landscape_edits"), &DCTerrain::clear_landscape_edits);
    ClassDB::bind_method(D_METHOD("get_edit_count"), &DCTerrain::get_edit_count);
    ClassDB::bind_method(D_METHOD("pick_brush", "point"), &DCTerrain::pick_brush);
    ClassDB::bind_method(D_METHOD("get_brush_center", "idx"), &DCTerrain::get_brush_center);
    ClassDB::bind_method(D_METHOD("get_brush_radius", "idx"), &DCTerrain::get_brush_radius);
    ClassDB::bind_method(D_METHOD("get_brush_hull_triangles", "idx"), &DCTerrain::get_brush_hull_triangles);
    ClassDB::bind_method(D_METHOD("lift_brush", "idx"), &DCTerrain::lift_brush);
    ClassDB::bind_method(D_METHOD("move_brush", "idx", "delta"), &DCTerrain::move_brush);
    ClassDB::bind_method(D_METHOD("get_edits_as_array"), &DCTerrain::get_edits_as_array);
    ClassDB::bind_method(D_METHOD("load_edits_from_array", "edits"), &DCTerrain::load_edits_from_array);

    // -- CSG primitives --
    ClassDB::bind_method(D_METHOD("add_csg_primitive", "type", "op", "group", "position", "size"),
                         &DCTerrain::add_csg_primitive);
    ClassDB::bind_method(D_METHOD("clear_csg_primitives"), &DCTerrain::clear_csg_primitives);
    ClassDB::bind_method(D_METHOD("get_csg_primitive_count"), &DCTerrain::get_csg_primitive_count);
    ClassDB::bind_method(D_METHOD("set_csg_primitive_position", "index", "position"),
                         &DCTerrain::set_csg_primitive_position);
    ClassDB::bind_method(D_METHOD("set_csg_primitive_size", "index", "size"),
                         &DCTerrain::set_csg_primitive_size);
    ClassDB::bind_method(D_METHOD("remove_csg_primitive", "index"),
                         &DCTerrain::remove_csg_primitive);

    // -- LOD group --
    ADD_GROUP("LOD", "");

    ClassDB::bind_method(D_METHOD("set_lod_mode", "mode"), &DCTerrain::set_lod_mode);
    ClassDB::bind_method(D_METHOD("get_lod_mode"), &DCTerrain::get_lod_mode);
    ADD_PROPERTY(PropertyInfo(Variant::INT, "lod_mode", PROPERTY_HINT_ENUM,
        "Eager Fade,Blocking Swap"), "set_lod_mode", "get_lod_mode");

    ClassDB::bind_method(D_METHOD("set_lod_factor", "value"), &DCTerrain::set_lod_factor);
    ClassDB::bind_method(D_METHOD("get_lod_factor"), &DCTerrain::get_lod_factor);
    ADD_PROPERTY(PropertyInfo(Variant::FLOAT, "lod_factor", PROPERTY_HINT_RANGE, "1.0,8.0,0.1"), "set_lod_factor", "get_lod_factor");

    ClassDB::bind_method(D_METHOD("set_hysteresis", "value"), &DCTerrain::set_hysteresis);
    ClassDB::bind_method(D_METHOD("get_hysteresis"), &DCTerrain::get_hysteresis);
    ADD_PROPERTY(PropertyInfo(Variant::FLOAT, "hysteresis", PROPERTY_HINT_RANGE, "1.0,2.0,0.05"), "set_hysteresis", "get_hysteresis");

    ClassDB::bind_method(D_METHOD("set_fade_speed", "value"), &DCTerrain::set_fade_speed);
    ClassDB::bind_method(D_METHOD("get_fade_speed"), &DCTerrain::get_fade_speed);
    ADD_PROPERTY(PropertyInfo(Variant::FLOAT, "fade_speed", PROPERTY_HINT_RANGE, "0.5,20.0,0.5"), "set_fade_speed", "get_fade_speed");

    ClassDB::bind_method(D_METHOD("set_normal_perturbation", "value"), &DCTerrain::set_normal_perturbation);
    ClassDB::bind_method(D_METHOD("get_normal_perturbation"), &DCTerrain::get_normal_perturbation);
    ADD_PROPERTY(PropertyInfo(Variant::FLOAT, "normal_perturbation", PROPERTY_HINT_RANGE, "0.0,0.3,0.01"), "set_normal_perturbation", "get_normal_perturbation");

    ClassDB::bind_method(D_METHOD("set_screen_split_threshold", "value"), &DCTerrain::set_screen_split_threshold);
    ClassDB::bind_method(D_METHOD("get_screen_split_threshold"), &DCTerrain::get_screen_split_threshold);
    ADD_PROPERTY(PropertyInfo(Variant::FLOAT, "screen_split_threshold", PROPERTY_HINT_RANGE, "0.1,4.0,0.1"), "set_screen_split_threshold", "get_screen_split_threshold");

    ClassDB::bind_method(D_METHOD("set_use_screen_space_error", "value"), &DCTerrain::set_use_screen_space_error);
    ClassDB::bind_method(D_METHOD("get_use_screen_space_error"), &DCTerrain::get_use_screen_space_error);
    ADD_PROPERTY(PropertyInfo(Variant::BOOL, "use_screen_space_error"), "set_use_screen_space_error", "get_use_screen_space_error");

    ClassDB::bind_method(D_METHOD("set_lod_frozen", "frozen"), &DCTerrain::set_lod_frozen);
    ClassDB::bind_method(D_METHOD("get_lod_frozen"), &DCTerrain::get_lod_frozen);
    ClassDB::bind_method(D_METHOD("debug_kick_lod"), &DCTerrain::debug_kick_lod);
    ADD_PROPERTY(PropertyInfo(Variant::BOOL, "lod_frozen"), "set_lod_frozen", "get_lod_frozen");

    // -- Mesh Quality group --
    ADD_GROUP("Mesh Quality", "use_");

    ClassDB::bind_method(D_METHOD("set_use_adaptive_qef", "value"), &DCTerrain::set_use_adaptive_qef);
    ClassDB::bind_method(D_METHOD("get_use_adaptive_qef"), &DCTerrain::get_use_adaptive_qef);
    ADD_PROPERTY(PropertyInfo(Variant::BOOL, "use_adaptive_qef"), "set_use_adaptive_qef", "get_use_adaptive_qef");
    ClassDB::bind_method(D_METHOD("set_adaptive_arch_lod", "value"), &DCTerrain::set_adaptive_arch_lod);
    ClassDB::bind_method(D_METHOD("get_adaptive_arch_lod"), &DCTerrain::get_adaptive_arch_lod);
    ADD_PROPERTY(PropertyInfo(Variant::BOOL, "adaptive_arch_lod"), "set_adaptive_arch_lod", "get_adaptive_arch_lod");
    ClassDB::bind_method(D_METHOD("set_arch_min_lod", "lod"), &DCTerrain::set_arch_min_lod);
    ClassDB::bind_method(D_METHOD("get_arch_min_lod"), &DCTerrain::get_arch_min_lod);
    ADD_PROPERTY(PropertyInfo(Variant::INT, "arch_min_lod"), "set_arch_min_lod", "get_arch_min_lod");
    ClassDB::bind_method(D_METHOD("set_texture_atlas", "atlas"), &DCTerrain::set_texture_atlas);
    ClassDB::bind_method(D_METHOD("set_glow_atlas", "atlas"), &DCTerrain::set_glow_atlas);
    ClassDB::bind_method(D_METHOD("set_norm_atlas", "atlas"), &DCTerrain::set_norm_atlas);
    ClassDB::bind_method(D_METHOD("set_projection_table", "table"), &DCTerrain::set_projection_table);
    ClassDB::bind_method(D_METHOD("set_use_textures", "value"), &DCTerrain::set_use_textures);
    ClassDB::bind_method(D_METHOD("get_use_textures"), &DCTerrain::get_use_textures);
    ADD_PROPERTY(PropertyInfo(Variant::BOOL, "use_textures"), "set_use_textures", "get_use_textures");

    ClassDB::bind_method(D_METHOD("set_use_shortest_diagonal", "value"), &DCTerrain::set_use_shortest_diagonal);
    ClassDB::bind_method(D_METHOD("get_use_shortest_diagonal"), &DCTerrain::get_use_shortest_diagonal);
    ADD_PROPERTY(PropertyInfo(Variant::BOOL, "use_shortest_diagonal"), "set_use_shortest_diagonal", "get_use_shortest_diagonal");

    ClassDB::bind_method(D_METHOD("set_use_mesh_simplify", "value"), &DCTerrain::set_use_mesh_simplify);
    ClassDB::bind_method(D_METHOD("get_use_mesh_simplify"), &DCTerrain::get_use_mesh_simplify);
    ADD_PROPERTY(PropertyInfo(Variant::BOOL, "use_mesh_simplify"), "set_use_mesh_simplify", "get_use_mesh_simplify");

    // -- Debug group --
    ADD_GROUP("Debug", "debug_");

    ClassDB::bind_method(D_METHOD("set_debug_logging", "value"), &DCTerrain::set_debug_logging);
    ClassDB::bind_method(D_METHOD("get_debug_logging"), &DCTerrain::get_debug_logging);
    ADD_PROPERTY(PropertyInfo(Variant::BOOL, "debug_logging"), "set_debug_logging", "get_debug_logging");

    ClassDB::bind_method(D_METHOD("set_debug_lod_colors", "value"), &DCTerrain::set_debug_lod_colors);
    ClassDB::bind_method(D_METHOD("get_debug_lod_colors"), &DCTerrain::get_debug_lod_colors);
    ADD_PROPERTY(PropertyInfo(Variant::BOOL, "debug_lod_colors"), "set_debug_lod_colors", "get_debug_lod_colors");

    ClassDB::bind_method(D_METHOD("set_debug_seams", "value"), &DCTerrain::set_debug_seams);
    ClassDB::bind_method(D_METHOD("get_debug_seams"), &DCTerrain::get_debug_seams);
    ADD_PROPERTY(PropertyInfo(Variant::BOOL, "debug_seams"), "set_debug_seams", "get_debug_seams");

    ClassDB::bind_method(D_METHOD("set_debug_clipmap", "value"), &DCTerrain::set_debug_clipmap);
    ClassDB::bind_method(D_METHOD("get_debug_clipmap"), &DCTerrain::get_debug_clipmap);
    ADD_PROPERTY(PropertyInfo(Variant::BOOL, "debug_clipmap"), "set_debug_clipmap", "get_debug_clipmap");

    ClassDB::bind_method(D_METHOD("set_debug_wireframe", "value"), &DCTerrain::set_debug_wireframe);
    ClassDB::bind_method(D_METHOD("get_debug_wireframe"), &DCTerrain::get_debug_wireframe);
    ADD_PROPERTY(PropertyInfo(Variant::BOOL, "debug_wireframe"), "set_debug_wireframe", "get_debug_wireframe");

    ClassDB::bind_method(D_METHOD("set_debug_edits", "value"), &DCTerrain::set_debug_edits);
    ClassDB::bind_method(D_METHOD("get_debug_edits"), &DCTerrain::get_debug_edits);
    ADD_PROPERTY(PropertyInfo(Variant::BOOL, "debug_edits"), "set_debug_edits", "get_debug_edits");
}

} // namespace godot