/**
 * TerrainManager — voxel-ландшафт для KubikonEditor.
 *
 * Архитектура: отдельный слой voxel-кубов 1×1×1, рисующийся через
 * Babylon thin-instances (один proto-mesh на каждый материал). По
 * структуре повторяет упрощённый BlockManager, но:
 *
 *   - Никаких мульти-текстур и жидкостей — только однотонные материалы.
 *   - 12 материалов из палитры (трава/камень/песок/снег/...), без id-блоков.
 *   - Каждый voxel — `{x, y, z, mat}`, mat — id материала ('grass'/'rock'/...).
 *   - Кисти работают по сферическому объёму вокруг точки клика.
 *   - Сериализация: компактный массив `{x, y, z, m}` в `project_data.scene.terrain`.
 *
 * Public API:
 *
 *   setVoxel(x, y, z, matId)
 *   removeVoxel(x, y, z)
 *   getVoxel(x, y, z)               → matId | null
 *   hasVoxel(x, y, z)               → bool
 *
 *   // Кисти. brush — { x, y, z, radius, shape: 'sphere'|'cube'|'cylinder' }
 *   brushDraw(brush, matId)         → добавляет voxel'ы материала
 *   brushSculpt(brush, dir)         → dir=+1 поднимает, -1 опускает столбцы
 *   brushSmooth(brush)              → сглаживает поверхность
 *   brushFlatten(brush, targetY)    → выравнивает по плоскости Y
 *   brushPaint(brush, matId)        → меняет материал у существующих
 *   brushErase(brush)               → удаляет voxel'ы
 *
 *   serialize() / loadFromArray()
 *   count() / clear()
 *
 * Производительность: thin-instances Babylon тянут 50К+ voxel'ов на 60 FPS.
 * При перестройке используется тот же приём что в BlockManager: индекс
 * слота переиспользуется при удалении (Map _freeSlots), без полной перестройки
 * массива matrices.
 */
import {
    MeshBuilder,
    StandardMaterial,
    MultiMaterial,
    SubMesh,
    Texture,
    Color3,
    Matrix,
    Vector3,
    BoundingInfo,
    Mesh,
    VertexData,
} from '@babylonjs/core';

/**
 * Какой sub-material применить к какой грани куба Babylon.MeshBuilder.CreateBox.
 * Совпадает с FACE_INDEX_MAP в BlockManager.js. Порядок Babylon BoxBuilder:
 *   0: +Z front · 1: -Z back · 2: +X right · 3: -X left · 4: +Y top · 5: -Y bottom
 */
const FACE_INDEX = { front: 0, back: 1, right: 2, left: 3, top: 4, bottom: 5 };

// ============================================================================
// Палитра материалов террейна. Текстуры — Kenney Voxel Pack (CC0),
// те же что используются в BlockTypes.js. За счёт этого террейн стилистически
// сливается с обычными блоками сцены.
//
// У каждого материала:
//   name      — отображаемое имя
//   color     — fallback-цвет на случай если текстура не загрузилась +
//               цвет в превью-плашке TerrainPanel
//   texture   — путь к одной текстуре на 6 граней (простой куб)
//   top/side/bottom — три текстуры (верх/бока/низ) для материалов вроде травы,
//               где верх отличается. Если задано — texture игнорируется.
//   emissive  — RGB-эмиссия [0..1, 0..1, 0..1], опционально (для светящихся
//               материалов вроде лавы; пока не используется в палитре)
// ============================================================================
const TEX = '/kubikon-assets/textures';

export const TERRAIN_MATERIALS = {
    grass: {
        name: 'Трава', color: '#52b15a',
        // Только ВЕРХ зелёный — бока и низ чистая земля, без зелёной
        // каёмки. Иначе на ступенчатом рельефе voxel-террейна
        // (множество уступов 0.5м) видны «полосы травы» на стене —
        // визуальный шум, ландшафт выглядит грязно.
        top:    `${TEX}/grass_top.png`,
        side:   `${TEX}/dirt.png`,
        bottom: `${TEX}/dirt.png`,
    },
    rock: {
        name: 'Камень', color: '#7e7e7e',
        // stone_coal вместо greystone: greystone — почти одноцветная
        // (разброс RGB всего 17 единиц на канал) → на дистанции 30м+
        // выглядит сплошной серо-голубой стенкой. stone_coal имеет
        // высокий контраст (чёрные осколки на сером) → паттерн виден.
        texture: `${TEX}/stone_coal.png`,
    },
    sand: {
        name: 'Песок', color: '#e6d27a',
        texture: `${TEX}/sand.png`,
    },
    snow: {
        name: 'Снег', color: '#f5f7fb',
        texture: `${TEX}/snow.png`,
    },
    dirt: {
        name: 'Земля', color: '#7c5430',
        texture: `${TEX}/dirt.png`,
    },
    water: {
        name: 'Вода', color: '#3a8fd6',
        texture: `${TEX}/water.png`,
        alpha: 0.72,
    },
    asphalt: {
        name: 'Асфальт', color: '#3b3b3b',
        texture: `${TEX}/stone.png`,
    },
    concrete: {
        name: 'Бетон', color: '#b8b8b8',
        texture: `${TEX}/greystone.png`,
    },
    wood: {
        name: 'Дерево', color: '#a06a3a',
        texture: `${TEX}/wood.png`,
    },
    glacier: {
        name: 'Ледник', color: '#c8e6f5',
        texture: `${TEX}/ice.png`,
        alpha: 0.92,
    },
    salt: {
        name: 'Соль', color: '#ecedef',
        // Соль ближе всего к снегу + лёгкий emissive для «искристости»
        texture: `${TEX}/snow.png`,
    },
    mud: {
        name: 'Грязь', color: '#553a25',
        texture: `${TEX}/gravel_dirt.png`,
    },
    // === Новые материалы для Voxlands-стиля (декорации мира) ===
    leaves: {
        name: 'Листва', color: '#3f7a3a',
        texture: `${TEX}/leaves.png`,
    },
    leaves_orange: {
        name: 'Листва осенняя', color: '#c2741e',
        texture: `${TEX}/leaves_orange.png`,
    },
    trunk: {
        name: 'Ствол дерева', color: '#5a3b1f',
        // У ствола разные текстуры: top/bottom — спил, side — кора.
        top:    `${TEX}/trunk_top.png`,
        side:   `${TEX}/trunk_side.png`,
        bottom: `${TEX}/trunk_bottom.png`,
    },
    trunk_white: {
        name: 'Ствол берёзы', color: '#e0dfd6',
        top:    `${TEX}/trunk_top.png`,
        side:   `${TEX}/trunk_white_side.png`,
        bottom: `${TEX}/trunk_top.png`,
    },
    rock_moss: {
        name: 'Камень со мхом', color: '#5d6f43',
        texture: `${TEX}/rock_moss.png`,
    },
    flower_red: {
        name: 'Красный цветок', color: '#b84141',
        texture: `${TEX}/cotton_red.png`,
    },
    flower_blue: {
        name: 'Синий цветок', color: '#4673b8',
        texture: `${TEX}/cotton_blue.png`,
    },
    flower_yellow: {
        name: 'Жёлтый цветок', color: '#d4c84a',
        texture: `${TEX}/cotton_tan.png`,
    },
    mushroom_red: {
        name: 'Красный гриб', color: '#a02525',
        texture: `${TEX}/mushroom_red.png`,
    },
    tall_grass: {
        name: 'Высокая трава', color: '#5fa84e',
        texture: `${TEX}/wheat_stage4.png`,
    },
};

const TERRAIN_MATERIAL_IDS = Object.keys(TERRAIN_MATERIALS);

/**
 * Размер одной voxel-ячейки в метрах. 0.25 даёт 4 voxel'а на метр —
 * в 64 раза больше плотности чем у обычных блоков (1×1×1).
 * Холмы выглядят почти гладкими, но память/FPS ×64 относительно блоков.
 *
 * Прежние значения:
 *   1.0 — как Minecraft, грубо
 *   0.5 — детальнее (бывший дефолт)
 *   0.25 — тестовый режим, очень детально, ~64× памяти на ту же площадь
 *
 * Координаты voxel'ов в Map хранятся как ЦЕЛЫЕ индексы grid (x,y,z),
 * а в мире преобразуются как world = (gridX + 0.5) * VOXEL_SIZE.
 * Это позволяет легко уменьшать/увеличивать размер без переписки логики.
 */
export const VOXEL_SIZE = 0.25;

export class TerrainManager {
    constructor(scene) {
        this.scene = scene;
        /** Map<"x,y,z", matId> — единственный источник правды. */
        this.voxels = new Map();
        /** Proto-mesh по материалу: matId → Mesh с thin-instances. */
        this._protoMeshes = new Map();
        /** Кешированные StandardMaterial по matId. */
        this._materials = new Map();
        /** matId → Array<key|null> — индекс слота instance'а → ключ voxel. */
        this._instanceKeys = new Map();
        /** matId → Array<idx> — свободные слоты (после удаления). */
        this._freeSlots = new Map();
        /** "x,y,z" → { mat, idx } — обратный индекс для быстрого remove. */
        this._cellToInst = new Map();
        /** Колбэк изменения (для авто-сохранения). */
        this._onChange = null;
        /** Колбэк создания proto-меша (для shadow caster registration). */
        this._onProtoCreated = null;
    }

    setOnChange(cb) { this._onChange = cb; }
    setOnProtoCreated(cb) { this._onProtoCreated = cb; }
    _emit() { try { this._onChange?.(); } catch (e) {} }

    count() { return this.voxels.size; }

    // ========================================================================
    // CRUD voxel'ов
    // ========================================================================

    /** Существует ли voxel в (x,y,z). */
    hasVoxel(x, y, z) {
        return this.voxels.has(`${x},${y},${z}`);
    }

    /** matId или null. */
    getVoxel(x, y, z) {
        return this.voxels.get(`${x},${y},${z}`) || null;
    }

    /**
     * Поставить voxel. Если уже стоит — переставляет с новым материалом
     * (удаляет старый instance, добавляет новый). Координаты целые.
     */
    setVoxel(x, y, z, matId) {
        if (!TERRAIN_MATERIALS[matId]) {
            // eslint-disable-next-line no-console
            console.warn(`[TerrainManager] unknown matId: ${matId}`);
            return;
        }
        this._ensureCellToInstHydrated();
        const key = `${x},${y},${z}`;
        const existing = this._cellToInst.get(key);
        if (existing) {
            if (existing.mat === matId) return;  // уже тот же материал
            this._removeInstance(key, existing);
        }
        this._addInstance(key, x, y, z, matId);
        this.voxels.set(key, matId);
        this._emit();
    }

    /** Удалить voxel. Если нет — no-op. */
    removeVoxel(x, y, z) {
        this._ensureCellToInstHydrated();
        const key = `${x},${y},${z}`;
        const existing = this._cellToInst.get(key);
        if (!existing) return;
        this._removeInstance(key, existing);
        this.voxels.delete(key);
        this._emit();
    }

    /**
     * Lazy-hydration _cellToInst после массовой загрузки большой карты.
     *
     * При loadFromArray на картах >100K вокселей на материал мы пропустили
     * заполнение _cellToInst (это экономит ~5-10 сек CPU + ~3 ГБ памяти).
     * При первом edit-операции — досоздаём Map по _instanceKeys.
     *
     * Стоимость одного prebuild: ~3-5 сек для 5.7M вокселей. Но это
     * происходит ОДИН РАЗ при первом клике кистью, а не при загрузке.
     */
    _ensureCellToInstHydrated() {
        // Перед редактированием материализуем все pending регионы.
        // Иначе кисть не увидит дальние воксели и не сможет их изменить.
        if (this._pendingRegions && this._pendingRegions.size > 0) {
            console.log(`[TerrainManager] materializing ${this._pendingRegions.size} pending regions before edit`);
            const keys = [...this._pendingRegions.keys()];
            for (const k of keys) this._materializeRegion(k);
            this._occupancySet = null;
        }
        if (!this._lazyCellToInstMats || this._lazyCellToInstMats.size === 0) return;
        const t0 = performance.now();
        let total = 0;
        for (const matId of this._lazyCellToInstMats) {
            const keysArr = this._instanceKeys.get(matId);
            if (!keysArr) continue;
            for (let idx = 0; idx < keysArr.length; idx++) {
                const key = keysArr[idx];
                if (key === null || key === undefined) continue;
                this._cellToInst.set(key, { mat: matId, idx });
                total++;
            }
        }
        this._lazyCellToInstMats.clear();
        const dt = performance.now() - t0;
        console.log(`[TerrainManager] lazy-hydrated _cellToInst: ${total} entries in ${dt.toFixed(0)}ms`);
    }

    /** Полная очистка. */
    clear() {
        this.voxels.clear();
        this._cellToInst.clear();
        if (this._lazyCellToInstMats) this._lazyCellToInstMats.clear();
        for (const [matId, proto] of this._protoMeshes) {
            try { proto.thinInstanceCount = 0; } catch (e) {}
            this._instanceKeys.set(matId, []);
            this._freeSlots.set(matId, []);
        }
        // Очищаем region-meshes от предыдущей генерации
        this._disposeRegionMeshes();
        this._emit();
    }

