/**
 * DecoManager — рендерит мелкие воксельные декорации (цветы, грибы, трава).
 *
 * Размер voxel'а — 0.05м (×5 меньше terrain). Каждая декорация состоит из
 * 7-15 мини-вокселей, формируя цветок/гриб/кустик.
 *
 * Архитектура (Этап B оптимизации — merged geometry):
 *   - Все voxel'ы группируются по ЦВЕТУ (а не по модели)
 *   - Для каждой группы цвета строится ОДИН большой merged mesh:
 *     surface culling внутри этого цвета (соседи без учёта других цветов)
 *   - На карте 80м с травой ~15% = ~100К mini-voxel'ов → ~10 merged mesh'ей
 *     вместо ~100К thin-instances. Главный выигрыш — драстически меньше
 *     draw calls.
 *
 * Surface culling считает соседей ВНУТРИ группы цвета (упрощение). Это
 * означает: грани между «стебелем» и «лепестком» рисуются обе. На мелких
 * моделях это не критично — лепестки разные цвета, всё равно были видны.
 *
 * Без коллизий — игрок проходит сквозь decorations.
 * Без shadow casting — мелкие воксели не дают красивые тени.
 *
 * Сериализация: список { x, y, z, modelId, rotation, scale } — позиции в
 * МИРОВЫХ координатах (метры). Воксели модели разворачиваются при рендере.
 *
 * Public API:
 *   placeModel(worldX, worldY, worldZ, modelId, rotation=0, scale=1)
 *   clear()
 *   serialize() / loadFromArray()
 *   count()
 */

import {
    Mesh,
    VertexData,
    StandardMaterial,
    Color3,
} from '@babylonjs/core';
import { DECO_VOXEL_SIZE, DECO_PALETTE, DECO_MODELS } from './DecoModels';

/**
 * Алиасы цветов — объединяем близкие оттенки в один меш.
 * Это даёт ×2-3 меньше mesh'ей в сцене.
 *
 * Стратегия: похожие зелёные (grass1/2/3/4, leafGrass, stemLight,
 * leafDark, stemGreen) → 3 группы (тёмный, средний, светлый).
 *
 * Цветы — каждый цвет отдельно (важно для контраста).
 */
const COLOR_ALIAS = {
    // === Зелёные → 3 группы ===
    grass2: 'greenDark', leafDark: 'greenDark', stemGreen: 'greenDark',
    grass1: 'greenMid', leafGrass: 'greenMid', grass4: 'greenMid',
    grass3: 'greenLight', stemLight: 'greenLight',
    // === Жёлто-сухие → отдельно ===
    grassYellow: 'greenYellow', grassRed: 'greenYellow',
    // === Белые → один ===
    petalWhite: 'white', capWhite: 'white', capDots: 'white', mushroomStem: 'mushroomStem',
    // Остальные — без алиаса (используют свой ключ)
};

// Цвета для алиасов (используются если ключ в COLOR_ALIAS)
const ALIAS_COLORS = {
    greenDark:  [0.20, 0.50, 0.15],
    greenMid:   [0.32, 0.62, 0.20],
    greenLight: [0.40, 0.70, 0.25],
    greenYellow: [0.50, 0.60, 0.18],
    white: [0.95, 0.95, 0.90],
    mushroomStem: [0.92, 0.88, 0.75],
};

function getAliasedKey(colorKey) {
    return COLOR_ALIAS[colorKey] || colorKey;
}
function getAliasedColor(colorKey) {
    if (ALIAS_COLORS[colorKey]) return ALIAS_COLORS[colorKey];
    return DECO_PALETTE[colorKey] || [0.5, 0.5, 0.5];
}