    // ========================================================================
    // Внутренности thin-instances
    // ========================================================================

    /** Получить или создать proto-меш для материала. */
    _getOrCreateProto(matId) {
        let proto = this._protoMeshes.get(matId);
        if (proto) return proto;

        const def = TERRAIN_MATERIALS[matId];
        if (!def) return null;

        // Куб размером VOXEL_SIZE, центр в (0,0,0). thinInstance ставится через
        // Matrix.Translation в _addInstance ниже. Размер ячейки 0.5×0.5×0.5
        // даёт 8× плотность относительно стандартного блока 1×1×1 — холмы
        // выглядят детальнее.
        proto = MeshBuilder.CreateBox(`__terrainProto_${matId}`, { size: VOXEL_SIZE }, this.scene);
        const matEntry = this._getMaterial(matId);
        proto.material = matEntry.material;

        // Если у материала разные текстуры на гранях (например grass: верх трава,
        // бока земля-с-травой, низ земля) — нужно создать SubMesh'и и применить
        // MultiMaterial. Логика повторяет BlockManager._cutCubeFaces.
        if (matEntry.isMulti) {
            this._cutCubeFaces(proto);
        }

        // Метаданные: помечаем как террейн-proto чтобы _pickFromMouse мог
        // отличить от пользовательских мешей.
        proto.metadata = { _isTerrainProto: true, terrainMatId: matId };
        // Без isPickable, чтобы стандартный scene.pick не возвращал
        // proto-mesh (как у BlockManager). Свой raycast — отдельно через
        // _cellToInst.
        proto.isPickable = false;
        // Тени: receiveShadows стоит — терреин может принимать тени от
        // других объектов (моделей). НО: cast shadows ОТКЛЮЧАЕМ через
        // не-регистрацию в shadowGenerator.renderList. Это критично для
        // больших карт — иначе shadow-pass отрендерит весь террейн дважды
        // (с player perspective + с sun perspective).
        proto.receiveShadows = true;
        // thinInstanceEnablePicking=false тоже, для производительности.
        try { proto.thinInstanceEnablePicking = false; } catch (e) {}
        // Критично для multi-material + thin-instances: без этих флагов
        // Babylon пересчитывает bbox при каждом thinInstanceAdd и может
        // потерять subMesh-разбиение (тогда все грани красятся subMaterials[0]).
        // То же делает BlockManager — иначе на блоке-«траве» бока тоже
        // становятся top-текстурой.
        proto.alwaysSelectAsActiveMesh = true;
        proto.doNotSyncBoundingInfo = true;
        // proto стоит в (0,0,0) и НИКОГДА не двигается — instance-матрицы
        // живут в GPU thin-instance buffer. freezeWorldMatrix экономит
        // ~5-10% CPU/кадр на больших картах. Безопасно потому что мы не
        // меняем proto.position/scaling/rotation.
        try { proto.freezeWorldMatrix(); } catch (e) {}

        this._protoMeshes.set(matId, proto);
        this._instanceKeys.set(matId, []);
        this._freeSlots.set(matId, []);

        try { this._onProtoCreated?.(proto); } catch (e) {}
        return proto;
    }

    /**
     * Разбить cube-mesh на 6 SubMesh (по одной на грань), чтобы MultiMaterial
     * мог применить разные материалы к front/back/right/left/top/bottom.
     * Логика идентична BlockManager._cutCubeFaces.
     */
    _cutCubeFaces(mesh) {
        const totalIndices = mesh.getTotalIndices();
        mesh.subMeshes = [];
        const indicesPerFace = totalIndices / 6;
        for (let face = 0; face < 6; face++) {
            new SubMesh(
                face,                       // материал-индекс
                0,                          // verticesStart
                mesh.getTotalVertices(),    // verticesCount
                face * indicesPerFace,      // indexStart
                indicesPerFace,             // indexCount
                mesh,
            );
        }
    }

    /**
     * Получить (создать если нет) материал для material-id.
     * Возвращает: { material, isMulti } — где material это StandardMaterial
     * (для простого куба) или MultiMaterial (для top/side/bottom-куба).
     *
     * Текстуры берутся из Kenney Voxel Pack (общий для блоков и террейна) —
     * за счёт этого холмы стилистически сливаются с обычной сценой.
     * Sampling — NEAREST (pixel-art), без size-mipmap-blurring.
     */
    _getMaterial(matId) {
        if (this._materials.has(matId)) {
            return this._materials.get(matId);
        }
        const def = TERRAIN_MATERIALS[matId];
        if (!def) return null;

        let entry;
        if (def.top || def.side || def.bottom) {
            // Мульти-материал: разные текстуры на верх/бока/низ (трава и подобное).
            const matTop    = this._createSingleMat(def, def.top    || def.side, `__terrainMat_${matId}_top`);
            const matSide   = this._createSingleMat(def, def.side   || def.top,  `__terrainMat_${matId}_side`);
            const matBottom = this._createSingleMat(def, def.bottom || def.side, `__terrainMat_${matId}_bot`);

            const multi = new MultiMaterial(`__terrainMulti_${matId}`, this.scene);
            multi.subMaterials[FACE_INDEX.front]  = matSide;
            multi.subMaterials[FACE_INDEX.back]   = matSide;
            multi.subMaterials[FACE_INDEX.right]  = matSide;
            multi.subMaterials[FACE_INDEX.left]   = matSide;
            multi.subMaterials[FACE_INDEX.top]    = matTop;
            multi.subMaterials[FACE_INDEX.bottom] = matBottom;
            // freeze ОТЛОЖЕН до завершения загрузки текстур (см. ниже).
            // Иначе material замораживается ДО того как Texture успеет
            // загрузиться асинхронно → шейдер пересобран без diffuseTexture
            // и видны только pure-light цвета (баг 2026-05-27 «текстуры
            // не показываются», голые stenки серо-зелёные с жёлтым верхом).
            this._scheduleFreezeAfterTextures([matSide, matTop, matBottom]);
            entry = { material: multi, isMulti: true };
        } else {
            // Простой куб — одна текстура на все 6 граней.
            const mat = this._createSingleMat(def, def.texture, `__terrainMat_${matId}`);
            this._scheduleFreezeAfterTextures([mat]);
            entry = { material: mat, isMulti: false };
        }

        this._materials.set(matId, entry);
        return entry;
    }

    /**
     * Создать один StandardMaterial с диффузной текстурой.
     * Текстура семплируется в NEAREST-режиме (sharp pixel-art под Kenney pack).
     */
    /**
     * Замораживает материалы только ПОСЛЕ загрузки их diffuseTexture.
     *
     * Babylon генерит шейдер материала под текущее состояние (есть текстура
     * или нет). Если позвать .freeze() ДО onLoad текстуры — шейдер
     * собрался без неё, и текстура потом никогда не «прорастёт» на
     * поверхность. Видны только pure-light цвета.
     */
    _scheduleFreezeAfterTextures(mats) {
        const pending = [];
        for (const mat of mats) {
            if (!mat) continue;
            const tex = mat.diffuseTexture;
            if (!tex) {
                // материал без текстуры — можно замораживать сразу
                try { mat.freeze?.(); } catch (e) {}
                continue;
            }
            if (tex.isReady?.()) {
                try { mat.freeze?.(); } catch (e) {}
                continue;
            }
            pending.push({ mat, tex });
        }
        for (const { mat, tex } of pending) {
            const onLoad = () => { try { mat.freeze?.(); } catch (e) {} };
            try {
                if (tex.onLoadObservable && typeof tex.onLoadObservable.addOnce === 'function') {
                    tex.onLoadObservable.addOnce(onLoad);
                } else if (typeof tex.onLoadObservable?.add === 'function') {
                    tex.onLoadObservable.add(onLoad);
                } else {
                    // Fallback: polling каждые 100мс, до 5с
                    let tries = 0;
                    const iv = setInterval(() => {
                        tries++;
                        if (tex.isReady?.() || tries > 50) {
                            clearInterval(iv);
                            try { mat.freeze?.(); } catch (e) {}
                        }
                    }, 100);
                }
            } catch (e) {
                try { mat.freeze?.(); } catch (_) {}
            }
        }
    }

    _createSingleMat(def, texturePath, name) {
        const mat = new StandardMaterial(name, this.scene);
        // Specular почти выключен — на pixel-art-текстуре любой блик уничтожает стиль.
        mat.specularColor = new Color3(0, 0, 0);
        // 2026-06-02: воксели «просвечивали» (видна задняя грань сквозь переднюю).
        // backFaceCulling=false рисует обе стороны, ближняя перекрывает дальнюю
        // по depth. Прозрачным (water/glacier) culling оставляем. См. studio.
        mat.backFaceCulling = !(def.alpha != null && def.alpha < 1) ? false : true;
        // Ambient ставим в белый, чтобы hemisphere-light освещал материал
        // с любой стороны (иначе нижние/тыловые грани выходят серыми, что
        // особенно заметно на светло-бежевом песке — он становится серым).
        // С ambientColor=(1,1,1) текстура «читается» в полной интенсивности
        // независимо от угла нормали к directional-свету.
        mat.ambientColor = new Color3(1, 1, 1);

        if (texturePath) {
            // mipmap=ON + TRILINEAR — критично для world-tiled UV.
            //
            // Раньше стоял noMipmap=true + NEAREST. Это работало для близких
            // блоков (pixel-art Kenney). Но с world-tiled UV (1 тайл = 1м)
            // на больших стенах вулкана/террейна тайл занимает 2-5 экранных
            // px → без mipmap GPU берёт один случайный pixel из 128×128 →
            // выглядит как сплошной средний цвет (баг 2026-05-27 «текстуры
            // не показываются»). Mipmap отдаёт усреднённую версию текстуры
            // для каждой дистанции — паттерн виден на любом расстоянии.
            // invertY=false — Kenney pack уже в обычной (top-down) ориентации.
            const tex = new Texture(
                texturePath, this.scene,
                /*noMipmap*/ false,
                /*invertY*/ false,
                Texture.TRILINEAR_SAMPLINGMODE,
            );
            mat.diffuseTexture = tex;
            if (def.alpha != null && def.alpha < 1) {
                mat.diffuseTexture.hasAlpha = true;
                mat.useAlphaFromDiffuseTexture = true;
                mat.alpha = def.alpha;
            } else {
                // RGBA-текстуры (alpha=255) Babylon мог рендерить с alpha-blend →
                // воксели просвечивали. Явно OPAQUE для непрозрачных. См. studio.
                mat.diffuseTexture.hasAlpha = false;
                mat.useAlphaFromDiffuseTexture = false;
                mat.transparencyMode = 0;
            }
            if (Array.isArray(def.emissive)) {
                mat.emissiveColor = new Color3(def.emissive[0], def.emissive[1], def.emissive[2]);
            }
        } else {
            // Fallback на цвет если по какой-то причине нет текстуры
            mat.diffuseColor = Color3.FromHexString(def.color || '#888');
            if (def.alpha != null && def.alpha < 1) mat.alpha = def.alpha;
        }
        return mat;
    }

    /** Добавить thin-instance. Возвращает idx слота. */
    _addInstance(key, x, y, z, matId) {
        const proto = this._getOrCreateProto(matId);
        if (!proto) return -1;

        // Перевод voxel-индекса → центр меша в мире.
        const wx = (x + 0.5) * VOXEL_SIZE;
        const wy = (y + 0.5) * VOXEL_SIZE;
        const wz = (z + 0.5) * VOXEL_SIZE;
        const matEntry = this._materials.get(matId);
        const isMulti = !!matEntry?.isMulti;
        let mat;
        if (isMulti) {
            mat = Matrix.Translation(wx, wy, wz);
        } else {
            const hash = ((x * 73856093) ^ (y * 19349663) ^ (z * 83492791)) >>> 0;
            const jitterY = ((hash % 1000) / 1000 - 0.5) * 0.10 * VOXEL_SIZE;
            mat = Matrix.Translation(wx, wy + jitterY, wz);
        }

        // Batch-режим: refresh=false → GPU-buffer не обновляется на каждом
        // add. Вызываем _flushBatch() в конце brush-операции. Это превращает
        // 17000 GPU-uploads (sphere r=16) → 1 upload. Драматическое ускорение
        // brushDraw на больших радиусах.
        const refresh = !this._inBatch;
        const free = this._freeSlots.get(matId);
        const keys = this._instanceKeys.get(matId);
        let idx;
        if (free && free.length > 0) {
            idx = free.pop();
            proto.thinInstanceSetMatrixAt(idx, mat, refresh);
            keys[idx] = key;
        } else {
            idx = proto.thinInstanceAdd(mat, refresh);
            keys[idx] = key;
        }
        this._cellToInst.set(key, { mat: matId, idx });
        if (this._inBatch) this._dirtyBatchProtos.add(matId);
        return idx;
    }