export class DecoManager {
    constructor(scene) {
        this.scene = scene;
        /** Array<{ x, y, z, modelId, rotation, scale }> */
        this.placements = [];
        /** Map<colorKey, Mesh> — merged mesh на цвет. */
        this._meshes = new Map();
        /** Map<colorKey, StandardMaterial> — материал на цвет. */
        this._materials = new Map();
        /** Этап D: per-CHUNK раздробление. Map<"cx,cz", Array<placement>>. */
        this._byChunk = new Map();
        /** Map<"cx,cz", Map<colorKey, Mesh>> — меши по чанкам. */
        this._chunkMeshes = new Map();
        /** Lazy build: Map<"cx,cz", placement[]> — чанки которые ещё не материализованы.
         *  Заполняется в loadFromArray, очищается в updateStreaming при заходе
         *  чанка в радиус (или в _materializeDecoChunk при ручном вызове). */
        this._pendingDecoChunks = new Map();
        /** Размер чанка для LOD-стриминга (метры). 64м даёт ×4 меньше mesh'ей
         *  в scene.meshes (4-9 chunks вместо 16+). */
        this._chunkSize = 64;
        /** Радиус LOD: декорации видны только в этом радиусе от камеры. */
        this._lodRadius = 50;
        /** Колбэк изменений. */
        this._onChange = null;
    }

    setOnChange(cb) { this._onChange = cb; }
    _emit() { try { this._onChange?.(); } catch (e) {} }
    count() { return this.placements.length; }

    /**
     * Получить или создать материал для цвета.
     */
    _getOrCreateMaterial(colorKey) {
        // colorKey уже aliased на этапе rebuildAllMeshes
        let mat = this._materials.get(colorKey);
        if (mat) return mat;
        const color = getAliasedColor(colorKey);
        mat = new StandardMaterial(`__decoMat_${colorKey}`, this.scene);
        mat.diffuseColor = new Color3(color[0], color[1], color[2]);
        mat.specularColor = new Color3(0, 0, 0);
        mat.ambientColor = new Color3(1, 1, 1);
        mat.emissiveColor = new Color3(color[0] * 0.15, color[1] * 0.15, color[2] * 0.15);
        try { mat.freeze(); } catch (e) {}
        this._materials.set(colorKey, mat);
        return mat;
    }

    /**
     * Разместить декорацию. Только добавляет в placements; сборка mesh'ей
     * происходит в _flushBuffers() / loadFromArray.
     */
    placeModel(worldX, worldY, worldZ, modelId, rotation = 0, scale = 1.0) {
        if (!DECO_MODELS[modelId]) {
            console.warn(`[DecoManager] unknown modelId: ${modelId}`);
            return;
        }
        const placement = { x: worldX, y: worldY, z: worldZ, modelId, rotation, scale };
        this.placements.push(placement);
        // Сразу материализуем chunk, чтобы декорация появилась.
        // Если chunk уже построен — перестраиваем (новый placement добавляется
        // в _byChunk, и старый mesh диспозится).
        const cx = Math.floor(worldX / this._chunkSize);
        const cz = Math.floor(worldZ / this._chunkSize);
        const chunkKey = cx + ',' + cz;
        // Кладём в pending (даже если chunk уже был построен) и перестраиваем.
        let arrChunk = this._pendingDecoChunks.get(chunkKey);
        if (!arrChunk) {
            // Если chunk уже materializ'ован — переносим его placements обратно
            // в pending, добавляем новый, и заново строим.
            const existing = this._byChunk.get(chunkKey);
            arrChunk = existing ? [...existing] : [];
            this._pendingDecoChunks.set(chunkKey, arrChunk);
        }
        arrChunk.push(placement);
        // Удаляем старые меши этого chunk перед перестроением
        const oldMeshes = this._chunkMeshes.get(chunkKey);
        if (oldMeshes) {
            for (const m of oldMeshes.values()) {
                try { m.dispose(); } catch (e) {}
            }
            this._chunkMeshes.delete(chunkKey);
        }
        this._materializeDecoChunk(chunkKey);
        this._emit();
    }

    /**
     * Полная очистка.
     */
    clear() {
        this.placements = [];
        for (const m of this._meshes.values()) {
            try { m.dispose(); } catch (e) {}
        }
        this._meshes.clear();
        // Этап D: чистим chunk meshes тоже
        for (const colorMap of this._chunkMeshes.values()) {
            for (const m of colorMap.values()) {
                try { m.dispose(); } catch (e) {}
            }
        }
        this._chunkMeshes.clear();
        this._byChunk.clear();
        // Lazy: pending chunks тоже сбрасываем
        if (this._pendingDecoChunks) this._pendingDecoChunks.clear();
        this._emit();
    }