    /** Убрать thin-instance — сжимаем matrix до identity, помечаем слот свободным. */
    _removeInstance(key, info) {
        const proto = this._protoMeshes.get(info.mat);
        if (!proto) return;
        // Замораживаем слот за пределами видимости (scale=0).
        const zero = Matrix.Scaling(0, 0, 0);
        const refresh = !this._inBatch;
        try { proto.thinInstanceSetMatrixAt(info.idx, zero, refresh); } catch (e) {}
        const free = this._freeSlots.get(info.mat);
        const keys = this._instanceKeys.get(info.mat);
        if (keys) keys[info.idx] = null;
        if (free) free.push(info.idx);
        this._cellToInst.delete(key);
        if (this._inBatch) this._dirtyBatchProtos.add(info.mat);
    }

    /** Начать batch — add/remove не будут обновлять GPU-buffer. */
    _beginBatch() {
        this._inBatch = true;
        if (!this._dirtyBatchProtos) this._dirtyBatchProtos = new Set();
        this._dirtyBatchProtos.clear();
    }
    /** Завершить batch — один upload GPU-buffer'а на каждый затронутый proto. */
    _flushBatch() {
        if (!this._inBatch) return;
        this._inBatch = false;
        if (!this._dirtyBatchProtos) return;
        for (const matId of this._dirtyBatchProtos) {
            const proto = this._protoMeshes.get(matId);
            if (!proto) continue;
            try { proto.thinInstanceBufferUpdated('matrix'); } catch (e) {}
        }
        this._dirtyBatchProtos.clear();
    }

    // ========================================================================
    // Кисти
    // ========================================================================

    /** Вернуть набор voxel-ключей в кисти. shape: 'sphere'|'cube'|'cylinder'. */
    _enumerateBrushCells(brush) {
        const { x: cx, y: cy, z: cz, radius, shape = 'sphere' } = brush;
        const r = Math.max(1, Math.floor(radius));
        const cells = [];
        const rSq = r * r;
        for (let dx = -r; dx <= r; dx++) {
            for (let dy = -r; dy <= r; dy++) {
                for (let dz = -r; dz <= r; dz++) {
                    let inside;
                    if (shape === 'cube') {
                        inside = true;
                    } else if (shape === 'cylinder') {
                        // Цилиндр вдоль Y: круг в XZ + полная высота 2r.
                        inside = (dx * dx + dz * dz) <= rSq;
                    } else {
                        // sphere
                        inside = (dx * dx + dy * dy + dz * dz) <= rSq;
                    }
                    if (inside) {
                        cells.push([cx + dx, cy + dy, cz + dz]);
                    }
                }
            }
        }
        return cells;
    }

    /**
     * Кисть «Рисовать» — добавить voxel'ы материала в области кисти.
     * Не перезаписывает существующие (иначе придётся ставить кисть рядом).
     * Для перекраски используй brushPaint.
     */
    brushDraw(brush, matId) {
        this._ensureCellToInstHydrated();
        const cells = this._enumerateBrushCells(brush);
        let added = 0;
        const affected = [];
        this._beginBatch();
        try {
            for (const [x, y, z] of cells) {
                const key = `${x},${y},${z}`;
                if (this.voxels.has(key)) continue;
                this._addInstance(key, x, y, z, matId);
                this.voxels.set(key, matId);
                affected.push([x, y, z]);
                added++;
            }
            if (matId === 'grass' && affected.length < 2000) {
                this._normalizeGrass(affected);
            }
        } finally {
            this._flushBatch();
        }
        if (added > 0) this._emit();
        return added;
    }

    /**
     * Постобработка после кисти grass: оставляет grass только на ВЕРХНЕМ
     * voxel'е каждого столбца, всё что ниже — превращает в dirt.
     *
     * Логика как у Minecraft: блок grass всегда «корочка» поверх земли.
     * Без этого столбец из 3+ grass-кубов показывает 3 повторения боковой
     * текстуры (зелёная полоса каждые 0.5м), что визуально выглядит как
     * «полосы травы» на стене.
     *
     * Принимает список затронутых клеток (xyz-массивы), плюс перебирает
     * их верхних соседей чтобы исправить и предыдущие grass'ы.
     */
    _normalizeGrass(cells) {
        if (!cells || cells.length === 0) return;
        // Собираем уникальные XZ-столбцы которые могли быть затронуты,
        // и для каждого — диапазон Y который реально поменялся.
        const colRange = new Map(); // "x,z" → {minY, maxY}
        for (const [x, y, z] of cells) {
            const k = `${x},${z}`;
            const r = colRange.get(k);
            if (!r) {
                colRange.set(k, { minY: y, maxY: y });
            } else {
                if (y < r.minY) r.minY = y;
                if (y > r.maxY) r.maxY = y;
            }
        }
        // Для каждого столбца: сканируем только в [minY-1 .. maxY+1] —
        // верхний grass найдём за O(range) вместо O(voxels.size).
        for (const [colKey, range] of colRange) {
            const onlyComma = colKey.lastIndexOf(',');
            const x = parseInt(colKey.slice(0, onlyComma), 10);
            const z = parseInt(colKey.slice(onlyComma + 1), 10);
            // Сначала находим верхний grass в столбце вокруг изменённой зоны.
            // Если grass нет — нечего нормализовывать.
            let topGrassY = null;
            const scanMax = range.maxY + 1;
            const scanMin = range.minY - 1;
            for (let y = scanMax; y >= scanMin; y--) {
                if (this.voxels.get(`${x},${y},${z}`) === 'grass') {
                    topGrassY = y;
                    break;
                }
            }
            if (topGrassY === null) continue;
            // Теперь от topGrassY-1 вниз до scanMin превращаем все grass в dirt.
            for (let y = topGrassY - 1; y >= scanMin; y--) {
                const k = `${x},${y},${z}`;
                if (this.voxels.get(k) !== 'grass') continue;
                const info = this._cellToInst.get(k);
                if (info) this._removeInstance(k, info);
                this._addInstance(k, x, y, z, 'dirt');
                this.voxels.set(k, 'dirt');
            }
        }
    }

    /**
     * Кисть «Стереть» — убрать voxel'ы в области кисти.
     */
    brushErase(brush) {
        this._ensureCellToInstHydrated();
        const cells = this._enumerateBrushCells(brush);
        let removed = 0;
        this._beginBatch();
        try {
            for (const [x, y, z] of cells) {
                const key = `${x},${y},${z}`;
                const info = this._cellToInst.get(key);
                if (!info) continue;
                this._removeInstance(key, info);
                this.voxels.delete(key);
                removed++;
            }
        } finally {
            this._flushBatch();
        }
        if (removed > 0) this._emit();
        return removed;
    }

    /**
     * Кисть «Скульпт» — поднимает (dir=+1) или опускает (dir=-1) поверхность.
     * Для каждого XZ-столбца в радиусе кисти находит верхний voxel и добавляет
     * над ним новый (или удаляет верхний если dir<0).
     * Сила в зависимости от strength: 1..1 столбец, 5..несколько строк.
     */
    brushSculpt(brush, dir, matId, strength = 1) {
        this._ensureCellToInstHydrated();
        const { x: cx, z: cz, radius } = brush;
        const r = Math.max(1, Math.floor(radius));
        const rSq = r * r;
        let changed = 0;
        const layers = Math.max(1, Math.min(8, Math.round(strength / 25)));  // 1..4
        const affected = [];
        // Оптимизация: диапазон сканирования _findTopY = brush.y±r вместо ±r*2
        // (поверхность не может быть сильно выше центра brush — drag-lock-Y
        // уже выровнял курсор по первой точке).
        const yMax = brush.y + r;
        const yMin = brush.y - r;
        this._beginBatch();
        try {
            for (let dx = -r; dx <= r; dx++) {
                const dx2 = dx * dx;
                for (let dz = -r; dz <= r; dz++) {
                    if (dx2 + dz * dz > rSq) continue;
                    const x = cx + dx;
                    const z = cz + dz;
                    const topY = this._findTopY(x, z, yMax, yMin);
                    if (dir > 0) {
                        const startY = topY === null ? brush.y : topY + 1;
                        for (let i = 0; i < layers; i++) {
                            const ny = startY + i;
                            const key = `${x},${ny},${z}`;
                            if (this.voxels.has(key)) continue;
                            this._addInstance(key, x, ny, z, matId);
                            this.voxels.set(key, matId);
                            affected.push([x, ny, z]);
                            changed++;
                        }
                    } else {
                        if (topY === null) continue;
                        for (let i = 0; i < layers; i++) {
                            const ny = topY - i;
                            const key = `${x},${ny},${z}`;
                            const info = this._cellToInst.get(key);
                            if (!info) break;
                            this._removeInstance(key, info);
                            this.voxels.delete(key);
                            affected.push([x, ny, z]);
                            changed++;
                        }
                    }
                }
            }
            if (affected.length < 2000) {
                this._normalizeGrass(affected);
            }
        } finally {
            this._flushBatch();
        }
        if (changed > 0) this._emit();
        return changed;
    }

    /**
     * Кисть «Сгладить» — для каждого XZ-столбца в радиусе подгоняет верхнюю
     * Y к усреднённой высоте соседних столбцов. Резкие пики срезает,
     * глубокие ямы засыпает.
     *
     * matId=null → используем материал верхнего voxel каждого столбца.
     * Это позволяет сглаживать без выбора материала и без перекрашивания.
     */
    brushSmooth(brush, matId) {
        this._ensureCellToInstHydrated();
        const { x: cx, z: cz, radius } = brush;
        const r = Math.max(1, Math.floor(radius));
        const rSq = r * r;
        // 1. Собрать высоты столбцов в радиусе
        const heights = new Map(); // "x,z" → topY
        let sum = 0;
        let n = 0;
        for (let dx = -r; dx <= r; dx++) {
            for (let dz = -r; dz <= r; dz++) {
                if (dx * dx + dz * dz > rSq) continue;
                const x = cx + dx;
                const z = cz + dz;
                const topY = this._findTopY(x, z, brush.y + r * 4, brush.y - r * 4);
                if (topY === null) continue;
                heights.set(`${x},${z}`, topY);
                sum += topY;
                n++;
            }
        }
        if (n === 0) return 0;
        const avg = sum / n;
        // 2. Для каждого столбца сдвинуть topY к avg (1 шаг)
        let changed = 0;
        this._beginBatch();
        for (const [k, topY] of heights) {
            const [xs, zs] = k.split(',');
            const x = parseInt(xs, 10);
            const z = parseInt(zs, 10);
            // Сдвигаем на 1 шаг в сторону avg
            if (topY > avg + 0.5) {
                // Срезаем верх
                const info = this._cellToInst.get(`${x},${topY},${z}`);
                if (info) {
                    this._removeInstance(`${x},${topY},${z}`, info);
                    this.voxels.delete(`${x},${topY},${z}`);
                    changed++;
                }
            } else if (topY < avg - 0.5) {
                // Засыпаем сверху — материалом этого столбца (если matId не задан)
                const ny = topY + 1;
                const nk = `${x},${ny},${z}`;
                if (!this.voxels.has(nk)) {
                    // Берём материал ближайшего solid voxel в столбце,
                    // fallback на 'grass' если столбец пуст.
                    let mat = matId;
                    if (!mat) {
                        mat = this.voxels.get(`${x},${topY},${z}`) || 'grass';
                    }
                    this._addInstance(nk, x, ny, z, mat);
                    this.voxels.set(nk, mat);
                    changed++;
                }
            }
        }
        this._flushBatch();
        if (changed > 0) this._emit();
        return changed;
    }

    /**
     * Кисть «Выровнять» — все столбцы в радиусе кисти приводит к Y=brush.y.
     * Лишнее срезает, нехватку засыпает.
     */
    brushFlatten(brush, matId) {
        this._ensureCellToInstHydrated();
        const { x: cx, y: targetY, z: cz, radius } = brush;
        const r = Math.max(1, Math.floor(radius));
        const rSq = r * r;
        let changed = 0;
        const affected = [];
        this._beginBatch();
        try {
            for (let dx = -r; dx <= r; dx++) {
                for (let dz = -r; dz <= r; dz++) {
                    if (dx * dx + dz * dz > rSq) continue;
                    const x = cx + dx;
                    const z = cz + dz;
                    let cur = this._findTopY(x, z, targetY + r * 4, targetY - r * 4);
                    while (cur !== null && cur > targetY) {
                        const info = this._cellToInst.get(`${x},${cur},${z}`);
                        if (info) {
                            this._removeInstance(`${x},${cur},${z}`, info);
                            this.voxels.delete(`${x},${cur},${z}`);
                            affected.push([x, cur, z]);
                            changed++;
                        }
                        cur--;
                    }
                    const startY = cur === null ? targetY : cur + 1;
                    for (let y = startY; y <= targetY; y++) {
                        const k = `${x},${y},${z}`;
                        if (this.voxels.has(k)) continue;
                        this._addInstance(k, x, y, z, matId);
                        this.voxels.set(k, matId);
                        affected.push([x, y, z]);
                        changed++;
                    }
                }
            }
            if (affected.length < 2000) {
                this._normalizeGrass(affected);
            }
        } finally {
            this._flushBatch();
        }
        if (changed > 0) this._emit();
        return changed;
    }