    /**
     * Этап D LOD streaming: enable/disable chunk-мешей по дистанции
     * от точки (camX, camZ). Декорации дальше lodRadius метров скрыты.
     * Вызывается из game-loop раз в 100-150мс.
     * @returns {{visible:number, hidden:number, total:number}}
     */
    updateStreaming(camX, camZ, radius, options) {
        const r = radius ?? this._lodRadius;
        const halfDiag = this._chunkSize * 0.71;
        const cutoff = r + halfDiag;
        const cutoff2 = cutoff * cutoff;
        const maxBuild = options?.maxBuild ?? 4;

        // === ШАГ 1: материализуем pending chunks в радиусе ===
        // Лимит — не более maxBuild за один тик (deco тяжелее чем regions).
        // При первом вызове из loadFromState можно поднять maxBuild чтобы
        // сразу построить все видимые chunks.
        if (this._pendingDecoChunks.size > 0) {
            const candidates = [];
            for (const [chunkKey] of this._pendingDecoChunks) {
                const [cx, cz] = chunkKey.split(',').map(Number);
                const ccx = (cx + 0.5) * this._chunkSize;
                const ccz = (cz + 0.5) * this._chunkSize;
                const dx = ccx - camX, dz = ccz - camZ;
                const dist2 = dx * dx + dz * dz;
                if (dist2 <= cutoff2) candidates.push({ chunkKey, dist2 });
            }
            candidates.sort((a, b) => a.dist2 - b.dist2);
            const limit = Math.min(maxBuild, candidates.length);
            for (let i = 0; i < limit; i++) {
                this._materializeDecoChunk(candidates[i].chunkKey);
            }
        }

        // === ШАГ 2: enable/disable существующих ===
        let visible = 0, hidden = 0, total = 0;
        for (const [chunkKey, colorMap] of this._chunkMeshes) {
            total++;
            const [cx, cz] = chunkKey.split(',').map(Number);
            const ccx = (cx + 0.5) * this._chunkSize;
            const ccz = (cz + 0.5) * this._chunkSize;
            const dx = ccx - camX, dz = ccz - camZ;
            const dist2 = dx * dx + dz * dz;
            const shouldShow = dist2 <= cutoff2;
            for (const mesh of colorMap.values()) {
                if (shouldShow) {
                    if (!mesh.isEnabled()) mesh.setEnabled(true);
                } else {
                    if (mesh.isEnabled()) mesh.setEnabled(false);
                }
            }
            if (shouldShow) visible++; else hidden++;
        }
        return { visible, hidden, total };
    }