    /**
     * Кисть «Раскрасить» — меняет матeриал у существующих voxel'ов в области
     * кисти, без изменения формы.
     */
    brushPaint(brush, matId) {
        this._ensureCellToInstHydrated();
        const cells = this._enumerateBrushCells(brush);
        let changed = 0;
        this._beginBatch();
        try {
            for (const [x, y, z] of cells) {
                const key = `${x},${y},${z}`;
                const info = this._cellToInst.get(key);
                if (!info) continue;
                if (info.mat === matId) continue;
                this._removeInstance(key, info);
                this._addInstance(key, x, y, z, matId);
                this.voxels.set(key, matId);
                changed++;
            }
        } finally {
            this._flushBatch();
        }
        if (changed > 0) this._emit();
        return changed;
    }

    /**
     * Найти верхний voxel в столбце (x, z) в диапазоне [yMin..yMax].
     * Возвращает Y верхнего voxel'а или null если столбец пуст.
     */
    _findTopY(x, z, yMax, yMin) {
        const min = Math.floor(Math.min(yMin, yMax));
        const max = Math.floor(Math.max(yMin, yMax));
        for (let y = max; y >= min; y--) {
            if (this.voxels.has(`${x},${y},${z}`)) return y;
        }
        return null;
    }

    /** Найти voxel под точкой клика (raycast по AABB-сетке).
     *  Работает в МИРОВЫХ координатах, но grid-индексы получаются делением
     *  на VOXEL_SIZE. DDA-traversal идёт шагами длиной VOXEL_SIZE в мире,
     *  каждый шаг = ±1 индекс voxel'а. */
    pickVoxelByRay(rayOrigin, rayDir, maxDist = 200) {
        const o = rayOrigin;
        const d = rayDir;
        const S = VOXEL_SIZE;
        // Текущая клетка — мировая координата делённая на размер voxel'а
        let ix = Math.floor(o.x / S);
        let iy = Math.floor(o.y / S);
        let iz = Math.floor(o.z / S);
        const stepX = d.x > 0 ? 1 : -1;
        const stepY = d.y > 0 ? 1 : -1;
        const stepZ = d.z > 0 ? 1 : -1;
        // Длина мирового шага при переходе на одну клетку — VOXEL_SIZE / |d.k|
        const tDeltaX = Math.abs(S / d.x);
        const tDeltaY = Math.abs(S / d.y);
        const tDeltaZ = Math.abs(S / d.z);
        // Расстояние до ближайшей границы клетки в мире
        const nextX = (d.x > 0 ? (ix + 1) * S : ix * S);
        const nextY = (d.y > 0 ? (iy + 1) * S : iy * S);
        const nextZ = (d.z > 0 ? (iz + 1) * S : iz * S);
        let tMaxX = (nextX - o.x) / d.x;
        let tMaxY = (nextY - o.y) / d.y;
        let tMaxZ = (nextZ - o.z) / d.z;
        if (!isFinite(tMaxX)) tMaxX = Infinity;
        if (!isFinite(tMaxY)) tMaxY = Infinity;
        if (!isFinite(tMaxZ)) tMaxZ = Infinity;

        let t = 0;
        let lastAxis = 'none';
        // Лимит итераций — в мире 200 м максимум, при шаге 0.5м = до 400 шагов
        const MAX_STEPS = Math.ceil(maxDist / S);
        for (let i = 0; i < MAX_STEPS; i++) {
            if (this.voxels.has(`${ix},${iy},${iz}`)) {
                // Нормаль — противоположна шагу по той оси что мы только что пересекли
                let nx = 0, ny = 0, nz = 0;
                if (lastAxis === 'x') nx = -stepX;
                else if (lastAxis === 'y') ny = -stepY;
                else if (lastAxis === 'z') nz = -stepZ;
                return {
                    cell: { x: ix, y: iy, z: iz },
                    normal: { x: nx, y: ny, z: nz },
                    t,
                };
            }
            if (tMaxX < tMaxY && tMaxX < tMaxZ) {
                ix += stepX;
                t = tMaxX;
                tMaxX += tDeltaX;
                lastAxis = 'x';
            } else if (tMaxY < tMaxZ) {
                iy += stepY;
                t = tMaxY;
                tMaxY += tDeltaY;
                lastAxis = 'y';
            } else {
                iz += stepZ;
                t = tMaxZ;
                tMaxZ += tDeltaZ;
                lastAxis = 'z';
            }
            if (t > maxDist) return null;
        }
        return null;
    }

    // ========================================================================
    // Сериализация
    // ========================================================================

    /**
     * Сохранить как массив `{x, y, z, m}` (m — короткий ключ материала).
     * Используется в project_data.scene.terrain.
     */
    serialize() {
        const out = [];
        for (const [key, mat] of this.voxels) {
            const [xs, ys, zs] = key.split(',');
            out.push({
                x: parseInt(xs, 10),
                y: parseInt(ys, 10),
                z: parseInt(zs, 10),
                m: mat,
            });
        }
        return out;
    }

    /**
     * RLE-сериализация (Этап 3 voxel-движка) — компактный формат для БД.
     *
     * Размер на 250м карте: ~1.5 МБ вместо ~38 МБ legacy JSON (×25 меньше).
     * Это критично — legacy JSON.stringify на 38МБ блокирует браузер на 10+
     * секунд и БД отказывается принимать такой payload.
     *
     * Формат:
     * {
     *   format: 'rle-v1',
     *   palette: ['grass', 'rock', 'sand', ...],   // index 0 = пусто (не используется)
     *   chunks: {
     *     "cx,cy,cz": "<base64 of RLE bytes>",     // только непустые чанки
     *     ...
     *   }
     * }
     *
     * @param {Function} [progressCb] - (done, total) для отображения прогресса
     * @returns {Object} RLE-формат
     */
    serializeRLE(progressCb = null) {
        // Группируем voxel'и по чанкам 32×32×32 (тот же CHUNK_SIZE что в VoxelChunk)
        const CHUNK_SIZE = 32;
        const CHUNK_VOLUME = CHUNK_SIZE ** 3;

        // 1. Палитра материалов (на лету)
        const palette = [null];   // index 0 = пусто
        const matToIdx = new Map();
        const getMatIdx = (matId) => {
            let idx = matToIdx.get(matId);
            if (idx === undefined) {
                idx = palette.length;
                palette.push(matId);
                matToIdx.set(matId, idx);
            }
            return idx;
        };

        // 2. Группируем voxel'и по chunkKey, заполняем Uint8Array на чанк
        // Используем sparse Map чтобы пустые чанки не аллоцировались.
        const chunkData = new Map();   // "cx,cy,cz" → Uint8Array(32768)
        let processed = 0;
        const total = this.voxels.size;
        for (const [key, matId] of this.voxels) {
            const lastComma = key.lastIndexOf(',');
            const midComma = key.lastIndexOf(',', lastComma - 1);
            const x = parseInt(key.slice(0, midComma), 10);
            const y = parseInt(key.slice(midComma + 1, lastComma), 10);
            const z = parseInt(key.slice(lastComma + 1), 10);
            const cx = Math.floor(x / CHUNK_SIZE);
            const cy = Math.floor(y / CHUNK_SIZE);
            const cz = Math.floor(z / CHUNK_SIZE);
            const lx = x - cx * CHUNK_SIZE;
            const ly = y - cy * CHUNK_SIZE;
            const lz = z - cz * CHUNK_SIZE;
            const chunkKey = `${cx},${cy},${cz}`;
            let data = chunkData.get(chunkKey);
            if (!data) {
                data = new Uint8Array(CHUNK_VOLUME);
                chunkData.set(chunkKey, data);
            }
            const idx = ly * CHUNK_SIZE * CHUNK_SIZE + lz * CHUNK_SIZE + lx;
            data[idx] = getMatIdx(matId);
            processed++;
            if (progressCb && processed % 10000 === 0) {
                progressCb(processed * 0.5, total);   // первая половина прогресса
            }
        }

        // 3. Для каждого чанка — RLE encode + base64
        const chunks = {};
        let chunkIdx = 0;
        const totalChunks = chunkData.size;
        for (const [chunkKey, data] of chunkData) {
            const rleBytes = this._encodeRLE(data);
            const b64 = this._uint8ToBase64(rleBytes);
            chunks[chunkKey] = b64;
            chunkIdx++;
            if (progressCb && chunkIdx % 16 === 0) {
                progressCb(total * 0.5 + (chunkIdx / totalChunks) * total * 0.5, total);
            }
        }
        if (progressCb) progressCb(total, total);

        return {
            format: 'rle-v1',
            palette,
            chunks,
        };
    }

    /**
     * Десериализация RLE-формата обратно в Map voxel'ов + загрузка через
     * loadFromArray (для region-meshes/рендера). Возвращает Promise.
     */
    async loadFromRLE(rleData, onProgress) {
        if (!rleData || rleData.format !== 'rle-v1') {
            throw new Error('loadFromRLE: invalid format ' + (rleData?.format ?? 'unknown'));
        }
        const CHUNK_SIZE = 32;
        const palette = rleData.palette || [null];
        const voxels = [];   // {x, y, z, m}
        const chunkEntries = Object.entries(rleData.chunks || {});
        let processed = 0;
        const total = chunkEntries.length;

        for (const [chunkKey, b64] of chunkEntries) {
            const [cx, cy, cz] = chunkKey.split(',').map(Number);
            const rleBytes = this._base64ToUint8(b64);
            const data = this._decodeRLE(rleBytes);
            for (let ly = 0; ly < CHUNK_SIZE; ly++) {
                for (let lz = 0; lz < CHUNK_SIZE; lz++) {
                    for (let lx = 0; lx < CHUNK_SIZE; lx++) {
                        const idx = data[ly * CHUNK_SIZE * CHUNK_SIZE + lz * CHUNK_SIZE + lx];
                        if (idx === 0) continue;
                        const m = palette[idx];
                        if (!m) continue;
                        voxels.push({
                            x: cx * CHUNK_SIZE + lx,
                            y: cy * CHUNK_SIZE + ly,
                            z: cz * CHUNK_SIZE + lz,
                            m,
                        });
                    }
                }
            }
            processed++;
            if (onProgress && processed % 8 === 0) onProgress(processed, total);
        }
        // Делегируем основной загрузке (она строит region-meshes и т.д.)
        return await this.loadFromArray(voxels);
    }

    // ========================================================================
    // RLE encoding helpers (приватные)
    // ========================================================================

    /** Uint8Array → RLE byte stream.
     *  Формат: uint16 numRuns, [uint16 start, uint16 len, uint8 matIdx] × N */
    _encodeRLE(data) {
        const CHUNK_VOLUME = data.length;
        const runs = [];
        let i = 0;
        while (i < CHUNK_VOLUME) {
            const mat = data[i];
            if (mat === 0) { i++; continue; }
            let j = i + 1;
            while (j < CHUNK_VOLUME && data[j] === mat) j++;
            runs.push({ start: i, length: j - i, matIdx: mat });
            i = j;
        }
        const buf = new Uint8Array(2 + runs.length * 5);
        const view = new DataView(buf.buffer);
        view.setUint16(0, runs.length, true);
        let offset = 2;
        for (const r of runs) {
            view.setUint16(offset, r.start, true);
            view.setUint16(offset + 2, r.length, true);
            view.setUint8(offset + 4, r.matIdx);
            offset += 5;
        }
        return buf;
    }

    /** RLE bytes → Uint8Array(32768). */
    _decodeRLE(bytes) {
        const CHUNK_SIZE = 32;
        const CHUNK_VOLUME = CHUNK_SIZE ** 3;
        const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
        const numRuns = view.getUint16(0, true);
        const data = new Uint8Array(CHUNK_VOLUME);
        let offset = 2;
        for (let i = 0; i < numRuns; i++) {
            const start = view.getUint16(offset, true);
            const length = view.getUint16(offset + 2, true);
            const matIdx = view.getUint8(offset + 4);
            for (let j = 0; j < length; j++) data[start + j] = matIdx;
            offset += 5;
        }
        return data;
    }

    /** Uint8Array → base64 (с чанками по 8KB чтобы не упереться в stack). */
    _uint8ToBase64(bytes) {
        let binary = '';
        const chunkSize = 8192;
        for (let i = 0; i < bytes.length; i += chunkSize) {
            const chunk = bytes.subarray(i, Math.min(i + chunkSize, bytes.length));
            binary += String.fromCharCode.apply(null, chunk);
        }
        return btoa(binary);
    }

    /** base64 → Uint8Array. */
    _base64ToUint8(b64) {
        const binary = atob(b64);
        const len = binary.length;
        const bytes = new Uint8Array(len);
        for (let i = 0; i < len; i++) bytes[i] = binary.charCodeAt(i);
        return bytes;
    }

    /** Загрузить из массива (формат serialize).
     *
     *  АРХИТЕКТУРА (важна для производительности!):
     *
     *  Старая версия делала `thinInstanceAdd` на каждом voxel'е — это
     *  переаллоцирует GPU-буфер матриц каждый раз. На 66К voxel'ов это
     *  занимало ~1 минуту.
     *
     *  Сейчас:
     *    1. Группируем voxel'ы по материалу за O(N) — голые данные.
     *    2. Нормализуем grass прямо в данных (без создания instance'ов).
     *    3. Для каждого материала строим один большой Float32Array из
     *       N×16 значений (N матриц) и заливаем через `thinInstanceSetBuffer`
     *       — ОДИН GPU upload вместо N штук.
     *
     *  На 66К voxel'ов это ~100-200мс вместо 60 секунд.
     *
     *  Возвращает Promise (для совместимости со старым кодом). onProgress
     *  больше не нужен — операция быстрая.
     */
    async loadFromArray(arr, onProgress) {
        this.clear();
        if (!Array.isArray(arr) || arr.length === 0) {
            this._emit();
            return Promise.resolve();
        }

        // ---- 1. Группировка voxel'ов по материалу ----
        // Заодно строим this.voxels (Map<"x,y,z", matId>) — это источник правды.
        // ВНИМАНИЕ: пока НЕ создаём thin-instances. Только данные.
        //
        // ОПТИМИЗАЦИЯ: раньше делали 2 прохода — сначала this.voxels.set(),
        // потом re-парсили ключи через parseInt × 3 в byMat. На 5.7M
        // вокселей это давало 17M parseInt + 5.7M lastIndexOf — очень
        // дорого (10-20 секунд CPU). Теперь — ОДИН проход: складываем в
        // воксели и byMat одновременно. Дубликаты обрабатываем через
        // dedup-Set (если ключ уже видели, помечаем для замены матом).
        /** matId → Array<[x, y, z]> */
        const byMat = new Map();
        // Для дедупа дубликатов — если входной arr содержит один и тот же
        // ключ дважды, побеждает ПОСЛЕДНИЙ (как раньше). Чтобы не возвращаться
        // к старым, храним индекс в bucket каждого ключа.
        // На больших картах дубликатов почти нет → проверка дешёвая.
        const keyToBucketIdx = new Map();
        for (let i = 0; i < arr.length; i++) {
            const v = arr[i];
            if (!v || typeof v.x !== 'number') continue;
            const matId = v.m || v.mat || 'grass';
            if (!TERRAIN_MATERIALS[matId]) continue;
            const x = v.x, y = v.y, z = v.z;
            const key = `${x},${y},${z}`;
            const prev = this.voxels.get(key);
            this.voxels.set(key, matId);
            if (prev === undefined) {
                // Новый ключ — добавляем в bucket
                let bucket = byMat.get(matId);
                if (!bucket) { bucket = []; byMat.set(matId, bucket); }
                keyToBucketIdx.set(key, { matId, idx: bucket.length });
                bucket.push([x, y, z]);
            } else if (prev !== matId) {
                // Дубликат с другим материалом — удаляем из старого bucket
                // (помечаем null) и добавляем в новый.
                const oldInfo = keyToBucketIdx.get(key);
                if (oldInfo) {
                    const oldBucket = byMat.get(oldInfo.matId);
                    if (oldBucket) oldBucket[oldInfo.idx] = null;
                }
                let bucket = byMat.get(matId);
                if (!bucket) { bucket = []; byMat.set(matId, bucket); }
                keyToBucketIdx.set(key, { matId, idx: bucket.length });
                bucket.push([x, y, z]);
            }
            // prev === matId → ничего не делаем (тот же ключ + тот же мат)
        }
        // Чистим null'ы (созданные при дедупе)
        for (const [matId, bucket] of byMat) {
            const compact = [];
            for (let i = 0; i < bucket.length; i++) {
                if (bucket[i] !== null) compact.push(bucket[i]);
            }
            byMat.set(matId, compact);
        }
        keyToBucketIdx.clear();

        // ---- 2. Нормализация grass на голых данных ----
        // Это работает напрямую с this.voxels (Map), переводит grass под верхушкой
        // в dirt. Без создания thin-instances — никаких аллокаций GPU.
        this._normalizeGrassData(byMat);

        // ---- 3. Bulk-заливка по материалам ----
        // Создаём прото-меш + один Float32Array на 16×N значений + один
        // thinInstanceSetBuffer вызов на материал.
        //
        // ВАЖНО: после заливки переопределяем boundingInfo на КАЖДОМ proto-mesh
        // вычисленным AABB его voxel'ов. Это позволит Babylon правильно
        // выполнять frustum culling per-mesh. Без этого с alwaysSelectAsActiveMesh
        // false мы потеряли бы рендер совсем.
        let totalProcessed = 0;
        for (const [matId, cells] of byMat) {
            if (cells.length === 0) continue;
            const proto = this._getOrCreateProto(matId);
            if (!proto) continue;

            const N = cells.length;
            const buffer = new Float32Array(16 * N);
            const isMulti = !!this._materials.get(matId)?.isMulti;
            const tmpMat = new Float32Array(16);

            // Вычисляем AABB всех voxel'ов этого материала
            let minX = Infinity, minY = Infinity, minZ = Infinity;
            let maxX = -Infinity, maxY = -Infinity, maxZ = -Infinity;

            // ОПТИМИЗАЦИЯ: на больших картах _cellToInst строится lazy.
            // Сам _cellToInst нужен только при edit'инге (setVoxel/removeVoxel).
            // На карте 5.7M вокселей Map с 5.7M записями жрёт ~2-3 ГБ
            // памяти JS и 5-10 сек CPU. До первого edit'а он не нужен.
            //
            // Решение: при загрузке заполняем только _instanceKeys (массив
            // ключей по индексу — нужен для editing math). А _cellToInst
            // помечаем как "lazy" и строим только при первом обращении в edit.
            const lazyMode = N > 100_000;   // порог lazy режима
            const keysArr = this._instanceKeys.get(matId);

            for (let i = 0; i < N; i++) {
                const [x, y, z] = cells[i];
                const wx = (x + 0.5) * VOXEL_SIZE;
                const wy = (y + 0.5) * VOXEL_SIZE;
                const wz = (z + 0.5) * VOXEL_SIZE;
                if (wx < minX) minX = wx; if (wx > maxX) maxX = wx;
                if (wy < minY) minY = wy; if (wy > maxY) maxY = wy;
                if (wz < minZ) minZ = wz; if (wz > maxZ) maxZ = wz;
                let translateY = wy;
                if (!isMulti) {
                    // Y-jitter ±5% детерминированно от координат
                    const hash = ((x * 73856093) ^ (y * 19349663) ^ (z * 83492791)) >>> 0;
                    const jitterY = ((hash % 1000) / 1000 - 0.5) * 0.10 * VOXEL_SIZE;
                    translateY += jitterY;
                }
                // Матрица Translation в column-major (Babylon формат)
                tmpMat[0]  = 1; tmpMat[1]  = 0; tmpMat[2]  = 0; tmpMat[3]  = 0;
                tmpMat[4]  = 0; tmpMat[5]  = 1; tmpMat[6]  = 0; tmpMat[7]  = 0;
                tmpMat[8]  = 0; tmpMat[9]  = 0; tmpMat[10] = 1; tmpMat[11] = 0;
                tmpMat[12] = wx; tmpMat[13] = translateY; tmpMat[14] = wz; tmpMat[15] = 1;
                buffer.set(tmpMat, i * 16);

                // Обновляем индексные структуры (для последующих set/remove)
                const key = `${x},${y},${z}`;
                keysArr[i] = key;
                if (!lazyMode) {
                    // Малая карта — сразу строим _cellToInst как раньше
                    this._cellToInst.set(key, { mat: matId, idx: i });
                }
            }
            // Большая карта — помечаем что _cellToInst для этого материала
            // нужно построить lazy при первом edit'е.
            if (lazyMode) {
                if (!this._lazyCellToInstMats) this._lazyCellToInstMats = new Set();
                this._lazyCellToInstMats.add(matId);
            }

            // Одна команда вместо N — GPU upload весь буфер сразу
            try {
                proto.thinInstanceSetBuffer('matrix', buffer, 16);
            } catch (e) {
                // eslint-disable-next-line no-console
                console.error('thinInstanceSetBuffer failed for', matId, e);
            }

            // Сохраняем bbox этого материала для последующего region-split.
            // Пока не используется — для будущей оптимизации.
            proto.metadata = proto.metadata || {};
            proto.metadata.terrainBBox = { minX, minY, minZ, maxX, maxY, maxZ };

            // ОТКЛЮЧАЕМ proto-mesh СРАЗУ для больших карт (>50К voxels).
            // Раньше: proto оставался enabled до конца region-split (20+ сек),
            // рендеря всю карту thin-instance кубами (10-45M триангулов) =
            // FPS падал до 1. Region-split в конце выключал, но юзер успевал
            // увидеть лаги.
            // Теперь: для больших карт сразу выключаем — region-split построит
            // нормальные региональные меши и пользователь увидит карту
            // постепенно.
            if (N > 50000) {
                try { proto.setEnabled(false); } catch (e) {}
            }

            totalProcessed += N;
            try { onProgress?.(totalProcessed, this.voxels.size); } catch (e) {}
        }

        // ---- 4. REGION-SPLITTING для больших карт ----
        // Если карта большая (>~80м), грубое разбиение по материалам не
        // даёт frustum culling: каждый материал распределён по всей карте,
        // его bbox = вся карта, Babylon всегда рендерит.
        //
        // Решение: для больших карт ДОПОЛНИТЕЛЬНО разбиваем по region 32м.
        // Каждый region × material = отдельный thin-instance proto-mesh
        // со своим bbox. Babylon культит регионы вне frustum автоматически.
        //
        // Регионы создаются как обычные proto-meshes с именем
        // "__terrainProto_${matId}__R_${rx}_${rz}". Они НЕ участвуют в
        // _cellToInst (он остаётся на основных protos). Если в будущем
        // нужно редактировать через кисти — выкинуть регионы и пересобрать.
        await this._buildRegionMeshes(byMat);

        // === Этап оптимизации после загрузки ===
        // Применяем тяжёлые оптимизации Babylon для thin-instances. Они
        // безопасны потому что после loadFromArray террейн статичный
        // (изменения только через кисти, которые сами зовут _setInstance
        // и инвалидируют эти оптимизации при необходимости).
        this._optimizeForRender();

        this._emit();
        return Promise.resolve();
    }