    /**
     * Материализовать один pending chunk: построить меши по цветам.
     * Вызывается из updateStreaming при заходе в радиус.
     */
    _materializeDecoChunk(chunkKey) {
        const placements = this._pendingDecoChunks.get(chunkKey);
        if (!placements || placements.length === 0) return false;
        this._pendingDecoChunks.delete(chunkKey);
        this._byChunk.set(chunkKey, placements);

        // Разворачиваем модели → группируем по цвету
        const ANCHOR_X = 2, ANCHOR_Z = 2;
        const voxelByColor = new Map();
        const occupiedSet = new Set();

        for (const p of placements) {
            const model = DECO_MODELS[p.modelId];
            if (!model) continue;
            const scale = p.scale || 1.0;
            const stepSize = DECO_VOXEL_SIZE * scale;
            const rot = p.rotation & 3;
            for (const v of model.voxels) {
                const localDx = v.x - ANCHOR_X;
                const localDz = v.z - ANCHOR_Z;
                let dx, dz;
                switch (rot) {
                    case 1: dx = -localDz; dz = localDx; break;
                    case 2: dx = -localDx; dz = -localDz; break;
                    case 3: dx = localDz; dz = -localDx; break;
                    default: dx = localDx; dz = localDz;
                }
                const dy = v.y;
                const cx = p.x + dx * stepSize;
                const cy = p.y + dy * stepSize;
                const cz = p.z + dz * stepSize;
                const ix = Math.round(cx / DECO_VOXEL_SIZE);
                const iy = Math.round(cy / DECO_VOXEL_SIZE);
                const iz = Math.round(cz / DECO_VOXEL_SIZE);
                const key = ix + ',' + iy + ',' + iz;
                if (occupiedSet.has(key)) continue;
                occupiedSet.add(key);
                const aliasedColor = getAliasedKey(v.c);
                let arr = voxelByColor.get(aliasedColor);
                if (!arr) { arr = []; voxelByColor.set(aliasedColor, arr); }
                arr.push({ cx, cy, cz, scale, ix, iy, iz });
            }
        }

        // Строим mesh per цвет
        const chunkMeshMap = new Map();
        for (const [colorKey, voxels] of voxelByColor) {
            const built = this._buildMergedColorGeometry(voxels, occupiedSet);
            if (!built) continue;
            const meshName = `__decoMesh_${chunkKey}_${colorKey}`;
            const mesh = new Mesh(meshName, this.scene);
            const vd = new VertexData();
            vd.positions = built.positions;
            vd.normals = built.normals;
            vd.uvs = built.uvs;
            vd.indices = built.indices;
            vd.applyToMesh(mesh, false);
            mesh.material = this._getOrCreateMaterial(colorKey);
            mesh.isPickable = false;
            mesh.receiveShadows = false;
            mesh.alwaysSelectAsActiveMesh = false;
            try { mesh.freezeWorldMatrix?.(); } catch (e) {}
            chunkMeshMap.set(colorKey, mesh);
        }
        this._chunkMeshes.set(chunkKey, chunkMeshMap);
        return true;
    }

    /** Установить LOD radius из настроек. */
    setLodRadius(r) {
        this._lodRadius = Math.max(10, r);
    }

    getChunkCount() {
        const built = this._chunkMeshes.size;
        const pending = this._pendingDecoChunks ? this._pendingDecoChunks.size : 0;
        return built + pending;
    }

    /**
     * Загрузить из массива placements. Очищает существующее.
     *
     * LAZY BUILD: меши НЕ строятся здесь, только группируются по chunks.
     * Реальное построение происходит в updateStreaming() когда камера
     * заходит в радиус chunk'а. На больших картах (500K+ decorations)
     * это даёт ×10-20 ускорения загрузки.
     */
    loadFromArray(arr) {
        this.clear();
        if (!Array.isArray(arr) || arr.length === 0) {
            this._emit();
            return;
        }
        const t0 = performance.now();
        const chunkSize = this._chunkSize;
        // Сохраняем placements и группируем по chunk одним проходом
        for (const p of arr) {
            if (!DECO_MODELS[p.modelId]) continue;
            const placement = {
                x: p.x, y: p.y, z: p.z,
                modelId: p.modelId,
                rotation: p.rotation || 0,
                scale: p.scale ?? 1.0,
            };
            this.placements.push(placement);
            const cx = Math.floor(p.x / chunkSize);
            const cz = Math.floor(p.z / chunkSize);
            const k = cx + ',' + cz;
            let arrChunk = this._pendingDecoChunks.get(k);
            if (!arrChunk) { arrChunk = []; this._pendingDecoChunks.set(k, arrChunk); }
            arrChunk.push(placement);
        }
        const dt = performance.now() - t0;
        console.log(`[DecoManager] loadFromArray (lazy plan): ${this.placements.length} decorations → ${this._pendingDecoChunks.size} pending chunks in ${dt.toFixed(0)}ms`);
        this._emit();
    }