    /**
     * Применить оптимизации Babylon ко всем proto-мешам террейна.
     * Вызывается после loadFromArray (когда мир статичный).
     *
     *   1. Per-mesh thin-instance octree — frustum culling per-instance.
     *      Babylon разбивает 100К матриц на пространственные partitions
     *      и рендерит только видимые на экране. Это самая мощная
     *      оптимизация для thin-instances. БЕЗОПАСНО (это per-mesh API,
     *      не глобальный scene.createOrUpdateSelectionOctree).
     *
     *   2. material.freeze() — запрещает Babylon пересчитывать шейдер
     *      каждый кадр. Экономит CPU на больших сценах.
     *
     *   3. proto.freezeWorldMatrix() — proto-mesh стоит в (0,0,0),
     *      world matrix не меняется. Экономит matrix-recompute каждый кадр.
     */
    /**
     * Собрать MERGED геометрию для региона: positions/normals/uvs/indices.
     * Добавляем только видимые грани (где сосед в this.voxels отсутствует
     * или прозрачный).
     *
     * Грани куба и UV — стандартный layout MeshBuilder.CreateBox.
     *
     * @param {Array<[x,y,z]>} cells - voxel'ы региона
     * @returns {{positions:Float32Array, normals:Float32Array, uvs:Float32Array,
     *           indices:Uint32Array, bbox:{minX,minY,minZ,maxX,maxY,maxZ}} | null}
     */
    _buildMergedRegionGeometry(cells) {
        // 6 граней куба: [normalAxis, normalSign, dx, dy, dz, faceCorners[4]]
        // Каждая грань — 4 угла в локальных координатах [-0.5..+0.5].
        // dx/dy/dz — направление соседа (для surface culling).
        const HALF = VOXEL_SIZE * 0.5;
        const FACES = [
            // +X (right). sign=+1, dx=+1
            { n: [1, 0, 0], dx: 1, dy: 0, dz: 0, c: [
                [HALF, -HALF, -HALF], [HALF, HALF, -HALF],
                [HALF, HALF, HALF], [HALF, -HALF, HALF],
            ]},
            // -X (left). dx=-1
            { n: [-1, 0, 0], dx: -1, dy: 0, dz: 0, c: [
                [-HALF, -HALF, HALF], [-HALF, HALF, HALF],
                [-HALF, HALF, -HALF], [-HALF, -HALF, -HALF],
            ]},
            // +Y (top). dy=+1
            { n: [0, 1, 0], dx: 0, dy: 1, dz: 0, c: [
                [-HALF, HALF, -HALF], [-HALF, HALF, HALF],
                [HALF, HALF, HALF], [HALF, HALF, -HALF],
            ]},
            // -Y (bottom). dy=-1
            { n: [0, -1, 0], dx: 0, dy: -1, dz: 0, c: [
                [-HALF, -HALF, HALF], [-HALF, -HALF, -HALF],
                [HALF, -HALF, -HALF], [HALF, -HALF, HALF],
            ]},
            // +Z (back). dz=+1
            { n: [0, 0, 1], dx: 0, dy: 0, dz: 1, c: [
                [HALF, -HALF, HALF], [HALF, HALF, HALF],
                [-HALF, HALF, HALF], [-HALF, -HALF, HALF],
            ]},
            // -Z (front). dz=-1
            { n: [0, 0, -1], dx: 0, dy: 0, dz: -1, c: [
                [-HALF, -HALF, -HALF], [-HALF, HALF, -HALF],
                [HALF, HALF, -HALF], [HALF, -HALF, -HALF],
            ]},
        ];

        const positions = [];
        const normals = [];
        const uvs = [];
        const indices = [];
        let vIdx = 0;

        let minX = Infinity, minY = Infinity, minZ = Infinity;
        let maxX = -Infinity, maxY = -Infinity, maxZ = -Infinity;

        // Числовой occupancySet — должен быть построен в _buildRegionMeshes
        // перед вызовом этого метода. Surface culling делается через bit-packed
        // numeric key (inline для JIT).
        const occSet = this._occupancySet;
        const voxels = this.voxels;

        // UV-генератор: world-tiled, 1 тайл текстуры = 4 метра.
        //
        // Прошлый вариант uvs.push(0,0,0,1,1,1,1,0) — фиксированный квадрат
        // на грань 0.25м. На дистанции 30м voxel = 2-4 px экрана → текстура
        // усреднена в одноцветную.
        //
        // Был промежуточный вариант с UV_SCALE=1 (1 тайл = 1м), тоже мелко.
        // Сейчас UV_SCALE=0.25 → 1 тайл = 4м: на расстоянии 30м тайл
        // занимает ~80px экрана, паттерн камня читается чётко.
        const UV_SCALE = 0.25;
        function pushUVsForFace(axis, p0, p1, p2, p3) {
            const s = UV_SCALE;
            if (axis === 0) {
                uvs.push(p0[2]*s, p0[1]*s, p1[2]*s, p1[1]*s, p2[2]*s, p2[1]*s, p3[2]*s, p3[1]*s);
            } else if (axis === 1) {
                uvs.push(p0[0]*s, p0[2]*s, p1[0]*s, p1[2]*s, p2[0]*s, p2[2]*s, p3[0]*s, p3[2]*s);
            } else {
                uvs.push(p0[0]*s, p0[1]*s, p1[0]*s, p1[1]*s, p2[0]*s, p2[1]*s, p3[0]*s, p3[1]*s);
            }
        }
        const FACE_AXIS = [0, 0, 1, 1, 2, 2];

        if (!occSet) {
            // fallback на старый путь со строковыми ключами — сильно медленнее
            for (let i = 0; i < cells.length; i++) {
                const [x, y, z] = cells[i];
                const cx = (x + 0.5) * VOXEL_SIZE;
                const cy = (y + 0.5) * VOXEL_SIZE;
                const cz = (z + 0.5) * VOXEL_SIZE;
                if (cx - HALF < minX) minX = cx - HALF;
                if (cx + HALF > maxX) maxX = cx + HALF;
                if (cy - HALF < minY) minY = cy - HALF;
                if (cy + HALF > maxY) maxY = cy + HALF;
                if (cz - HALF < minZ) minZ = cz - HALF;
                if (cz + HALF > maxZ) maxZ = cz + HALF;
                for (let f = 0; f < 6; f++) {
                    const face = FACES[f];
                    const nKey = `${x + face.dx},${y + face.dy},${z + face.dz}`;
                    if (voxels.has(nKey)) continue;
                    const c0 = face.c[0], c1 = face.c[1], c2 = face.c[2], c3 = face.c[3];
                    const wp0 = [cx + c0[0], cy + c0[1], cz + c0[2]];
                    const wp1 = [cx + c1[0], cy + c1[1], cz + c1[2]];
                    const wp2 = [cx + c2[0], cy + c2[1], cz + c2[2]];
                    const wp3 = [cx + c3[0], cy + c3[1], cz + c3[2]];
                    positions.push(
                        wp0[0], wp0[1], wp0[2],
                        wp1[0], wp1[1], wp1[2],
                        wp2[0], wp2[1], wp2[2],
                        wp3[0], wp3[1], wp3[2],
                    );
                    const nx = face.n[0], ny = face.n[1], nz = face.n[2];
                    normals.push(nx, ny, nz, nx, ny, nz, nx, ny, nz, nx, ny, nz);
                    pushUVsForFace(FACE_AXIS[f], wp0, wp1, wp2, wp3);
                    indices.push(vIdx, vIdx + 1, vIdx + 2, vIdx, vIdx + 2, vIdx + 3);
                    vIdx += 4;
                }
            }
            if (vIdx === 0) return null;
            return {
                positions: new Float32Array(positions),
                normals: new Float32Array(normals),
                uvs: new Float32Array(uvs),
                indices: new Uint32Array(indices),
                bbox: { minX, minY, minZ, maxX, maxY, maxZ },
            };
        }

        // Fast path с numeric Set — packKey inline для JIT.
        for (let i = 0; i < cells.length; i++) {
            const cell = cells[i];
            const x = cell[0], y = cell[1], z = cell[2];
            const cx = (x + 0.5) * VOXEL_SIZE;
            const cy = (y + 0.5) * VOXEL_SIZE;
            const cz = (z + 0.5) * VOXEL_SIZE;
            if (cx - HALF < minX) minX = cx - HALF;
            if (cx + HALF > maxX) maxX = cx + HALF;
            if (cy - HALF < minY) minY = cy - HALF;
            if (cy + HALF > maxY) maxY = cy + HALF;
            if (cz - HALF < minZ) minZ = cz - HALF;
            if (cz + HALF > maxZ) maxZ = cz + HALF;

            for (let f = 0; f < 6; f++) {
                const face = FACES[f];
                const nx_i = x + face.dx;
                const ny_i = y + face.dy;
                const nz_i = z + face.dz;
                const packed = ((nx_i + 2048) & 4095) * 16777216 + ((ny_i + 2048) & 4095) * 4096 + ((nz_i + 2048) & 4095);
                if (occSet.has(packed)) continue;

                const c0 = face.c[0], c1 = face.c[1], c2 = face.c[2], c3 = face.c[3];
                const wp0 = [cx + c0[0], cy + c0[1], cz + c0[2]];
                const wp1 = [cx + c1[0], cy + c1[1], cz + c1[2]];
                const wp2 = [cx + c2[0], cy + c2[1], cz + c2[2]];
                const wp3 = [cx + c3[0], cy + c3[1], cz + c3[2]];
                positions.push(
                    wp0[0], wp0[1], wp0[2],
                    wp1[0], wp1[1], wp1[2],
                    wp2[0], wp2[1], wp2[2],
                    wp3[0], wp3[1], wp3[2],
                );
                const nx = face.n[0], ny = face.n[1], nz = face.n[2];
                normals.push(nx, ny, nz, nx, ny, nz, nx, ny, nz, nx, ny, nz);
                pushUVsForFace(FACE_AXIS[f], wp0, wp1, wp2, wp3);
                indices.push(vIdx, vIdx + 1, vIdx + 2, vIdx, vIdx + 2, vIdx + 3);
                vIdx += 4;
            }
        }

        if (vIdx === 0) return null;   // ни одной грани не видно
        return {
            positions: new Float32Array(positions),
            normals: new Float32Array(normals),
            uvs: new Float32Array(uvs),
            indices: new Uint32Array(indices),
            bbox: { minX, minY, minZ, maxX, maxY, maxZ },
        };
    }

    /**
     * MERGED GEOMETRY для MultiCube-материалов (grass/trunk/trunk_white).
     *
     * Идея: разделить грани по типу текстуры (top / side / bottom) и собрать
     * 3 отдельных меша вместо thin-instances. Каждый меш использует обычный
     * StandardMaterial с одной текстурой → быстро, surface culling работает.
     *
     * Эффект на больших регионах: thin-instance grass ~10K кубов давал
     * 10K × 12 = 120K треугольников. С merged + surface culling — ~5-8K
     * (только видимые грани). Это ×15-20 уменьшение.
     *
     * @param {Array<Array<number>>} cells — [[x,y,z], ...] в этом регионе
     * @returns {Object|null} { top, side, bottom, bbox } — 3 группы геометрии
     *                       или null если регион пустой.
     *                       Каждая группа = { positions, normals, uvs, indices }
     *                       или null если такой грани не видно.
     */
    _buildMultiCubeMergedRegion(cells) {
        const HALF = VOXEL_SIZE * 0.5;
        // ВСЕ 6 граней в одном массиве с тэгом группы (0=top, 1=side, 2=bottom).
        const FACES = [
            { g: 0, n: [0, 1, 0], dx: 0, dy: 1, dz: 0, c: [
                [-HALF, HALF, -HALF], [-HALF, HALF, HALF],
                [HALF, HALF, HALF], [HALF, HALF, -HALF],
            ]},
            { g: 1, n: [1, 0, 0], dx: 1, dy: 0, dz: 0, c: [
                [HALF, -HALF, -HALF], [HALF, HALF, -HALF],
                [HALF, HALF, HALF], [HALF, -HALF, HALF],
            ]},
            { g: 1, n: [-1, 0, 0], dx: -1, dy: 0, dz: 0, c: [
                [-HALF, -HALF, HALF], [-HALF, HALF, HALF],
                [-HALF, HALF, -HALF], [-HALF, -HALF, -HALF],
            ]},
            { g: 1, n: [0, 0, 1], dx: 0, dy: 0, dz: 1, c: [
                [HALF, -HALF, HALF], [HALF, HALF, HALF],
                [-HALF, HALF, HALF], [-HALF, -HALF, HALF],
            ]},
            { g: 1, n: [0, 0, -1], dx: 0, dy: 0, dz: -1, c: [
                [-HALF, -HALF, -HALF], [-HALF, HALF, -HALF],
                [HALF, HALF, -HALF], [HALF, -HALF, -HALF],
            ]},
            { g: 2, n: [0, -1, 0], dx: 0, dy: -1, dz: 0, c: [
                [-HALF, -HALF, HALF], [-HALF, -HALF, -HALF],
                [HALF, -HALF, -HALF], [HALF, -HALF, HALF],
            ]},
        ];

        const acc = [
            { positions: [], normals: [], uvs: [], indices: [], vIdx: 0 },
            { positions: [], normals: [], uvs: [], indices: [], vIdx: 0 },
            { positions: [], normals: [], uvs: [], indices: [], vIdx: 0 },
        ];

        let minX = Infinity, minY = Infinity, minZ = Infinity;
        let maxX = -Infinity, maxY = -Infinity, maxZ = -Infinity;

        const occSet = this._occupancySet;
        const voxels = this.voxels;
        const hasOcc = !!occSet;

        for (let i = 0; i < cells.length; i++) {
            const [x, y, z] = cells[i];
            const cx = (x + 0.5) * VOXEL_SIZE;
            const cy = (y + 0.5) * VOXEL_SIZE;
            const cz = (z + 0.5) * VOXEL_SIZE;
            if (cx - HALF < minX) minX = cx - HALF;
            if (cx + HALF > maxX) maxX = cx + HALF;
            if (cy - HALF < minY) minY = cy - HALF;
            if (cy + HALF > maxY) maxY = cy + HALF;
            if (cz - HALF < minZ) minZ = cz - HALF;
            if (cz + HALF > maxZ) maxZ = cz + HALF;

            for (let f = 0; f < 6; f++) {
                const face = FACES[f];
                if (hasOcc) {
                    const nx_i = x + face.dx;
                    const ny_i = y + face.dy;
                    const nz_i = z + face.dz;
                    const packed = ((nx_i + 2048) & 4095) * 16777216 + ((ny_i + 2048) & 4095) * 4096 + ((nz_i + 2048) & 4095);
                    if (occSet.has(packed)) continue;
                } else {
                    const nKey = `${x + face.dx},${y + face.dy},${z + face.dz}`;
                    if (voxels.has(nKey)) continue;
                }
                const A = acc[face.g];
                const c0 = face.c[0], c1 = face.c[1], c2 = face.c[2], c3 = face.c[3];
                A.positions.push(
                    cx + c0[0], cy + c0[1], cz + c0[2],
                    cx + c1[0], cy + c1[1], cz + c1[2],
                    cx + c2[0], cy + c2[1], cz + c2[2],
                    cx + c3[0], cy + c3[1], cz + c3[2],
                );
                const nx = face.n[0], ny = face.n[1], nz = face.n[2];
                A.normals.push(nx, ny, nz, nx, ny, nz, nx, ny, nz, nx, ny, nz);
                A.uvs.push(0, 0, 0, 1, 1, 1, 1, 0);
                A.indices.push(A.vIdx, A.vIdx + 1, A.vIdx + 2, A.vIdx, A.vIdx + 2, A.vIdx + 3);
                A.vIdx += 4;
            }
        }

        const pack = (A) => {
            if (A.vIdx === 0) return null;
            return {
                positions: new Float32Array(A.positions),
                normals: new Float32Array(A.normals),
                uvs: new Float32Array(A.uvs),
                indices: new Uint32Array(A.indices),
            };
        };
        const top = pack(acc[0]);
        const side = pack(acc[1]);
        const bottom = pack(acc[2]);
        if (!top && !side && !bottom) return null;

        return {
            top, side, bottom,
            bbox: { minX, minY, minZ, maxX, maxY, maxZ },
        };
    }

    /**
     * Получить (создать если нет) 3 раздельных StandardMaterial для
     * MultiCube-материала (top/side/bottom). Используются для merged-mesh.
     * Кэшируется в this._multiSubMats: Map<matId, {top, side, bottom}>.
     */
    _getMultiSubMaterials(matId) {
        if (!this._multiSubMats) this._multiSubMats = new Map();
        if (this._multiSubMats.has(matId)) return this._multiSubMats.get(matId);
        const def = TERRAIN_MATERIALS[matId];
        if (!def) return null;
        const matTop    = this._createSingleMat(def, def.top    || def.side, `__terrainMatSub_${matId}_top`);
        const matSide   = this._createSingleMat(def, def.side   || def.top,  `__terrainMatSub_${matId}_side`);
        const matBottom = this._createSingleMat(def, def.bottom || def.side, `__terrainMatSub_${matId}_bot`);
        try { matTop.freeze?.(); matSide.freeze?.(); matBottom.freeze?.(); } catch (e) {}
        const entry = { top: matTop, side: matSide, bottom: matBottom };
        this._multiSubMats.set(matId, entry);
        return entry;
    }

    /**
     * Разбить voxel'и на region-meshes 64×64×64 grid units (~16×16×16 м).
     * Это позволяет Babylon делать frustum culling per-region и НЕ рендерить
     * regions за пределами вида камеры.
     *
     * Алгоритм:
     *   1. Группируем voxel'и по (matId, regionX, regionZ) — bucket'ы по 64×64
     *   2. Для каждого bucket создаём отдельный proto-mesh (клон основного)
     *   3. Заливаем thin-instance матрицы только этого bucket
     *   4. Babylon культит per-mesh — невидимые regions не рендерятся
     *
     * Оригинальные proto-meshes (один на материал на всю карту) отключаются.
     * Они остаются для совместимости с editing API (_cellToInst), но при
     * region-mode они НЕ рендерятся.
     *
     * @param {Map<string, Array>} byMat - {matId → [[x,y,z], ...]}
     */
    async _buildRegionMeshes(byMat) {
        // REGION_SIZE = 256 grid units = 64м при VOXEL_SIZE=0.25.
        const REGION_SIZE = 256;
        this._regionSize = REGION_SIZE;

        // ===========================================================
        // LAZY REGION BUILD — главное ускорение загрузки.
        //
        // Идея: НЕ строим геометрию всех регионов сразу. На больших
        // картах это занимает 30+ сек. Вместо этого:
        //   1. Здесь — быстро строим _pendingRegions: Map<key, {matId, cells, isMulti}>
        //      Это ~1-2 сек на 5.7M voxels (просто группировка cells).
        //   2. Streaming-loop вызывает _materializeRegion(key) только для
        //      тех ключей которые в радиусе видимости. Регионы вне radius
        //      остаются pending — никакой работы не сделано.
        //
        // На большой карте 330м с radius 72м: в радиусе ~30 регионов из 284.
        // Стоит построить только их — это ~3 сек вместо 30 сек.
        //
        // При движении камеры новые регионы строятся по требованию (1-2
        // региона за 100-300мс в момент захода в видимость).
        // ===========================================================
        const yieldUI = async () => {
            // Отдаём контроль браузеру: React обновит overlay-прогресс
            await new Promise(r => setTimeout(r, 0));
        };
        // Пропускаем region-split для маленьких карт.
        // Понизили порог с 30K до 15K voxels — на 100м карте уже видны лаги
        // без streaming.
        let totalCells = 0;
        for (const cells of byMat.values()) totalCells += cells.length;
        if (totalCells < 15000) {
            // Малая карта — оставляем 1 mesh на материал
            console.log(`[TerrainManager] skip region-split (${totalCells} voxels < 15000)`);
            return;
        }

        const t0 = performance.now();
        // Очищаем старые region-meshes если были (повторный loadFromArray)
        this._disposeRegionMeshes();

        // ОПТИМИЗАЦИЯ surface culling: вместо `this.voxels.has("x,y,z")`
        // делаем числовой Set ОДИН РАЗ. Bit-pack: координаты в [-2048..2047]
        // (12 бит), upacked = (x+2048)&4095, total 36 бит — помещается в Number.
        // Inline без замыканий — JIT даёт лучшую оптимизацию.
        const t1 = performance.now();
        const occupancySet = new Set();
        for (const cells of byMat.values()) {
            for (let i = 0; i < cells.length; i++) {
                const c = cells[i];
                const x = c[0], y = c[1], z = c[2];
                occupancySet.add(((x + 2048) & 4095) * 16777216 + ((y + 2048) & 4095) * 4096 + ((z + 2048) & 4095));
            }
        }
        console.log(`[TerrainManager] occupancy-set built: ${occupancySet.size} keys in ${(performance.now() - t1).toFixed(0)}ms`);
        // Сохраняем для использования в _buildMergedRegionGeometry и _buildMultiCubeMergedRegion.
        this._occupancySet = occupancySet;

        if (!this._regionMeshes) this._regionMeshes = new Map();
        // Pending regions — план который будет материализован по запросу
        // (streaming-loop). Каждая запись = "${matId}:${rx},${rz}" → {matId, cells, isMulti, bbox, centerX, centerZ, halfDiag}
        this._pendingRegions = new Map();

        // Фаза 1: группировка cells по (matId, region). БЫСТРО.
        // Считаем bbox региона прямо здесь — не нужно строить меш.
        let pendingCount = 0;
        let stage = 0;
        const totalStages = byMat.size;
        for (const [matId, cells] of byMat) {
            if (cells.length === 0) { stage++; continue; }
            const def = TERRAIN_MATERIALS[matId];
            if (!def) { stage++; continue; }
            const matEntry = this._getMaterial(matId);
            const isMulti = !!matEntry.isMulti;

            // Группируем по (rx, rz) И считаем bbox прямо в проходе
            const byRegion = new Map();
            for (let i = 0; i < cells.length; i++) {
                const c = cells[i];
                const x = c[0], y = c[1], z = c[2];
                const rx = Math.floor(x / REGION_SIZE);
                const rz = Math.floor(z / REGION_SIZE);
                const key = rx + ',' + rz;
                let bucket = byRegion.get(key);
                if (!bucket) {
                    bucket = {
                        cells: [],
                        minX: Infinity, minY: Infinity, minZ: Infinity,
                        maxX: -Infinity, maxY: -Infinity, maxZ: -Infinity,
                    };
                    byRegion.set(key, bucket);
                }
                bucket.cells.push(c);
                const wx = (x + 0.5) * VOXEL_SIZE;
                const wy = (y + 0.5) * VOXEL_SIZE;
                const wz = (z + 0.5) * VOXEL_SIZE;
                if (wx < bucket.minX) bucket.minX = wx; if (wx > bucket.maxX) bucket.maxX = wx;
                if (wy < bucket.minY) bucket.minY = wy; if (wy > bucket.maxY) bucket.maxY = wy;
                if (wz < bucket.minZ) bucket.minZ = wz; if (wz > bucket.maxZ) bucket.maxZ = wz;
            }

            // Сохраняем pending — НЕ строим меш
            for (const [regionKey, bucket] of byRegion) {
                const fullKey = matId + ':' + regionKey;
                const halfX = (bucket.maxX - bucket.minX) * 0.5;
                const halfZ = (bucket.maxZ - bucket.minZ) * 0.5;
                this._pendingRegions.set(fullKey, {
                    matId,
                    isMulti,
                    cells: bucket.cells,
                    bbox: {
                        minX: bucket.minX, minY: bucket.minY, minZ: bucket.minZ,
                        maxX: bucket.maxX, maxY: bucket.maxY, maxZ: bucket.maxZ,
                    },
                    centerX: (bucket.minX + bucket.maxX) * 0.5,
                    centerZ: (bucket.minZ + bucket.maxZ) * 0.5,
                    halfDiag: Math.sqrt(halfX * halfX + halfZ * halfZ),
                });
                pendingCount++;
            }

            // Отключаем основной proto-mesh — региональные его заменяют
            const mainProto = this._protoMeshes.get(matId);
            if (mainProto) mainProto.setEnabled(false);

            stage++;
            // Обновление прогресс-бара и yield UI
            if (typeof window !== 'undefined') {
                const pct = 40 + Math.floor((stage / Math.max(1, totalStages)) * 30);
                window.__kubikonLoadProgress = {
                    percent: Math.min(70, pct),
                    label: `Планирование: ${matId} (${stage} / ${totalStages})`,
                    ts: performance.now(),
                };
            }
            await yieldUI();
        }

        const dt = performance.now() - t0;
        console.log(`[TerrainManager] region-plan: ${pendingCount} pending regions in ${dt.toFixed(0)}ms (lazy build on streaming)`);
        // НЕ освобождаем occupancySet — он нужен для lazy build регионов.
        // Освободим в _disposeRegionMeshes или после полной материализации.

        // Фаза 2: материализуем ВИДИМЫЕ регионы синхронно (для красивого
        // первого кадра без пустоты). Streaming-loop потом достроит остальные
        // лениво. Используем текущую камеру или (0,0,0) по умолчанию.
        let camX = 0, camZ = 0, radius = 60;
        try {
            const cam = this.scene?.activeCamera;
            if (cam?.position) { camX = cam.position.x; camZ = cam.position.z; }
        } catch (e) {}
        // editor-like radius (немного шире чем play radius=40м)
        radius = 72;
        if (typeof window !== 'undefined') {
            window.__kubikonLoadProgress = { percent: 70, label: 'Построение видимой области…', ts: performance.now() };
        }
        await yieldUI();

        // Собираем все pending ключи которые попадают в видимый радиус
        const toBuildKeys = [];
        for (const [key, info] of this._pendingRegions) {
            const dx = info.centerX - camX;
            const dz = info.centerZ - camZ;
            const d2 = dx * dx + dz * dz;
            const cutoff = radius + info.halfDiag * 0.5;
            if (d2 <= cutoff * cutoff) toBuildKeys.push(key);
        }

        let built = 0;
        const totalToBuild = toBuildKeys.length;
        for (const key of toBuildKeys) {
            this._materializeRegion(key);
            built++;
            // Прогресс + yield каждые ~5 регионов чтобы React успел отрисовать
            if (built % 5 === 0) {
                if (typeof window !== 'undefined' && totalToBuild > 0) {
                    const pct = 70 + Math.floor((built / totalToBuild) * 25);
                    window.__kubikonLoadProgress = {
                        percent: Math.min(95, pct),
                        label: `Построение мира: ${built} / ${totalToBuild} регионов`,
                        ts: performance.now(),
                    };
                }
                await yieldUI();
            }
        }

        const dtTotal = performance.now() - t0;
        console.log(`[TerrainManager] region-split: ${built} visible regions materialized (${this._pendingRegions.size - built} pending) in ${dtTotal.toFixed(0)}ms`);
    }