    /**
     * Главный билдер (Этап D): chunks × colors.
     * Группируем placements по chunkKey (32м), внутри chunk — по цвету,
     * строим merged mesh per (chunk, color). Это даёт ~100-300 мешей
     * на большую карту, но streaming скрывает дальние сразу.
     */
    _rebuildAllMeshes() {
        // Очищаем старые меши
        for (const m of this._meshes.values()) {
            try { m.dispose(); } catch (e) {}
        }
        this._meshes.clear();
        for (const colorMap of this._chunkMeshes.values()) {
            for (const m of colorMap.values()) {
                try { m.dispose(); } catch (e) {}
            }
        }
        this._chunkMeshes.clear();
        this._byChunk.clear();

        const chunkSize = this._chunkSize;
        // Группируем placements по chunk
        for (const p of this.placements) {
            const cx = Math.floor(p.x / chunkSize);
            const cz = Math.floor(p.z / chunkSize);
            const k = cx + ',' + cz;
            let arr = this._byChunk.get(k);
            if (!arr) { arr = []; this._byChunk.set(k, arr); }
            arr.push(p);
        }

        // Для каждого chunk → разворачиваем модели → группируем по цвету → строим merged
        const ANCHOR_X = 2, ANCHOR_Z = 2;
        for (const [chunkKey, chunkPlacements] of this._byChunk) {
            const voxelByColor = new Map();
            const occupiedSet = new Set();

            for (const p of chunkPlacements) {
                const model = DECO_MODELS[p.modelId];
                if (!model) continue;
                const scale = p.scale || 1.0;
                const stepSize = DECO_VOXEL_SIZE * scale;
                const rot = p.rotation & 3;
                for (const v of model.voxels) {
                    const localDx = v.x - ANCHOR_X;
                    const localDz = v.z - ANCHOR_Z;
                    let dx, dz;
                    switch (rot) {
                        case 1: dx = -localDz; dz = localDx; break;
                        case 2: dx = -localDx; dz = -localDz; break;
                        case 3: dx = localDz; dz = -localDx; break;
                        default: dx = localDx; dz = localDz;
                    }
                    const dy = v.y;
                    const cx = p.x + dx * stepSize;
                    const cy = p.y + dy * stepSize;
                    const cz = p.z + dz * stepSize;
                    const ix = Math.round(cx / DECO_VOXEL_SIZE);
                    const iy = Math.round(cy / DECO_VOXEL_SIZE);
                    const iz = Math.round(cz / DECO_VOXEL_SIZE);
                    const key = ix + ',' + iy + ',' + iz;
                    if (occupiedSet.has(key)) continue;
                    occupiedSet.add(key);
                    // Применяем алиас цвета — близкие оттенки → одна группа
                    const aliasedColor = getAliasedKey(v.c);
                    let arr = voxelByColor.get(aliasedColor);
                    if (!arr) { arr = []; voxelByColor.set(aliasedColor, arr); }
                    arr.push({ cx, cy, cz, scale, ix, iy, iz });
                }
            }

            // Строим mesh per цвет в этом chunk
            const chunkMeshMap = new Map();
            for (const [colorKey, voxels] of voxelByColor) {
                const built = this._buildMergedColorGeometry(voxels, occupiedSet);
                if (!built) continue;
                const meshName = `__decoMesh_${chunkKey}_${colorKey}`;
                const mesh = new Mesh(meshName, this.scene);
                const vd = new VertexData();
                vd.positions = built.positions;
                vd.normals = built.normals;
                vd.uvs = built.uvs;
                vd.indices = built.indices;
                vd.applyToMesh(mesh, false);
                mesh.material = this._getOrCreateMaterial(colorKey);
                mesh.isPickable = false;
                mesh.receiveShadows = false;
                mesh.alwaysSelectAsActiveMesh = false;
                try { mesh.freezeWorldMatrix?.(); } catch (e) {}
                chunkMeshMap.set(colorKey, mesh);
            }
            this._chunkMeshes.set(chunkKey, chunkMeshMap);
        }
    }

    /**
     * Построить merged geometry для одной группы цвета.
     * voxels — Array<{cx, cy, cz, scale, ix, iy, iz}>
     * occupiedSet — Set<"ix,iy,iz"> всех занятых ячеек (для surface culling)
     *
     * ОПТИМИЗАЦИЯ (greedy): для каждой грани соединяем соседние voxel'и
     * одного цвета (которые уже в этом vLoxels-list) в один большой квад.
     * Делаем простую версию: scan по оси, склеиваем подряд идущие.
     */
    _buildMergedColorGeometry(voxels, occupiedSet) {
        if (voxels.length === 0) return null;

        // Хэш voxel'ов этого цвета по ix,iy,iz для быстрого поиска соседа
        // того же цвета.
        const sameColorSet = new Set();
        const voxelByKey = new Map();
        for (const v of voxels) {
            const key = v.ix + ',' + v.iy + ',' + v.iz;
            sameColorSet.add(key);
            voxelByKey.set(key, v);
        }

        // 6 граней с осью растяжения для greedy.
        // Для каждой грани мы будем "тянуть" квад вдоль 2 осей.
        const FACES = [
            // +X: грань в плоскости YZ, растягиваем по Y и Z
            { dx: 1, dy: 0, dz: 0, nx: 1, ny: 0, nz: 0,
              u: 'y', v: 'z', c: [[1,-1,-1],[1,1,-1],[1,1,1],[1,-1,1]] },
            // -X: плоскость YZ
            { dx: -1, dy: 0, dz: 0, nx: -1, ny: 0, nz: 0,
              u: 'y', v: 'z', c: [[-1,-1,1],[-1,1,1],[-1,1,-1],[-1,-1,-1]] },
            // +Y: плоскость XZ
            { dx: 0, dy: 1, dz: 0, nx: 0, ny: 1, nz: 0,
              u: 'x', v: 'z', c: [[-1,1,-1],[-1,1,1],[1,1,1],[1,1,-1]] },
            // -Y: плоскость XZ
            { dx: 0, dy: -1, dz: 0, nx: 0, ny: -1, nz: 0,
              u: 'x', v: 'z', c: [[-1,-1,1],[-1,-1,-1],[1,-1,-1],[1,-1,1]] },
            // +Z: плоскость XY
            { dx: 0, dy: 0, dz: 1, nx: 0, ny: 0, nz: 1,
              u: 'x', v: 'y', c: [[1,-1,1],[1,1,1],[-1,1,1],[-1,-1,1]] },
            // -Z: плоскость XY
            { dx: 0, dy: 0, dz: -1, nx: 0, ny: 0, nz: -1,
              u: 'x', v: 'y', c: [[-1,-1,-1],[-1,1,-1],[1,1,-1],[1,-1,-1]] },
        ];

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

        // Greedy: вместо одного voxel = один quad, склеиваем соседние voxel'и
        // ОДНОГО ЦВЕТА с одинаковой видимой гранью.
        //
        // Pass per face direction. Для каждого voxel определяем видимость
        // грани, и если видна — проверяем, не «съели» ли мы её уже соседом.
        // Помечаем съеденные через consumed-Set.
        for (let f = 0; f < 6; f++) {
            const face = FACES[f];
            const consumed = new Set();   // ключи voxel'ов чьи грани уже добавлены
            for (const v of voxels) {
                const vKey = v.ix + ',' + v.iy + ',' + v.iz;
                if (consumed.has(vKey)) continue;
                // Видна ли грань?
                const nKey = (v.ix + face.dx) + ',' + (v.iy + face.dy) + ',' + (v.iz + face.dz);
                if (occupiedSet.has(nKey)) continue;
                consumed.add(vKey);

                // Greedy: пробуем растянуть вдоль одной оси
                // (берём первую совпадающую — простой 1D greedy, не 2D).
                // Это даёт ×1.5-3 уменьшение, не максимум, но просто и надёжно.
                let lastVoxel = v;
                let dxAxis, dyAxis, dzAxis;
                // Идём вдоль оси u (что соответствует одной из x/y/z для face)
                if (face.u === 'x') { dxAxis = 1; dyAxis = 0; dzAxis = 0; }
                else if (face.u === 'y') { dxAxis = 0; dyAxis = 1; dzAxis = 0; }
                else { dxAxis = 0; dyAxis = 0; dzAxis = 1; }

                let extendCount = 0;
                while (true) {
                    const nextIx = lastVoxel.ix + dxAxis;
                    const nextIy = lastVoxel.iy + dyAxis;
                    const nextIz = lastVoxel.iz + dzAxis;
                    const nextKey = nextIx + ',' + nextIy + ',' + nextIz;
                    if (!sameColorSet.has(nextKey)) break;
                    if (consumed.has(nextKey)) break;
                    // Также нужно чтобы грань этого voxel'а тоже была видна
                    const nFaceNb = (nextIx + face.dx) + ',' + (nextIy + face.dy) + ',' + (nextIz + face.dz);
                    if (occupiedSet.has(nFaceNb)) break;
                    // Scale должен совпадать (иначе кубы разного размера)
                    const nextV = voxelByKey.get(nextKey);
                    if (!nextV || Math.abs(nextV.scale - v.scale) > 0.001) break;
                    consumed.add(nextKey);
                    lastVoxel = nextV;
                    extendCount++;
                    if (extendCount >= 32) break;   // safety
                }

                // Строим квад от v до lastVoxel (растянутый по оси u)
                const half = DECO_VOXEL_SIZE * v.scale * 0.5;
                // Центр квада — середина между v и lastVoxel
                const cxQ = (v.cx + lastVoxel.cx) * 0.5;
                const cyQ = (v.cy + lastVoxel.cy) * 0.5;
                const czQ = (v.cz + lastVoxel.cz) * 0.5;
                // Размер квада: в направлении оси u = half × (extendCount + 1)
                const lenHalf = half * (extendCount + 1);
                // Подменяем размер только по оси u
                const halfX = face.u === 'x' || face.v === 'x' ? (face.u === 'x' ? lenHalf : half) : half;
                const halfY = face.u === 'y' || face.v === 'y' ? (face.u === 'y' ? lenHalf : half) : half;
                const halfZ = face.u === 'z' || face.v === 'z' ? (face.u === 'z' ? lenHalf : half) : half;

                const c0 = face.c[0], c1 = face.c[1], c2 = face.c[2], c3 = face.c[3];
                positions.push(
                    cxQ + c0[0] * halfX, cyQ + c0[1] * halfY, czQ + c0[2] * halfZ,
                    cxQ + c1[0] * halfX, cyQ + c1[1] * halfY, czQ + c1[2] * halfZ,
                    cxQ + c2[0] * halfX, cyQ + c2[1] * halfY, czQ + c2[2] * halfZ,
                    cxQ + c3[0] * halfX, cyQ + c3[1] * halfY, czQ + c3[2] * halfZ,
                );
                normals.push(
                    face.nx, face.ny, face.nz,
                    face.nx, face.ny, face.nz,
                    face.nx, face.ny, face.nz,
                    face.nx, face.ny, face.nz,
                );
                uvs.push(0, 0, 0, 1, 1, 1, 1, 0);
                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),
        };
    }

    /**
     * Сериализация для БД.
     */
    serialize() {
        return this.placements.map(p => ({
            x: +p.x.toFixed(3),
            y: +p.y.toFixed(3),
            z: +p.z.toFixed(3),
            modelId: p.modelId,
            rotation: p.rotation || 0,
            ...(p.scale && p.scale !== 1.0 ? { scale: +p.scale.toFixed(2) } : {}),
        }));
    }

    /**
     * Освободить все meshes.
     */
    dispose() {
        for (const m of this._meshes.values()) {
            try { m.dispose(); } catch (e) {}
        }
        this._meshes.clear();
        for (const colorMap of this._chunkMeshes.values()) {
            for (const m of colorMap.values()) {
                try { m.dispose(); } catch (e) {}
            }
        }
        this._chunkMeshes.clear();
        for (const mat of this._materials.values()) {
            try { mat.dispose(); } catch (e) {}
        }
        this._materials.clear();
        this.placements = [];
        this._byChunk.clear();
    }
}