    /**
     * Материализовать (построить геометрию) одного pending региона.
     * Вызывается из updateStreaming при первом попадании региона в видимость.
     * Возвращает true если регион был построен, false если уже был построен или не найден.
     */
    _materializeRegion(fullKey) {
        if (!this._pendingRegions) return false;
        const info = this._pendingRegions.get(fullKey);
        if (!info) return false;
        // Удаляем из pending — больше не нужно
        this._pendingRegions.delete(fullKey);

        const { matId, isMulti, cells, bbox } = info;
        const matEntry = this._getMaterial(matId);
        if (!matEntry) return false;
        const N = cells.length;
        if (N === 0) return false;

        // Парсим regionKey "rx,rz" из fullKey "matId:rx,rz"
        const colonIdx = fullKey.indexOf(':');
        const regionKey = fullKey.slice(colonIdx + 1);
        const [rx, rz] = regionKey.split(',').map(Number);
        const regionMeshName = `__terrainProtoR_${matId}_${rx}_${rz}`;

        let regionMesh;
        if (isMulti) {
            // === thin-instance путь для MultiCube ===
            regionMesh = MeshBuilder.CreateBox(regionMeshName, { size: VOXEL_SIZE }, this.scene);
            regionMesh.material = matEntry.material;
            this._cutCubeFaces(regionMesh);
            const buffer = new Float32Array(16 * N);
            for (let i = 0; i < N; i++) {
                const [x, y, z] = cells[i];
                const wx = (x + 0.5) * VOXEL_SIZE;
                const wy = (y + 0.5) * VOXEL_SIZE;
                const wz = (z + 0.5) * VOXEL_SIZE;
                const off = i * 16;
                buffer[off]   = 1; buffer[off+1] = 0; buffer[off+2] = 0; buffer[off+3] = 0;
                buffer[off+4] = 0; buffer[off+5] = 1; buffer[off+6] = 0; buffer[off+7] = 0;
                buffer[off+8] = 0; buffer[off+9] = 0; buffer[off+10]= 1; buffer[off+11]= 0;
                buffer[off+12]= wx; buffer[off+13]= wy; buffer[off+14]= wz; buffer[off+15]= 1;
            }
            try { regionMesh.thinInstanceSetBuffer('matrix', buffer, 16); } catch (e) {}
            regionMesh.alwaysSelectAsActiveMesh = true;
            regionMesh.doNotSyncBoundingInfo = true;
        } else {
            // === MERGED GEOMETRY (для одно-материальных кубов) ===
            const built = this._buildMergedRegionGeometry(cells);
            if (!built) return false;
            regionMesh = new Mesh(regionMeshName, this.scene);
            const vd = new VertexData();
            vd.positions = built.positions;
            vd.normals = built.normals;
            vd.uvs = built.uvs;
            vd.indices = built.indices;
            vd.applyToMesh(regionMesh, false);
            regionMesh.material = matEntry.material;
            regionMesh.alwaysSelectAsActiveMesh = false;
        }

        regionMesh.metadata = { _isTerrainProto: true, _isRegionMesh: true, terrainMatId: matId };
        // isPickable=false: оставляем как было. Region-mesh бывает двух
        // типов — merged-геометрия и thin-instance путь. Для thin-instance
        // меша isPickable=true ломал рендер граней. Постановка моделей на
        // воксельный террейн решается через свой DDA-raycast по воксельной
        // сетке (см. _raycastVoxelSurface), а не через scene.pick.
        regionMesh.isPickable = false;
        regionMesh.receiveShadows = true;
        try { regionMesh.thinInstanceEnablePicking = false; } catch (e) {}

        const pad = VOXEL_SIZE * 0.5;
        try {
            regionMesh.setBoundingInfo(new BoundingInfo(
                new Vector3(bbox.minX - pad, bbox.minY - pad, bbox.minZ - pad),
                new Vector3(bbox.maxX + pad, bbox.maxY + pad, bbox.maxZ + pad),
            ));
        } catch (e) {}

        regionMesh.metadata.regionCenterX = info.centerX;
        regionMesh.metadata.regionCenterZ = info.centerZ;
        regionMesh.metadata.regionHalfDiag = info.halfDiag;

        this._regionMeshes.set(fullKey, regionMesh);
        return true;
    }

    /**
     * STREAMING: enable/disable region-meshes по расстоянию от камеры.
     * Регионы вне radius — отключаются (setEnabled false), не рендерятся.
     * Регионы внутри — включены.
     *
     * Вызывается из game-loop раз в ~150мс (или при изменении камеры на > 4м).
     *
     * @param {number} camX - X камеры в мире
     * @param {number} camZ - Z камеры в мире
     * @param {number} radius - радиус включения в метрах (default 60м)
     * @returns {{enabled: number, disabled: number, total: number}}
     */
    updateStreaming(camX, camZ, radius = 60) {
        if (!this._regionMeshes) this._regionMeshes = new Map();
        let enabled = 0, disabled = 0;

        // === ШАГ 1: материализуем pending регионы попавшие в радиус ===
        // Это даёт lazy load — карта появляется по мере приближения камеры.
        // Ограничение: материализуем не более 8 регионов за один updateStreaming
        // (50-200мс работы), чтобы не вешать UI. Остальное достроится при
        // следующем вызове.
        if (this._pendingRegions && this._pendingRegions.size > 0) {
            const candidates = [];
            for (const [key, info] of this._pendingRegions) {
                const dx = info.centerX - camX;
                const dz = info.centerZ - camZ;
                const d2 = dx * dx + dz * dz;
                const cutoff = radius + info.halfDiag * 0.5;
                if (d2 <= cutoff * cutoff) {
                    candidates.push({ key, d2 });
                }
            }
            // Сортируем по расстоянию — материализуем ближайшие сначала
            candidates.sort((a, b) => a.d2 - b.d2);
            const limit = Math.min(8, candidates.length);
            for (let i = 0; i < limit; i++) {
                this._materializeRegion(candidates[i].key);
            }
            // Если pending пуст — освобождаем occupancySet (~45МБ heap)
            if (this._pendingRegions.size === 0 && this._occupancySet) {
                this._occupancySet = null;
                console.log('[TerrainManager] all regions materialized, occupancy-set freed');
            }
        }

        if (this._regionMeshes.size === 0) {
            return { enabled: 0, disabled: 0, total: 0 };
        }

        // === ШАГ 2: enable/disable существующих региональных мешей ===
        for (const mesh of this._regionMeshes.values()) {
            const md = mesh.metadata;
            if (!md) continue;
            const dx = md.regionCenterX - camX;
            const dz = md.regionCenterZ - camZ;
            const d2 = dx * dx + dz * dz;
            const halfDiag = (md.regionHalfDiag ?? 0) * 0.5;
            const cutoff = radius + halfDiag;
            const shouldRender = d2 <= cutoff * cutoff;
            if (shouldRender) {
                if (!mesh.isEnabled()) mesh.setEnabled(true);
                enabled++;
            } else {
                if (mesh.isEnabled()) mesh.setEnabled(false);
                disabled++;
            }
        }
        return { enabled, disabled, total: this._regionMeshes.size };
    }

    /** Количество regions для отладки (built + pending). */
    getRegionCount() {
        const built = this._regionMeshes ? this._regionMeshes.size : 0;
        const pending = this._pendingRegions ? this._pendingRegions.size : 0;
        return built + pending;
    }

    /**
     * Включить picking воксельных мешей для raycast камеры (режим play).
     *
     * По умолчанию все proto- и region-меши имеют isPickable=false ради
     * производительности — но из-за этого camera _clampCameraToWorld не
     * может «увидеть» воксели в Ray-каст, и камера пролетает сквозь
     * стены. Включаем picking только на время play.
     *
     * ВАЖНО: для thin-instance мешей isPickable=true ЛОМАЕТ РЕНДЕР граней
     * (видна только одна грань вместо 6). Поэтому picking включаем
     * ТОЛЬКО для merged-mesh (regionMesh без thin-instance). Это и
     * есть основная масса воксельного террейна — multi-material материалы
     * (типа grass с разными top/side) идут через thin-instance, обычные
     * (rock/mud/dirt) — через merged. У merged-меша frustum в порядке.
     */
    enablePickingForCamera(enable) {
        const flag = !!enable;
        // proto-меши вообще не показываются в production (заменены regionMesh),
        // но на всякий случай тоже отключаем picking для thin-instance.
        if (this._regionMeshes) {
            for (const m of this._regionMeshes.values()) {
                // thinInstanceCount > 0 → это thin-instance путь, picking ломает рендер.
                let isThinInst = false;
                try { isThinInst = (m.thinInstanceCount || 0) > 0; } catch (e) {}
                if (isThinInst) continue;
                try {
                    m.isPickable = flag;
                } catch (e) {}
            }
        }
    }

    /** Очистить все region-meshes (при clear / повторной загрузке). */
    _disposeRegionMeshes() {
        if (this._pendingRegions) this._pendingRegions.clear();
        this._occupancySet = null;
        if (!this._regionMeshes) return;
        for (const m of this._regionMeshes.values()) {
            try { m.dispose(); } catch (e) {}
        }
        this._regionMeshes.clear();
        // Снова включаем основные proto-meshes (для совместимости)
        for (const proto of this._protoMeshes.values()) {
            try { proto.setEnabled(true); } catch (e) {}
        }
    }

    _optimizeForRender() {
        const t0 = performance.now();
        let frozen = 0;
        for (const [matId, proto] of this._protoMeshes) {
            // НЕ используем octree и НЕ меняем alwaysSelectAsActiveMesh:
            // octree thin-instance в Babylon некорректно работает с большими
            // thin-instance grids — кроны деревьев пропадают под определёнными
            // углами камеры из-за плохой партиции по bbox. Лучше оставить
            // alwaysSelectAsActiveMesh=true (всегда рендерим) и оптимизировать
            // другими способами.

            // freezeWorldMatrix — proto-mesh не двигается, экономит CPU
            // на пересчёт world matrix каждый кадр.
            try { proto.freezeWorldMatrix(); frozen++; } catch (e) {}

            // material.freeze() — без перекомпиляции шейдера каждый кадр.
            // Безопасно потому что мы не меняем свойства материалов после
            // загрузки (alpha, emissive и т.п. установлены при _createSingleMat).
            try {
                if (proto.material && typeof proto.material.freeze === 'function') {
                    proto.material.freeze();
                }
            } catch (e) {}
        }
        const dt = performance.now() - t0;
        console.log(`[TerrainManager] optimized for render in ${dt.toFixed(0)}ms: frozen=${frozen} of ${this._protoMeshes.size} protos`);
    }

    /**
     * Снять оптимизации (нужно перед редактированием через кисти).
     * Кисти модифицируют thin-instances → octree становится невалидным,
     * material/world freeze тоже мешают. Вызывается из brushDraw/Sculpt/...
     */
    _unfreezeForEdit() {
        for (const proto of this._protoMeshes.values()) {
            try { proto.unfreezeWorldMatrix(); } catch (e) {}
            try {
                if (proto.material && typeof proto.material.unfreeze === 'function') {
                    proto.material.unfreeze();
                }
            } catch (e) {}
            // octree автоматически инвалидируется при thinInstanceAdd/Remove
        }
    }

    /** Нормализация grass на ГОЛЫХ ДАННЫХ — переводит все grass-voxel'ы
     *  под верхушкой столбца в dirt. Работает с this.voxels-Map и
     *  byMat-словарём, БЕЗ создания thin-instance'ов (используется
     *  только в loadFromArray). */
    _normalizeGrassData(byMat) {
        const grassCells = byMat.get('grass');
        if (!grassCells || grassCells.length === 0) return;
        // Группируем grass-voxel'ы по столбцу (x,z) → массив Y
        const colYs = new Map(); // "x,z" → [y, y, y, ...]
        for (const [x, y, z] of grassCells) {
            const k = `${x},${z}`;
            let arr = colYs.get(k);
            if (!arr) { arr = []; colYs.set(k, arr); }
            arr.push(y);
        }
        // Для каждого столбца: оставляем максимум, остальное → dirt
        const newGrass = [];
        const movedToDirt = [];
        for (const [colKey, ys] of colYs) {
            if (ys.length === 1) {
                newGrass.push(this._parseColKeyToXZ(colKey, ys[0]));
                continue;
            }
            let maxY = ys[0];
            for (let i = 1; i < ys.length; i++) if (ys[i] > maxY) maxY = ys[i];
            const onlyComma = colKey.lastIndexOf(',');
            const x = parseInt(colKey.slice(0, onlyComma), 10);
            const z = parseInt(colKey.slice(onlyComma + 1), 10);
            for (const y of ys) {
                if (y === maxY) {
                    newGrass.push([x, y, z]);
                } else {
                    movedToDirt.push([x, y, z]);
                    this.voxels.set(`${x},${y},${z}`, 'dirt');
                }
            }
        }
        // Обновляем bucket'ы
        byMat.set('grass', newGrass);
        let dirtBucket = byMat.get('dirt');
        if (!dirtBucket) { dirtBucket = []; byMat.set('dirt', dirtBucket); }
        for (const c of movedToDirt) dirtBucket.push(c);
    }

    _parseColKeyToXZ(colKey, y) {
        const onlyComma = colKey.lastIndexOf(',');
        return [
            parseInt(colKey.slice(0, onlyComma), 10),
            y,
            parseInt(colKey.slice(onlyComma + 1), 10),
        ];
    }

    /** Освобождение ресурсов при выходе из редактора.
     *  В _materials лежат entry-объекты {material, isMulti}, диспозим .material —
     *  Babylon у MultiMaterial автоматически диспозит и subMaterials. */
    dispose() {
        for (const proto of this._protoMeshes.values()) {
            try { proto.dispose(); } catch (e) {}
        }
        for (const entry of this._materials.values()) {
            try { entry?.material?.dispose?.(); } catch (e) {}
        }
        this._protoMeshes.clear();
        this._materials.clear();
        this._instanceKeys.clear();
        this._freeSlots.clear();
        this._cellToInst.clear();
        this.voxels.clear();
    }
}

// Список ID для проверок снаружи (если понадобится).
export { TERRAIN_MATERIAL_IDS };
