/**
 * VoxelModelScene — изолированная Babylon-сцена для редактора пользовательской
 * воксельной модели (Этап 4 редактора моделей).
 *
 * Никакой связи с основной сценой проекта: своя Engine + Scene, орбит-камера,
 * сетка границ, воксели = thin-instances одного куба с per-instance цветом
 * (через colorBuffer thinInstanceSetBuffer).
 *
 * Размер модели — параметр grid_size (8/16/32/64). По умолчанию 16 (~4 м).
 * Воксели хранятся в Map<"x,y,z" → colorHex> + параллельно через
 * thin-instance buffer для рендера.
 *
 * Инструменты:
 *   - 'draw'  — добавить voxel выбранного цвета
 *   - 'erase' — удалить voxel
 *   - 'paint' — перекрасить существующий
 *   - 'fill'  — 3D flood-fill соединённой области одного цвета
 *
 * Управление камерой:
 *   - ПКМ + drag      → орбит вокруг центра модели
 *   - Колесо мыши     → zoom
 *   - F               → центрировать обратно
 *
 * Серьёзно урезанная архитектура относительно BabylonScene.js: нет физики,
 * блок-менеджеров, моделей, gizmo и т.п. Только voxel grid + кисти + камера.
 */
import {
    Engine, Scene, ArcRotateCamera, HemisphericLight, DirectionalLight,
    Vector3, Color3, Color4, Matrix, MeshBuilder, StandardMaterial,
    Texture, Tools, SceneLoader, TransformNode, VertexBuffer,
} from '@babylonjs/core';
import '@babylonjs/loaders/glTF';
import { encodeVoxelModel, decodeVoxelModel } from './voxelModelCodec';

/**
 * Каталог текстур для рисования. Использует те же текстуры что и terrain
 * (kubikon-assets/textures), но это просто PNG → diffuse map.
 * id — стабильный ключ для сохранения; preview — URL для палитры; file —
 * URL текстуры для diffuseTexture.
 */
const TEX_BASE = '/kubikon-assets/textures';
export const VOXEL_TEXTURES = [
    { id: 'grass',      label: 'Трава',         file: `${TEX_BASE}/grass_top.png` },
    { id: 'stone',      label: 'Камень',        file: `${TEX_BASE}/greystone.png` },
    { id: 'dirt',       label: 'Земля',         file: `${TEX_BASE}/dirt.png` },
    { id: 'sand',       label: 'Песок',         file: `${TEX_BASE}/sand.png` },
    { id: 'snow',       label: 'Снег',          file: `${TEX_BASE}/snow.png` },
    { id: 'wood',       label: 'Дерево',        file: `${TEX_BASE}/wood.png` },
    { id: 'leaves',     label: 'Листва',        file: `${TEX_BASE}/leaves.png` },
    { id: 'trunk',      label: 'Ствол',         file: `${TEX_BASE}/trunk_side.png` },
    { id: 'water',      label: 'Вода',          file: `${TEX_BASE}/water.png` },
    { id: 'ice',        label: 'Лёд',           file: `${TEX_BASE}/ice.png` },
    { id: 'gravel',     label: 'Гравий',        file: `${TEX_BASE}/gravel_dirt.png` },
    { id: 'brick_red',  label: 'Кирпич красн.', file: `${TEX_BASE}/brick_red.png` },
    { id: 'brick_grey', label: 'Кирпич сер.',   file: `${TEX_BASE}/brick_grey.png` },
];

/** Размер одного voxel'а в мире (метров).
 *  0.0625 м (=1/16 м) — мелкая ячейка. Это размер ЯЧЕЙКИ-кубика на сцене.
 *  Минимальный кубик который ставит кисть = размер 1 voxel = 0.0625 м.
 *  При gridSize=16 модель = 1 м; 32 = 2 м; 64 = 4 м; 128 = 8 м.
 *  Большие gridSize дают БОЛЬШЕ РАМКУ при том же мелком кубике —
 *  пользователь сам выбирает баланс детализации vs размера модели. */
export const VOXEL_SIZE = 0.0625;

/** Допустимые размеры модели (длина стороны grid в voxel'ах).
 *  При VOXEL_SIZE=0.0625 м: 16=1м, 32=2м, 48=3м, 64=4м, 80=5м, 96=6м, 128=8м.
 *  Промежуточные 48/80/96 нужны для крупных моделей (мебель, транспорт,
 *  здания) — генератор и пользователи используют их, чтобы модель не
 *  тонула в пустой сетке и не упиралась в стенки. */
export const ALLOWED_GRID_SIZES = [16, 32, 48, 64, 80, 96, 128];

/** Преобразование hex-строки '#RRGGBB' → Color4(0..1, alpha=1). */
function hexToColor4(hex) {
    if (!hex || typeof hex !== 'string') return new Color4(1, 1, 1, 1);
    const m = hex.trim().match(/^#?([0-9a-fA-F]{6})$/);
    if (!m) return new Color4(1, 1, 1, 1);
    const n = parseInt(m[1], 16);
    return new Color4(
        ((n >> 16) & 0xff) / 255,
        ((n >> 8) & 0xff) / 255,
        (n & 0xff) / 255,
        1,
    );
}

/** Преобразование Color4 → hex-строка. */
function color4ToHex(c) {
    const r = Math.round(c.r * 255);
    const g = Math.round(c.g * 255);
    const b = Math.round(c.b * 255);
    return '#' + [r, g, b].map(v => v.toString(16).padStart(2, '0')).join('');
}

export class VoxelModelScene {
    /**
     * @param {HTMLCanvasElement} canvas
     * @param {object} options
     * @param {number} options.gridSize — длина стороны 8|16|32|64 (default 16)
     * @param {string} options.initialColor — стартовый цвет кисти '#RRGGBB'
     * @param {function} options.onChange — колбэк когда модель меняется (для dirty-флага UI)
     */
    constructor(canvas, options = {}) {
        this.canvas = canvas;
        this.gridSize = ALLOWED_GRID_SIZES.includes(options.gridSize)
            ? options.gridSize : 32;
        this.currentColor = options.initialColor || '#5fa84e';
        this.currentTool = 'draw';   // 'draw' | 'erase' | 'paint' | 'fill'
        // Размер кисти 1..5 — за клик ставим/стираем куб NxNxN ячеек.
        this.brushSize = 1;
        // Рабочая плоскость для рисования "в пустоту" (как MagicaVoxel slice mode):
        //   workPlaneAxis: 'x' | 'y' | 'z' — какая ось перпендикулярна плоскости
        //   workPlaneLevel: 0..gridSize-1 — на какой ячейке по этой оси стоит
        // По умолчанию y=0 (на полу). Пользователь меняет в sidebar.
        this.workPlaneAxis = 'y';
        this.workPlaneLevel = 0;
        // Текстура voxel'я: null = чистый цвет, иначе id из VOXEL_TEXTURES.
        this.currentTexture = null;
        this._onChange = options.onChange || null;
        // Undo / Redo стеки. Хранят snapshot (serialize-строки).
        // Размер ограничен 50 шагами, чтобы не съедать память.
        this._undoStack = [];
        this._redoStack = [];
        this._undoLimit = 50;

        // voxels: Map<"x,y,z" → { color, texture, proto, idx }>
        //   color — '#RRGGBB' если voxel цветной (proto = _solidProto)
        //   texture — id из VOXEL_TEXTURES если voxel текстурный (proto = _texProtos.get(id))
        //   proto — ссылка на mesh куда был добавлен этот voxel (нужно для remove)
        //   idx — индекс инстанса в protо
        this.voxels = new Map();
        // Обратная карта: "<protoTag>:<idx>" → key. Один _idxToKey недостаточен
        // т.к. разные proto-меши имеют независимую нумерацию.
        this._idxToKey = new Map();

        // Per-proto _freeSlots: каждая proto-сетка имеет свой список свободных
        // слотов (когда удаляем — помечаем slot как scale=0, idx возвращается
        // в pool того же proto).
        this._freeSlotsByProto = new Map();

        // Батч-режим: при массовой загрузке/очистке (deserialize, clear)
        // _addVoxel/_removeVoxelByKey НЕ пересчитывают boundingInfo на каждом
        // вокселе — это O(N) на вызов → O(N²) на всю модель (модель 20K
        // вокселей грузилась "вечность"). В батче refresh откладывается и
        // делается один раз через _refreshAllProtos() в конце операции.
        this._batchMode = false;

        // Состояние ввода (для ПКМ-орбита и ЛКМ-рисования)
        this._mouse = {
            isOrbiting: false, isDrawing: false,
            lastX: 0, lastY: 0,
            lastBrushKey: null,
        };
        // Множество voxel-ключей поставленных за текущий drag.
        // Используется чтобы предотвратить "какаху к камере": когда пользователь
        // двигает мышь медленно, pick может попасть в только что поставленный
        // voxel и draw поставит соседний по нормали (новый voxel между ударом
        // и камерой). Если pick попал в "свой" voxel — игнорируем.
        this._currentDragVoxels = new Set();
        this._ptrX = null;
        this._ptrY = null;
        // WASD + QE — флаги нажатия клавиш для полёта камеры.
        // Активны только когда ПКМ зажата (как в редакторе сцены) — иначе
        // конфликты с input-полями.
        this._keys = { w: false, a: false, s: false, d: false, q: false, e: false, shift: false };

        this._initEngine();
        this._initScene();
        this._initCamera();
        this._initLights();
        this._initGrid();
        this._initVoxelProto();
        this._attachInput();
        this._startRender();
    }

    // =============== Engine / Scene ===============

    _initEngine() {
        this.engine = new Engine(this.canvas, true, {
            preserveDrawingBuffer: true,
            stencil: true,
            antialias: true,
            powerPreference: 'high-performance',
        });
    }

    _initScene() {
        this.scene = new Scene(this.engine);
        // Тёмный фон под общую тёмную тему редактора Рублокса.
        // #141414 = .canvasWrap background — сцена сливается с UI.
        this.scene.clearColor = new Color4(0.078, 0.078, 0.078, 1);
        // Высокий ambient — чтобы при ЛЮБОМ угле камеры все грани были
        // хорошо видны (модельный редактор, не игра — нужна равномерная подсветка).
        this.scene.ambientColor = new Color3(1, 1, 1);
    }

    _initCamera() {
        // ArcRotateCamera — орбита вокруг центра модели
        const half = this.gridSize * VOXEL_SIZE * 0.5;
        const dist = this.gridSize * VOXEL_SIZE * 2.2;
        this.camera = new ArcRotateCamera(
            'voxModelCam',
            -Math.PI / 4,        // alpha (горизонтальный угол)
            Math.PI / 3,         // beta (вертикальный)
            dist,
            new Vector3(half, half, half),
            this.scene,
        );
        this.camera.lowerRadiusLimit = this.gridSize * VOXEL_SIZE * 0.5;
        this.camera.upperRadiusLimit = this.gridSize * VOXEL_SIZE * 8;
        this.camera.wheelDeltaPercentage = 0.04;
        this.camera.pinchDeltaPercentage = 0.02;
        this.camera.panningSensibility = 50;
        this.camera.minZ = 0.05;
        // ВАЖНО: НЕ зовём camera.attachControl(canvas) — мы сами обрабатываем
        // ввод (ЛКМ = рисование, ПКМ = орбита, средняя = pan).
    }

    _initLights() {
        // Освещение модельного редактора: равномерное со всех сторон, чтобы
        // ЛЮБАЯ грань при ЛЮБОМ угле камеры была хорошо видна.
        // 4 directional light'а с разных сторон + сильный hemispheric с равным
        // sky/ground цветом (по сути ambient).
        const hemi = new HemisphericLight('hemi', new Vector3(0, 1, 0), this.scene);
        hemi.intensity = 0.6;
        // ВАЖНО: одинаковые diffuse и groundColor — иначе нижние грани темнее.
        hemi.diffuse = new Color3(1, 1, 1);
        hemi.groundColor = new Color3(0.85, 0.85, 0.9);
        hemi.specular = new Color3(0, 0, 0);

        // Свет сверху
        const top = new DirectionalLight('top', new Vector3(-0.3, -1, -0.2), this.scene);
        top.intensity = 0.45;
        top.specular = new Color3(0, 0, 0);

        // Свет снизу — слабее, чтобы при повороте грани не уходили в чёрное
        const bottom = new DirectionalLight('bottom', new Vector3(0.3, 1, 0.2), this.scene);
        bottom.intensity = 0.25;
        bottom.specular = new Color3(0, 0, 0);

        // Две боковые подсветки чтобы выявить рельеф (subtle rim)
        const sideA = new DirectionalLight('sideA', new Vector3(-1, 0, 0.4), this.scene);
        sideA.intensity = 0.20;
        sideA.specular = new Color3(0, 0, 0);
        const sideB = new DirectionalLight('sideB', new Vector3(1, 0, -0.4), this.scene);
        sideB.intensity = 0.20;
        sideB.specular = new Color3(0, 0, 0);
    }

    /**
     * Каркас сетки — рисуем рамку куба границ + плоскость "пола" для ориентира.
     * Помогает понять размер модели и где низ.
     */
    _initGrid() {
        const N = this.gridSize;
        const S = VOXEL_SIZE;
        const total = N * S;

        // Рамка-куб (wireframe) — серовато-синяя, едва заметная на светлом фоне.
        const frame = MeshBuilder.CreateBox('voxFrame', { size: total }, this.scene);
        frame.position = new Vector3(total / 2, total / 2, total / 2);
        const frameMat = new StandardMaterial('voxFrameMat', this.scene);
        frameMat.wireframe = true;
        frameMat.emissiveColor = new Color3(0.55, 0.62, 0.78);
        frameMat.disableLighting = true;
        frameMat.alpha = 0.50;
        frame.material = frameMat;
        frame.isPickable = false;
        frame._isFrame = true;

        // Пол — pickable, чтобы можно было рисовать на y=0.
        // Также служит визуальным ориентиром низа сетки.
        const floor = MeshBuilder.CreateGround('voxFloor', {
            width: total, height: total,
        }, this.scene);
        floor.position = new Vector3(total / 2, 0, total / 2);
        const floorMat = new StandardMaterial('voxFloorMat', this.scene);
        floorMat.emissiveColor = new Color3(0.83, 0.85, 0.90);
        floorMat.disableLighting = true;
        floorMat.alpha = 0.85;
        floor.material = floorMat;
        floor.isPickable = true;
        floor._isFloor = true;

        // Рабочая плоскость — синяя полупрозрачная, ставит voxel "в пустоту"
        // на выбранной оси/уровне. Включается из sidebar.
        const wp = MeshBuilder.CreateGround('voxWorkPlane', {
            width: total, height: total,
        }, this.scene);
        const wpMat = new StandardMaterial('voxWorkPlaneMat', this.scene);
        wpMat.emissiveColor = new Color3(0.18, 0.30, 0.55);
        wpMat.disableLighting = true;
        wpMat.alpha = 0.16;
        wpMat.backFaceCulling = false;
        wp.material = wpMat;
        wp.isPickable = true;
        wp._isWorkPlane = true;

        this._frame = frame;
        this._floor = floor;
        this._workPlane = wp;
        this._applyWorkPlaneTransform();
    }

    /**
     * Прото-меши. Один solid-proto для цветных voxels (per-instance vertex color)
     * + по одному proto на каждую текстуру (диффуз = текстура).
     * Все используют thin-instances → 1 draw call per proto.
     */
    _initVoxelProto() {
        // === Solid proto (цветной режим) ===
        const solidProto = this._createBaseProto('voxSolidProto');
        const solidMat = new StandardMaterial('voxSolidMat', this.scene);
        solidMat.diffuseColor = new Color3(1, 1, 1);
        solidMat.specularColor = new Color3(0, 0, 0);
        solidMat.backFaceCulling = true;
        solidProto.material = solidMat;
        solidProto.hasVertexAlpha = false;
        solidProto.useVertexColors = true;
        // Color buffer на gridSize³
        const maxVoxels = this.gridSize * this.gridSize * this.gridSize;
        this._colorsBuffer = new Float32Array(maxVoxels * 4);
        solidProto.thinInstanceSetBuffer('color', this._colorsBuffer, 4, false);
        this._solidProto = solidProto;
        this._freeSlotsByProto.set(solidProto, []);

        // === Текстурные proto (по одному на текстуру) ===
        this._texProtos = new Map();   // textureId → proto
        for (const tex of VOXEL_TEXTURES) {
            const p = this._createBaseProto(`voxTex_${tex.id}_proto`);
            const m = new StandardMaterial(`voxTex_${tex.id}_mat`, this.scene);
            try {
                const t = new Texture(tex.file, this.scene);
                // Минификация nearest для pixel-art стиля (как у блоков Minecraft)
                t.updateSamplingMode(Texture.NEAREST_NEAREST_MIPNEAREST);
                m.diffuseTexture = t;
            } catch (e) {
                m.diffuseColor = new Color3(1, 0.4, 0.8);   // фолбэк — заметный пурпур если файл не загрузился
            }
            m.specularColor = new Color3(0, 0, 0);
            m.backFaceCulling = true;
            p.material = m;
            p.hasVertexAlpha = false;
            this._texProtos.set(tex.id, p);
            this._freeSlotsByProto.set(p, []);
        }
    }

    /** Создать базовый proto-mesh (cube) с общими настройками pick/render. */
    _createBaseProto(name) {
        const p = MeshBuilder.CreateBox(name, { size: VOXEL_SIZE }, this.scene);
        p.isPickable = true;
        p.thinInstanceEnablePicking = true;
        // ВАЖНО: alwaysSelectAsActiveMesh=true мы НЕ ставим — иначе bbox
        // считается только по 1 proto-кубу в (0,0,0), а pick проверяет
        // ray-bbox в ЛОКАЛЬНОЙ системе и инстансы за пределами bbox не пикаются.
        // doNotSyncBoundingInfo тоже выключаем — Babylon сам пересчитывает
        // bbox при thinInstanceAdd чтобы pick работал по всем instance.
        // НЕ freezeWorldMatrix — ломает thin-instance picking.
        return p;
    }

    // =============== Ввод ===============

    _attachInput() {
        // Сами обрабатываем mouse: различаем ЛКМ vs ПКМ + drag.
        // Координаты pointer'а относительно canvas вычисляем через
        // getBoundingClientRect — НЕ полагаемся на scene.pointerX (он
        // обновляется только если Babylon attachControl, который мы выключили).
        const c = this.canvas;
        c.style.outline = 'none';
        c.tabIndex = 0;

        c.addEventListener('contextmenu', this._onContextMenu);
        c.addEventListener('pointerdown', this._onPointerDown);
        window.addEventListener('pointerup', this._onPointerUp);
        window.addEventListener('pointermove', this._onPointerMove);
        c.addEventListener('wheel', this._onWheel, { passive: false });
        window.addEventListener('keydown', this._onKeyDown);
        window.addEventListener('keyup', this._onKeyUp);
    }

    _detachInput() {
        const c = this.canvas;
        try {
            c.removeEventListener('contextmenu', this._onContextMenu);
            c.removeEventListener('pointerdown', this._onPointerDown);
            window.removeEventListener('pointerup', this._onPointerUp);
            window.removeEventListener('pointermove', this._onPointerMove);
            c.removeEventListener('wheel', this._onWheel);
            window.removeEventListener('keydown', this._onKeyDown);
            window.removeEventListener('keyup', this._onKeyUp);
        } catch (e) {}
    }

    _onContextMenu = (e) => { e.preventDefault(); };

    /** Обновить pointer-координаты в this._ptrX/Y (относительно canvas).
     *  Эти координаты используются в _applyBrushAtPointer для pick. */
    _updatePtr(e) {
        const rect = this.canvas.getBoundingClientRect();
        this._ptrX = e.clientX - rect.left;
        this._ptrY = e.clientY - rect.top;
    }

    _onPointerDown = (e) => {
        this.canvas.focus();
        this._updatePtr(e);
        // ПКМ может приходить с button=2; средняя кнопка с button=1.
        // ЛКМ = button 0.
        if (e.button === 2 || e.button === 1) {
            this._mouse.isOrbiting = true;
            this._mouse.lastX = e.clientX;
            this._mouse.lastY = e.clientY;
            try { this.canvas.setPointerCapture(e.pointerId); } catch (err) {}
        } else if (e.button === 0) {
            this._mouse.isDrawing = true;
            this._mouse.lastBrushKey = null;
            this._currentDragVoxels.clear();
            try { this.canvas.setPointerCapture(e.pointerId); } catch (err) {}
            // Снимок ДО изменений — один раз на каждое нажатие ЛКМ.
            // Drag не плодит снимки, undo откатывает всё мазок целиком.
            this._pushUndoSnapshot();
            this._applyBrushAtPointer(e.shiftKey);
        }
    };

    _onPointerUp = (e) => {
        if (e.button === 2 || e.button === 1) {
            this._mouse.isOrbiting = false;
            try { this.canvas.releasePointerCapture(e.pointerId); } catch (err) {}
        }
        if (e.button === 0) {
            this._mouse.isDrawing = false;
            this._mouse.lastBrushKey = null;
            this._currentDragVoxels.clear();
            try { this.canvas.releasePointerCapture(e.pointerId); } catch (err) {}
        }
    };

    _onPointerMove = (e) => {
        this._updatePtr(e);
        if (this._mouse.isOrbiting) {
            const dx = e.clientX - this._mouse.lastX;
            const dy = e.clientY - this._mouse.lastY;
            this._mouse.lastX = e.clientX;
            this._mouse.lastY = e.clientY;
            // Орбита (ручной alpha/beta). dx>0 (мышь вправо) → камера вращается
            // вокруг target по часовой стрелке → визуально модель уезжает влево.
            this.camera.alpha -= dx * 0.008;
            this.camera.beta = Math.max(0.05, Math.min(Math.PI - 0.05,
                this.camera.beta - dy * 0.008));
        } else if (this._mouse.isDrawing) {
            this._applyBrushAtPointer(e.shiftKey);
        }
    };

    _onWheel = (e) => {
        e.preventDefault();
        const delta = e.deltaY > 0 ? 1.08 : 0.92;
        const newR = this.camera.radius * delta;
        this.camera.radius = Math.max(
            this.camera.lowerRadiusLimit,
            Math.min(this.camera.upperRadiusLimit, newR),
        );
    };

    _onKeyDown = (e) => {
        const tag = (e.target?.tagName || '').toLowerCase();
        if (tag === 'input' || tag === 'textarea') return;
        // Ctrl+Z / Ctrl+Y — обрабатываются на React-уровне; сюда не доходят
        if (e.ctrlKey || e.metaKey) return;
        if (e.code === 'KeyF') {
            const half = this.gridSize * VOXEL_SIZE * 0.5;
            this.camera.target = new Vector3(half, half, half);
            return;
        }
        // WASD + Q/E — флаг нажатия (активно при зажатой ПКМ для движения камеры)
        let consumed = false;
        switch (e.code) {
            case 'KeyW': this._keys.w = true; consumed = true; break;
            case 'KeyA': this._keys.a = true; consumed = true; break;
            case 'KeyS': this._keys.s = true; consumed = true; break;
            case 'KeyD': this._keys.d = true; consumed = true; break;
            case 'KeyQ': this._keys.q = true; consumed = true; break;
            case 'KeyE': this._keys.e = true; consumed = true; break;
            case 'ShiftLeft':
            case 'ShiftRight':
                this._keys.shift = true; break;
            default: break;
        }
        // Предотвращаем scroll-страницы (стрелки/пробел), хотя у нас не они
        if (consumed) e.preventDefault();
    };

    _onKeyUp = (e) => {
        switch (e.code) {
            case 'KeyW': this._keys.w = false; break;
            case 'KeyA': this._keys.a = false; break;
            case 'KeyS': this._keys.s = false; break;
            case 'KeyD': this._keys.d = false; break;
            case 'KeyQ': this._keys.q = false; break;
            case 'KeyE': this._keys.e = false; break;
            case 'ShiftLeft':
            case 'ShiftRight':
                this._keys.shift = false; break;
            default: break;
        }
    };

    /** Полёт камеры WASD/QE — летит в направлении взгляда (как fly-cam в shooter'ах).
     *  W/S = вперёд/назад по лучу зрения (включая вертикальную составляющую),
     *  A/D = вбок (strafe), Q/E = вверх/вниз по миру. */
    _updateCameraFly() {
        const k = this._keys;
        const anyKey = k.w || k.a || k.s || k.d || k.q || k.e;
        if (!anyKey) return;
        const dtRaw = this.engine.getDeltaTime() / 1000;
        const dt = dtRaw > 0 && dtRaw < 0.2 ? dtRaw : 0.016;
        const speed = (this.gridSize * VOXEL_SIZE * 0.6) * (k.shift ? 2.5 : 1) * dt;
        const cam = this.camera;
        // Forward — направление взгляда (от позиции камеры к target), в 3D.
        // Это даёт полёт "куда смотрю" — W полетит вверх если камера смотрит вверх.
        const forward = cam.target.subtract(cam.position);
        if (forward.lengthSquared() < 1e-6) return;
        forward.normalize();
        // Right — перпендикуляр к forward в горизонтальной плоскости.
        // ВАЖНО: Babylon — ЛЕВОсторонняя система координат. Для вектора
        // "вправо" нужен cross(worldUp, forward), а НЕ cross(forward, worldUp)
        // — последний даёт вектор ВЛЕВО, из-за чего A/D были перепутаны.
        // Если forward почти вертикальный — fallback через ось X.
        const worldUp = new Vector3(0, 1, 0);
        let right = Vector3.Cross(worldUp, forward);
        if (right.lengthSquared() < 1e-6) {
            // Камера смотрит строго вверх/вниз — берём перпендикуляр через X
            right = Vector3.Cross(new Vector3(1, 0, 0), forward);
        }
        right.normalize();
        const move = new Vector3(0, 0, 0);
        if (k.w) move.addInPlace(forward.scale(speed));
        if (k.s) move.subtractInPlace(forward.scale(speed));
        if (k.d) move.addInPlace(right.scale(speed));
        if (k.a) move.subtractInPlace(right.scale(speed));
        if (k.e) move.y += speed;
        if (k.q) move.y -= speed;
        // Двигаем target — ArcRotateCamera следует position автоматически.
        cam.target.addInPlace(move);
    }

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

    /**
     * Рейкаст под курсором и применить текущий инструмент.
     * Различаем pick по voxel'у (для erase/paint) или по полу (для draw на пустое).
     *
     * Координаты pointer'а — this._ptrX/Y, обновляются в _updatePtr на каждом
     * pointermove (относительно canvas). НЕ используем scene.pointerX —
     * он не обновляется без Babylon attachControl.
     */
    /** Является ли этот mesh одним из voxel-proto (solid или textured). */
    _isVoxelProto(m) {
        if (m === this._solidProto) return true;
        for (const p of this._texProtos.values()) {
            if (m === p) return true;
        }
        return false;
    }

    _applyBrushAtPointer(shiftKey) {
        if (this._ptrX == null || this._ptrY == null) return;
        // Pickable: все voxel-proto + пол + workPlane.
        // Pick по полу: voxel на y=0. Pick по workPlane: voxel на выбранном
        // axis/level (рисование "в пустоту"). Pick по существующему voxel'у:
        // соседний по нормали грани.
        const pick = this.scene.pick(this._ptrX, this._ptrY, (m) => {
            return this._isVoxelProto(m) || m === this._floor || m === this._workPlane;
        });
        if (!pick || !pick.hit) return;

        let tool = this.currentTool;
        if (shiftKey && tool === 'draw') tool = 'erase';

        const hitProto = pick.pickedMesh && this._isVoxelProto(pick.pickedMesh)
            ? pick.pickedMesh : null;
        const isVoxelHit = hitProto
            && pick.thinInstanceIndex !== undefined
            && pick.thinInstanceIndex >= 0;
        const isFloorHit = !!(pick.pickedMesh && pick.pickedMesh._isFloor);
        const isWorkPlaneHit = !!(pick.pickedMesh && pick.pickedMesh._isWorkPlane);

        // === Воксель под курсором (для erase/paint/fill всегда; для draw — ставим соседний) ===
        if (isVoxelHit) {
            const hitKey = this._findKeyByInstanceIdx(hitProto, pick.thinInstanceIndex);
            if (!hitKey) return;

            // Anti-spam: один и тот же voxel за drag обрабатываем 1 раз
            if (tool === 'erase' || tool === 'paint') {
                if (hitKey === this._mouse.lastBrushKey) return;
                this._mouse.lastBrushKey = hitKey;
            }

            if (tool === 'erase') {
                // Erase brushSize-aware: при N>1 удаляется куб NxNxN вокруг попадания.
                const [bx, by, bz] = hitKey.split(',').map(Number);
                this._tryEraseAt({ x: bx, y: by, z: bz });
                return;
            }
            if (tool === 'paint') {
                this._paintVoxelByKey(hitKey, this._currentMaterial());
                this._emit();
                return;
            }
            if (tool === 'fill') {
                // Fill применяется один раз на клик (не на каждый drag-тик)
                this._floodFillFromKey(hitKey, this._currentMaterial());
                this._emit();
                this._mouse.isDrawing = false;   // отпускаем — иначе drag заливал бы повторно
                return;
            }
            if (tool === 'draw') {
                // АНТИ-КАКАХА: если pick попал в voxel который мы поставили
                // в текущем drag — игнорируем. Это происходит когда мышь
                // двигается медленно после клика: pick прицеливается в новый
                // voxel и draw поставил бы соседний "к камере".
                if (this._currentDragVoxels.has(hitKey)) return;
                // Поставить voxel «над» гранью (по нормали)
                const cell = this._cellFromVoxelHit(pick, hitProto);
                if (cell) this._tryDrawAt(cell);
                return;
            }
        }

        // === Пол под курсором (пустая часть сетки, y=0) ===
        if (isFloorHit && pick.pickedPoint) {
            if (tool !== 'draw') return;
            const cell = this._cellFromFloorHit(pick);
            if (cell) this._tryDrawAt(cell);
            return;
        }

        // === Рабочая плоскость (рисование "в пустоту" на axis/level) ===
        if (isWorkPlaneHit && pick.pickedPoint) {
            if (tool !== 'draw') return;
            const cell = this._cellFromWorkPlaneHit(pick);
            if (cell) this._tryDrawAt(cell);
        }
    }

    /** Cell-индекс при клике по workPlane (зависит от axis/level). */
    _cellFromWorkPlaneHit(pick) {
        const p = pick.pickedPoint;
        const S = VOXEL_SIZE;
        const lv = this.workPlaneLevel | 0;
        if (this.workPlaneAxis === 'y') {
            return { x: Math.floor(p.x / S), y: lv, z: Math.floor(p.z / S) };
        } else if (this.workPlaneAxis === 'x') {
            return { x: lv, y: Math.floor(p.y / S), z: Math.floor(p.z / S) };
        } else {
            return { x: Math.floor(p.x / S), y: Math.floor(p.y / S), z: lv };
        }
    }

    /** Поставить кубик NxNxN (brushSize) в cell.
     *  cell — центральная ячейка, вокруг неё заполняется куб.
     *  brushSize=1 → 1 voxel, =2 → 2³=8 ячеек, =3 → 27, =5 → 125. */
    _tryDrawAt(cell) {
        const key = `${cell.x},${cell.y},${cell.z}`;
        if (key === this._mouse.lastBrushKey) return;
        this._mouse.lastBrushKey = key;
        this._applyBrushBox(cell, /*draw=*/ true);
    }

    /** Стереть кубик NxNxN вокруг cell. */
    _tryEraseAt(cell) {
        this._applyBrushBox(cell, /*draw=*/ false);
    }

    /**
     * Применить brushSize-куб в cell.
     * draw=true → добавить voxel'и (если ячейка пуста); draw=false → стереть.
     * Куб всегда центрирован на cell: brushSize=1 → только cell, =2 → cell..cell+1
     * (anchor нижний-левый-задний), =3 → cell-1..cell+1 (по центру).
     */
    _applyBrushBox(cell, draw) {
        const N = Math.max(1, Math.min(5, this.brushSize | 0));
        // Смещение: для нечётных N — куб с центром в cell;
        // для чётных — anchor cell (куб расширяется в +x/+y/+z).
        const half = Math.floor(N / 2);
        const offs = (N % 2 === 1) ? -half : 0;
        let changed = false;
        for (let dy = 0; dy < N; dy++) {
            for (let dx = 0; dx < N; dx++) {
                for (let dz = 0; dz < N; dz++) {
                    const x = cell.x + offs + dx;
                    const y = cell.y + offs + dy;
                    const z = cell.z + offs + dz;
                    if (!this._inBounds({ x, y, z })) continue;
                    const k = `${x},${y},${z}`;
                    if (draw) {
                        if (this.voxels.has(k)) continue;
                        this._addVoxel(x, y, z, this._currentMaterial());
                        // Запоминаем что мы поставили этот voxel в текущем drag —
                        // следующий pick по нему не должен ставить соседний.
                        this._currentDragVoxels.add(k);
                        changed = true;
                    } else {
                        if (!this.voxels.has(k)) continue;
                        this._removeVoxelByKey(k);
                        changed = true;
                    }
                }
            }
        }
        if (changed) this._emit();
    }

    /** Cell-индекс из попадания в voxel + нормаль грани (для draw на соседнюю). */
    _cellFromVoxelHit(pick, hitProto) {
        const idx = pick.thinInstanceIndex;
        const key = this._findKeyByInstanceIdx(hitProto, idx);
        if (!key) return null;
        const [bx, by, bz] = key.split(',').map(Number);
        // Нормаль грани — определяем по PickingInfo.getNormal(true)
        const n = pick.getNormal?.(true);
        if (!n) return null;
        const ax = Math.abs(n.x), ay = Math.abs(n.y), az = Math.abs(n.z);
        let dx = 0, dy = 0, dz = 0;
        if (ax >= ay && ax >= az) dx = Math.sign(n.x);
        else if (ay >= ax && ay >= az) dy = Math.sign(n.y);
        else dz = Math.sign(n.z);
        return { x: bx + dx, y: by + dy, z: bz + dz };
    }

    /** Cell-индекс при клике по полу (y=0). */
    _cellFromFloorHit(pick) {
        const p = pick.pickedPoint;
        const S = VOXEL_SIZE;
        return {
            x: Math.floor(p.x / S),
            y: 0,
            z: Math.floor(p.z / S),
        };
    }

    _inBounds(cell) {
        return cell.x >= 0 && cell.x < this.gridSize
            && cell.y >= 0 && cell.y < this.gridSize
            && cell.z >= 0 && cell.z < this.gridSize;
    }

    _findKeyByInstanceIdx(proto, idx) {
        return this._idxToKey.get(this._reverseKey(proto, idx)) || null;
    }

    // =============== Storage + thin-instance ===============

    /** Поставить voxel. material: { color?, texture? }. */
    _addVoxel(x, y, z, material) {
        const key = `${x},${y},${z}`;
        if (this.voxels.has(key)) return;
        const S = VOXEL_SIZE;
        const mat = Matrix.Translation(
            (x + 0.5) * S,
            (y + 0.5) * S,
            (z + 0.5) * S,
        );
        // Выбираем proto по типу материала
        const useTexture = !!material.texture;
        const proto = useTexture
            ? this._texProtos.get(material.texture)
            : this._solidProto;
        if (!proto) return;
        const free = this._freeSlotsByProto.get(proto);
        let idx;
        // ВАЖНО для скорости: refresh-флаг = !this._batchMode.
        //   thinInstanceAdd(mat, true) на КАЖДОМ вокселе заставляет Babylon
        //   пересоздавать весь матричный буфер (Float32Array N×16) — это
        //   O(N²) копирований на загрузку модели. В батч-режиме передаём
        //   refresh=false; буфер обновляется ОДИН раз через _refreshAllProtos().
        const refresh = !this._batchMode;
        if (free && free.length > 0) {
            idx = free.pop();
            proto.thinInstanceSetMatrixAt(idx, mat, refresh);
        } else {
            idx = proto.thinInstanceAdd(mat, refresh);
        }
        // Помечаем proto как "грязный" — в батче нужно потом обновить буфер.
        if (this._batchMode) {
            if (!this._dirtyProtos) this._dirtyProtos = new Set();
            this._dirtyProtos.add(proto);
        }
        const record = {
            color: useTexture ? null : material.color,
            texture: useTexture ? material.texture : null,
            proto, idx,
        };
        this.voxels.set(key, record);
        this._idxToKey.set(this._reverseKey(proto, idx), key);
        if (!useTexture) this._setColorAt(idx, material.color);
        // Обновить bounding info по всем thin-instance — нужно для корректного pick.
        // В батч-режиме откладываем: _refreshAllProtos() сделает это один раз.
        if (!this._batchMode) {
            try { proto.thinInstanceRefreshBoundingInfo(true); } catch (e) {}
        }
    }

    /** Пересчитать матричный буфер + boundingInfo у всех proto-мешей разом.
     *  Вызывается после батч-операции (deserialize/clear) — вместо
     *  per-voxel refresh, который давал O(N²). */
    _refreshAllProtos() {
        // Сначала обновляем матричные буферы тех proto, что менялись в батче
        // (thinInstanceAdd с refresh=false не отдал буфер на GPU).
        if (this._dirtyProtos) {
            for (const proto of this._dirtyProtos) {
                try { proto.thinInstanceBufferUpdated('matrix'); } catch (e) {}
            }
            this._dirtyProtos.clear();
        }
        // Color-буфер solid-proto — обновляем один раз, если менялся в батче.
        if (this._colorBufferDirty) {
            try { this._solidProto?.thinInstanceBufferUpdated('color'); } catch (e) {}
            this._colorBufferDirty = false;
        }
        try { this._solidProto?.thinInstanceRefreshBoundingInfo(true); } catch (e) {}
        if (this._texProtos) {
            for (const proto of this._texProtos.values()) {
                try { proto.thinInstanceRefreshBoundingInfo(true); } catch (e) {}
            }
        }
    }

    _reverseKey(proto, idx) {
        return `${proto.name}:${idx}`;
    }

    _removeVoxelByKey(key) {
        const v = this.voxels.get(key);
        if (!v) return;
        const zero = Matrix.Scaling(0, 0, 0);
        // refresh=false в батче — буфер обновится разом в _refreshAllProtos.
        const refresh = !this._batchMode;
        try { v.proto.thinInstanceSetMatrixAt(v.idx, zero, refresh); } catch (e) {}
        if (this._batchMode) {
            if (!this._dirtyProtos) this._dirtyProtos = new Set();
            this._dirtyProtos.add(v.proto);
        }
        const free = this._freeSlotsByProto.get(v.proto);
        if (free) free.push(v.idx);
        this._idxToKey.delete(this._reverseKey(v.proto, v.idx));
        this.voxels.delete(key);
        // В батч-режиме refresh откладывается (см. _refreshAllProtos).
        if (!this._batchMode) {
            try { v.proto.thinInstanceRefreshBoundingInfo(true); } catch (e) {}
        }
    }

    _paintVoxelByKey(key, material) {
        const v = this.voxels.get(key);
        if (!v) return;
        const sameColor = !material.texture && v.color === material.color;
        const sameTex = !!material.texture && v.texture === material.texture;
        if (sameColor || sameTex) return;
        // Если меняется proto (цвет ↔ текстура или текстура ↔ текстура) —
        // надо удалить и поставить заново
        const oldProto = v.proto;
        const newUsesTex = !!material.texture;
        const newProto = newUsesTex
            ? this._texProtos.get(material.texture)
            : this._solidProto;
        if (oldProto !== newProto) {
            // Простой re-add: удаляем + добавляем (idx, proto меняются)
            const [x, y, z] = key.split(',').map(Number);
            this._removeVoxelByKey(key);
            this._addVoxel(x, y, z, material);
            return;
        }
        // Тот же proto — для solid просто меняем цвет в буфере
        if (!newUsesTex) {
            v.color = material.color;
            this._setColorAt(v.idx, material.color);
        }
        // Текстурные с одинаковой текстурой — sameTex выше уже отсеял
    }

    /** 3D flood-fill: меняем материал у всех соединённых voxels того же типа. */
    _floodFillFromKey(startKey, newMaterial) {
        const start = this.voxels.get(startKey);
        if (!start) return;
        // Старый материал в каноническом виде (color или texture)
        const oldKey = start.texture ? `t:${start.texture}` : `c:${start.color}`;
        const newKey = newMaterial.texture ? `t:${newMaterial.texture}` : `c:${newMaterial.color}`;
        if (oldKey === newKey) return;
        const stack = [startKey];
        const seen = new Set();
        while (stack.length) {
            const k = stack.pop();
            if (seen.has(k)) continue;
            seen.add(k);
            const v = this.voxels.get(k);
            if (!v) continue;
            const vKey = v.texture ? `t:${v.texture}` : `c:${v.color}`;
            if (vKey !== oldKey) continue;
            this._paintVoxelByKey(k, newMaterial);
            const [x, y, z] = k.split(',').map(Number);
            for (const [dx, dy, dz] of [[1,0,0],[-1,0,0],[0,1,0],[0,-1,0],[0,0,1],[0,0,-1]]) {
                const nk = `${x+dx},${y+dy},${z+dz}`;
                if (!seen.has(nk) && this.voxels.has(nk)) stack.push(nk);
            }
        }
    }

    /** Установить цвет для thin-instance idx через colorBuffer. */
    _setColorAt(idx, hex) {
        const c = hexToColor4(hex);
        // Буфер выделен под gridSize^3 — всегда достаточно места.
        const off = idx * 4;
        if (off + 3 >= this._colorsBuffer.length) {
            // Запас: если приходит idx больше ожидаемого (например после resize
            // мы потеряли точное соответствие), растим буфер.
            const newSize = Math.max((idx + 16) * 4, this._colorsBuffer.length * 2);
            const nb = new Float32Array(newSize);
            nb.set(this._colorsBuffer);
            this._colorsBuffer = nb;
            this._solidProto.thinInstanceSetBuffer('color', this._colorsBuffer, 4, false);
        }
        this._colorsBuffer[off + 0] = c.r;
        this._colorsBuffer[off + 1] = c.g;
        this._colorsBuffer[off + 2] = c.b;
        this._colorsBuffer[off + 3] = 1;
        // В батч-режиме НЕ отдаём color-буфер на GPU на каждом вокселе —
        // это O(N²). _refreshAllProtos() обновит его один раз в конце.
        if (this._batchMode) {
            this._colorBufferDirty = true;
        } else {
            try { this._solidProto.thinInstanceBufferUpdated('color'); } catch (e) {}
        }
    }

    // =============== Публичный API ===============

    setTool(tool) {
        if (['draw', 'erase', 'paint', 'fill'].includes(tool)) {
            this.currentTool = tool;
            this._mouse.lastBrushKey = null;
        }
    }

    setColor(hex) {
        this.currentColor = hex;
        this.currentTexture = null;   // выбор цвета сбрасывает текстуру
    }

    /** Выбрать текстуру (id из VOXEL_TEXTURES) или null для возврата к цвету. */
    setTexture(textureId) {
        this.currentTexture = textureId || null;
    }

    /** Текущий материал для кисти. */
    _currentMaterial() {
        return this.currentTexture
            ? { texture: this.currentTexture }
            : { color: this.currentColor };
    }

    /** Размер кисти 1..5 — куб NxNxN ячеек за клик. */
    setBrushSize(n) {
        this.brushSize = Math.max(1, Math.min(5, n | 0));
    }

    /** Ось рабочей плоскости: 'x' | 'y' | 'z'. */
    setWorkPlaneAxis(axis) {
        if (axis !== 'x' && axis !== 'y' && axis !== 'z') return;
        this.workPlaneAxis = axis;
        this._applyWorkPlaneTransform();
    }

    /** Уровень рабочей плоскости — 0..gridSize-1 по выбранной оси. */
    setWorkPlaneLevel(level) {
        const lv = Math.max(0, Math.min(this.gridSize - 1, level | 0));
        this.workPlaneLevel = lv;
        this._applyWorkPlaneTransform();
    }

    /** Применить позицию/ориентацию workPlane по текущим axis/level. */
    _applyWorkPlaneTransform() {
        const wp = this._workPlane;
        if (!wp) return;
        const S = VOXEL_SIZE;
        const N = this.gridSize;
        const half = (N * S) / 2;
        const lv = (this.workPlaneLevel + 0.5) * S;
        if (this.workPlaneAxis === 'y') {
            wp.rotation.set(0, 0, 0);
            wp.position.set(half, lv, half);
        } else if (this.workPlaneAxis === 'x') {
            wp.rotation.set(0, 0, Math.PI / 2);
            wp.position.set(lv, half, half);
        } else {
            wp.rotation.set(Math.PI / 2, 0, 0);
            wp.position.set(half, half, lv);
        }
    }


    /** Очистить все voxels (с undo snapshot). */
    clear() {
        this._pushUndoSnapshot();
        this._clearWithoutSnapshot();
        this._emit();
    }

    _clearWithoutSnapshot() {
        // Батч: убираем все воксели без per-voxel refresh, refresh один раз в конце.
        const wasBatch = this._batchMode;
        this._batchMode = true;
        for (const k of Array.from(this.voxels.keys())) {
            this._removeVoxelByKey(k);
        }
        this._batchMode = wasBatch;
        if (!this._batchMode) this._refreshAllProtos();
    }

    /** Сохранить текущий snapshot в undo-стек. Чистит redo-стек. */
    _pushUndoSnapshot() {
        const snap = this.serialize();
        this._undoStack.push(snap);
        if (this._undoStack.length > this._undoLimit) {
            this._undoStack.shift();
        }
        // Любое новое действие сбрасывает redo
        this._redoStack.length = 0;
    }

    /** Откатить последнее изменение. */
    undo() {
        if (this._undoStack.length === 0) return false;
        const current = this.serialize();
        const prev = this._undoStack.pop();
        this._redoStack.push(current);
        this._restoreFromSnapshot(prev);
        return true;
    }

    /** Вернуть откат. */
    redo() {
        if (this._redoStack.length === 0) return false;
        const current = this.serialize();
        const next = this._redoStack.pop();
        this._undoStack.push(current);
        this._restoreFromSnapshot(next);
        return true;
    }

    /** Toggle референс-фигурки человека (стандартный рост 2м = 8 voxels).
     *  Помогает оценить масштаб модели — особенно полезно на крупных сетках. */
    setReferenceVisible(visible) {
        console.log('[VoxelModelScene] setReferenceVisible called with:', visible,
            '_reference exists:', !!this._reference);
        if (visible && !this._reference) {
            this._buildReference();
        }
        if (this._reference) {
            this._reference.setEnabled(!!visible);
        }
    }

    /** Построить референс масштаба: GLB-модель bacon-hair (рост 2 м).
     *  Логика повторяет ModelManager.addInstance — проверенный рабочий путь:
     *  LoadAssetContainerAsync + instantiateModelsToScene + парентим к
     *  своему TransformNode + scale по targetHeight через getVerticesData. */
    _buildReference() {
        console.log('[VoxelModelScene] _buildReference() called');
        const allNodes = [];
        let pendingVisible = false;
        const applyVisible = (v) => {
            console.log('[VoxelModelScene] applyVisible:', v, 'allNodes.length=', allNodes.length);
            let i = 0;
            for (const n of allNodes) {
                try {
                    n.setEnabled(!!v);
                    if (i < 3) {
                        console.log('  node:', n.name || '(unnamed)',
                            'type=', n.getClassName?.() || typeof n,
                            'enabled=', n.isEnabled?.());
                    }
                } catch (e) {
                    console.warn('  setEnabled failed for', n, e);
                }
                i++;
            }
        };
        this._reference = {
            setEnabled: (v) => {
                console.log('[VoxelModelScene] _reference.setEnabled:', v,
                    'wasLoaded=', allNodes.length > 0);
                pendingVisible = !!v;
                // Запоминаем последнее запрошенное состояние видимости —
                // нужно чтобы при снятии превью скрыть и вернуть как было.
                this._referenceVisible = !!v;
                applyVisible(v);
            },
            dispose: () => {
                for (const n of allNodes) { try { n.dispose(); } catch (e) {} }
                allNodes.length = 0;
            },
        };

        SceneLoader.LoadAssetContainerAsync(
            '/kubikon-assets/models/characters/',
            'bacon-hair.glb',
            this.scene,
        ).then((container) => {
            if (!this._reference) {
                try { container.dispose(); } catch (e) {}
                return;
            }

            // Инстанцируем модель (рабочий путь из ModelManager.addInstance).
            const inst = container.instantiateModelsToScene(
                (name) => `voxRef_${name}`,
                true,
                { doNotInstantiate: false }
            );
            console.log('[VoxelModelScene] bacon instantiated:',
                'rootNodes=', inst.rootNodes.length,
                'anims=', inst.animationGroups?.length || 0);

            // Останавливаем клонированные анимации (бег и т.п.).
            if (inst.animationGroups) {
                for (const ag of inst.animationGroups) {
                    try { ag.stop(); } catch (e) {}
                }
            }

            // Используем root самого инстанса (__root__ от GLB) как наш root —
            // НЕ создаём свой TransformNode-родитель сверху, иначе scaling
            // умножается на исходный glTF-scale (обычно 0.01).
            const root = inst.rootNodes[0];
            if (!root) {
                console.warn('[VoxelModelScene] no rootNodes in instance');
                return;
            }
            root.name = 'voxRefBacon';
            console.log('[VoxelModelScene] using GLB root directly:', root.name,
                'initial scaling=', root.scaling?.x, root.scaling?.y, root.scaling?.z,
                'initial rotation=', root.rotationQuaternion ? 'quat' : 'euler',
                'class=', root.getClassName());

            // Собираем clonedMeshes для bbox расчёта + регистрируем в allNodes.
            const clonedMeshes = [];
            root.getChildMeshes(false).forEach(m => {
                m.isPickable = false;
                clonedMeshes.push(m);
                allNodes.push(m);
            });
            if (root.getTotalVertices && root.getTotalVertices() > 0) {
                root.isPickable = false;
                clonedMeshes.push(root);
            }
            allNodes.push(root);
            // alwaysSelectAsActiveMesh — для надёжности рендера.
            for (const m of clonedMeshes) {
                try { m.alwaysSelectAsActiveMesh = true; } catch (e) {}
            }

            // Сохраняем ИСХОДНЫЕ знаки scaling — glTF-loader использует
            // scaling.z=-1 для конвертации right-handed → left-handed системы
            // координат. Если затереть на (1,1,1), нормали инвертируются и
            // модель рендерится наизнанку (backface culling делает её невидимой).
            const initSx = Math.sign(root.scaling.x) || 1;
            const initSy = Math.sign(root.scaling.y) || 1;
            const initSz = Math.sign(root.scaling.z) || 1;
            console.log('[VoxelModelScene] initial scaling signs:', initSx, initSy, initSz);

            root.position.set(0, 0, 0);
            // glTF-loader использует rotationQuaternion вместо rotation —
            // нужно его обнулить чтобы наше Euler-rotation работало.
            if (root.rotationQuaternion) {
                root.rotationQuaternion = null;
            }
            root.rotation.set(0, 0, 0);
            // Единичный scaling для расчёта bbox, но с СОХРАНЁННЫМИ знаками.
            root.scaling.set(initSx, initSy, initSz);
            try { root.computeWorldMatrix(true); } catch (e) {}
            for (const m of clonedMeshes) {
                try { m.computeWorldMatrix(true); } catch (e) {}
            }
            let minY = Infinity, maxY = -Infinity;
            let minX = Infinity, maxX = -Infinity;
            let minZ = Infinity, maxZ = -Infinity;
            const tmp = new Vector3();
            for (const m of clonedMeshes) {
                if (!m || typeof m.getTotalVertices !== 'function') continue;
                if (m.getTotalVertices() <= 0) continue;
                let positions;
                try { positions = m.getVerticesData(VertexBuffer.PositionKind); }
                catch (e) { continue; }
                if (!positions) continue;
                const wm = m.getWorldMatrix();
                for (let i = 0; i < positions.length; i += 3) {
                    tmp.set(positions[i], positions[i + 1], positions[i + 2]);
                    const w = Vector3.TransformCoordinates(tmp, wm);
                    if (w.y < minY) minY = w.y;
                    if (w.y > maxY) maxY = w.y;
                    if (w.x < minX) minX = w.x;
                    if (w.x > maxX) maxX = w.x;
                    if (w.z < minZ) minZ = w.z;
                    if (w.z > maxZ) maxZ = w.z;
                }
            }
            const realHeight = (maxY - minY) > 0.001 && isFinite(maxY - minY)
                ? (maxY - minY) : 2.0;
            // Целевая высота = реальный рост игрового персонажа в Кубиконе.
            // PlayerController использует HALF_H = 0.9 м → полная высота AABB
            // = 1.8 м. Это стандартный рост Roblox/Кубикон ≈ человеческий рост.
            // Так пользователь видит МАСШТАБ своей модели относительно
            // настоящего игрового персонажа.
            const targetH = 1.8;
            const finalScale = targetH / realHeight;
            const targetW = (maxX - minX) * finalScale;
            const targetD = (maxZ - minZ) * finalScale;
            console.log('[VoxelModelScene] bacon natural size: w=',
                (maxX - minX).toFixed(2),
                'h=', realHeight.toFixed(2),
                'd=', (maxZ - minZ).toFixed(2),
                'minY=', minY.toFixed(2),
                'targetH=', targetH.toFixed(3),
                'finalScale=', finalScale.toFixed(4));

            // Применяем scale с СОХРАНЁННЫМИ знаками. Z=-1 — это glTF-конвенция,
            // её нельзя терять иначе модель рендерится наизнанку (backface).
            root.scaling.set(initSx * finalScale, initSy * finalScale, initSz * finalScale);
            root.rotation.set(0, Math.PI, 0);
            // Сначала ставим в 0,0,0 чтобы пересчитать world bbox.
            root.position.set(0, 0, 0);
            // ПОЗИЦИОНИРОВАНИЕ ОТ КАМЕРЫ.
            // Камера ArcRotateCamera стоит в alpha=-PI/4, beta=PI/3 — это
            // смотрит из квадранта +X+Z к центру куба сетки (L/2, L/2, L/2).
            // Ближний к камере угол пола = (L, 0, L), дальний = (0, 0, 0).
            //
            // Хочу bacon ПЕРЕД кубом (между камерой и кубом), у ближнего угла.
            // Сместить от ближнего угла куба в направлении +X (правее куба
            // с точки зрения мира). Левее по экрану камера видит +X как лево
            // потому что rotation_z экрана повёрнут.
            //
            // Простое решение: bacon в (L + zazor + W/2, 0, L/2) — выходит
            // вправо от куба по +X, по центру по Z. Если "слева от куба" с
            // точки зрения экрана — меняем +X на -X, минусуем половину ширины.
            // ПОЗИЦИОНИРОВАНИЕ ПО WORLD-BBOX ПОСЛЕ scale+rotate.
            // Пересчитываем bbox в мировых координатах с применёнными
            // scaling и rotation — это даёт ТОЧНЫЕ границы как они в сцене.
            // Затем сдвигаем bacon так чтобы его world-bbox оказался в нужном
            // месте: ноги на y=0, ближнее-нижнее-правое углое модели рядом
            // с углом (L, 0, L) куба сетки.
            try { root.computeWorldMatrix(true); } catch (e) {}
            for (const m of clonedMeshes) {
                try { m.computeWorldMatrix(true); } catch (e) {}
            }
            let realMinX = Infinity, realMaxX = -Infinity;
            let realMinY = Infinity, realMaxY = -Infinity;
            let realMinZ = Infinity, realMaxZ = -Infinity;
            for (const m of clonedMeshes) {
                if (!m.getTotalVertices || m.getTotalVertices() <= 0) continue;
                let positions;
                try { positions = m.getVerticesData(VertexBuffer.PositionKind); }
                catch (e) { continue; }
                if (!positions) continue;
                const wm = m.getWorldMatrix();
                for (let i = 0; i < positions.length; i += 3) {
                    tmp.set(positions[i], positions[i + 1], positions[i + 2]);
                    const w = Vector3.TransformCoordinates(tmp, wm);
                    if (w.x < realMinX) realMinX = w.x;
                    if (w.x > realMaxX) realMaxX = w.x;
                    if (w.y < realMinY) realMinY = w.y;
                    if (w.y > realMaxY) realMaxY = w.y;
                    if (w.z < realMinZ) realMinZ = w.z;
                    if (w.z > realMaxZ) realMaxZ = w.z;
                }
            }
            console.log('[VoxelModelScene] bacon REAL world bbox after xform (root at 0,0,0):',
                'x[', realMinX.toFixed(2), '..', realMaxX.toFixed(2), ']',
                'y[', realMinY.toFixed(2), '..', realMaxY.toFixed(2), ']',
                'z[', realMinZ.toFixed(2), '..', realMaxZ.toFixed(2), ']');

            // Маркер origin виден в углу (0, 0, 0) куба — это передний-левый-
            // нижний угол на экране (подтверждено скриншотом).
            // Хочу bacon ВПЛОТНУЮ слева от маркера, снаружи куба:
            //   bacon.maxX = 0 - gap  (правый бок bacon чуть левее угла куба)
            //   bacon.minY = 0        (ноги на полу куба)
            //   bacon.minZ = 0 - gap  (задний край bacon чуть в -Z от угла куба)
            const gap = 0.10;
            const shiftX = (-gap) - realMaxX;       // bacon полностью в x<0
            const shiftY = 0 - realMinY;             // ноги на y=0
            const shiftZ = (-gap) - realMinZ;        // bacon в z<0 (впереди куба)
            root.position.set(shiftX, shiftY, shiftZ);
            console.log('[VoxelModelScene] bacon shift to corner (0,0,0) outside:',
                shiftX.toFixed(2), shiftY.toFixed(2), shiftZ.toFixed(2));
            console.log('[VoxelModelScene] bacon final position:',
                root.position.x.toFixed(2),
                root.position.y.toFixed(2),
                root.position.z.toFixed(2),
                'targetW=', targetW.toFixed(2),
                'targetD=', targetD.toFixed(2));

            // Диагностика после применения scale — реальные мировые координаты.
            try { root.computeWorldMatrix(true); } catch (e) {}
            for (const m of clonedMeshes) {
                try { m.computeWorldMatrix(true); } catch (e) {}
            }
            let postMinY = Infinity, postMaxY = -Infinity;
            for (const m of clonedMeshes) {
                if (!m.getTotalVertices || m.getTotalVertices() <= 0) continue;
                let positions;
                try { positions = m.getVerticesData(VertexBuffer.PositionKind); }
                catch (e) { continue; }
                if (!positions) continue;
                const wm = m.getWorldMatrix();
                for (let i = 0; i < positions.length; i += 3) {
                    tmp.set(positions[i], positions[i + 1], positions[i + 2]);
                    const w = Vector3.TransformCoordinates(tmp, wm);
                    if (w.y < postMinY) postMinY = w.y;
                    if (w.y > postMaxY) postMaxY = w.y;
                }
            }
            console.log('[VoxelModelScene] AFTER scale applied: world Y range [',
                postMinY.toFixed(2), '..', postMaxY.toFixed(2),
                '] height=', (postMaxY - postMinY).toFixed(2),
                '(ожидается ~2.0)');

            console.log('[VoxelModelScene] bacon root after xform:',
                'pos=', root.position.x.toFixed(2), root.position.y.toFixed(2), root.position.z.toFixed(2),
                'scaling=', root.scaling.x.toFixed(3),
                'enabled=', root.isEnabled());
            console.log('[VoxelModelScene] clonedMeshes details:');
            for (let i = 0; i < Math.min(clonedMeshes.length, 5); i++) {
                const m = clonedMeshes[i];
                console.log('  mesh#' + i, m.name,
                    'enabled=', m.isEnabled?.(),
                    'visibility=', m.visibility,
                    'verts=', m.getTotalVertices?.(),
                    'parent=', m.parent?.name,
                    'material=', m.material?.name,
                    'matAlpha=', m.material?.alpha);
            }
            console.log('[VoxelModelScene] allNodes.length=', allNodes.length,
                'pendingVisible=', pendingVisible);
            applyVisible(pendingVisible);
            // Дополнительная проверка — после applyVisible ещё раз посмотрим
            console.log('[VoxelModelScene] AFTER applyVisible:',
                'root.enabled=', root.isEnabled(),
                'firstMesh.enabled=', clonedMeshes[0]?.isEnabled());
        }).catch((err) => {
            console.warn('[VoxelModelScene] bacon-hair load failed:', err);
        });
    }

    _restoreFromSnapshot(snap) {
        // Загружаем без push'a в undo (избегаем рекурсии)
        this._clearWithoutSnapshot();
        const decoded = decodeVoxelModel(snap);
        if (!decoded) return;
        if (ALLOWED_GRID_SIZES.includes(decoded.size) && decoded.size !== this.gridSize) {
            // Размер изменился — пересоздаём grid без потери snapshot'ов
            this.gridSize = decoded.size;
            try { this._frame?.dispose(); } catch (e) {}
            try { this._floor?.dispose(); } catch (e) {}
            try { this._workPlane?.dispose(); } catch (e) {}
            this._initGrid();
            const maxVoxels = decoded.size * decoded.size * decoded.size;
            this._colorsBuffer = new Float32Array(maxVoxels * 4);
            this._solidProto.thinInstanceSetBuffer('color', this._colorsBuffer, 4, false);
            const half = decoded.size * VOXEL_SIZE * 0.5;
            this.camera.target = new Vector3(half, half, half);
        }
        // Батч-режим — без per-voxel refresh (см. deserialize).
        this._batchMode = true;
        for (const v of decoded.voxels) {
            const mat = v.t ? { texture: v.t } : { color: v.c || '#ffffff' };
            this._addVoxel(v.x, v.y, v.z, mat);
        }
        this._batchMode = false;
        this._refreshAllProtos();
        this._emit();
    }

    canUndo() { return this._undoStack.length > 0; }
    canRedo() { return this._redoStack.length > 0; }

    /** Сменить размер сетки — сбрасывает все voxels (с undo snapshot). */
    resize(newSize) {
        if (!ALLOWED_GRID_SIZES.includes(newSize)) return;
        this._pushUndoSnapshot();
        this._clearWithoutSnapshot();
        this.gridSize = newSize;
        // Корректируем workPlaneLevel чтобы не выйти за пределы новой сетки
        this.workPlaneLevel = Math.min(this.workPlaneLevel, newSize - 1);
        // Пересоздаём frame/floor/workPlane
        try { this._frame?.dispose(); } catch (e) {}
        try { this._floor?.dispose(); } catch (e) {}
        try { this._workPlane?.dispose(); } catch (e) {}
        this._initGrid();
        // Пересоздаём color-buffer под новый размер (n³ × 4 float)
        const maxVoxels = newSize * newSize * newSize;
        this._colorsBuffer = new Float32Array(maxVoxels * 4);
        this._solidProto.thinInstanceSetBuffer('color', this._colorsBuffer, 4, false);
        // Пересоздаём камеру-таргет
        const half = newSize * VOXEL_SIZE * 0.5;
        this.camera.target = new Vector3(half, half, half);
        this.camera.radius = newSize * VOXEL_SIZE * 2.2;
        this.camera.lowerRadiusLimit = newSize * VOXEL_SIZE * 0.5;
        this.camera.upperRadiusLimit = newSize * VOXEL_SIZE * 8;
    }

    /** Сериализация модели в JSON. Формат v3 (компактный: палитра + плоский
     *  массив чисел) — в 4-5 раз легче старого v2. См. voxelModelCodec.js. */
    serialize() {
        const arr = [];
        for (const [key, v] of this.voxels) {
            const [x, y, z] = key.split(',').map(Number);
            if (v.texture) arr.push({ x, y, z, t: v.texture });
            else arr.push({ x, y, z, c: v.color });
        }
        return encodeVoxelModel(this.gridSize, arr);
    }

    /** Загрузить из JSON-строки. Кодек читает v3 (компактный), v2 и v1. */
    deserialize(json) {
        this.clear();
        const decoded = decodeVoxelModel(json);
        if (!decoded) return false;
        if (ALLOWED_GRID_SIZES.includes(decoded.size) && decoded.size !== this.gridSize) {
            this.resize(decoded.size);
        }
        // Батч-режим: десятки тысяч вокселей добавляются без per-voxel
        // refresh boundingInfo (был O(N²) → "вечная" загрузка). Один
        // refresh в конце.
        this._batchMode = true;
        for (const v of decoded.voxels) {
            const mat = v.t ? { texture: v.t } : { color: v.c || '#ffffff' };
            this._addVoxel(v.x, v.y, v.z, mat);
        }
        this._batchMode = false;
        this._refreshAllProtos();
        this._emit();
        return true;
    }

    /**
     * Спрятать вспомогательные элементы сцены (рамка-куб границ сетки,
     * нижняя плоскость-пол, рабочая плоскость, референс роста) — на момент
     * снятия превью, чтобы они не попадали в обложку модели.
     * Возвращает объект состояния для последующего _restoreHelpers().
     */
    _hideHelpers() {
        const state = {
            frame: this._frame ? this._frame.isEnabled() : null,
            floor: this._floor ? this._floor.isEnabled() : null,
            workPlane: this._workPlane ? this._workPlane.isEnabled() : null,
            reference: !!this._referenceVisible,
        };
        try { this._frame?.setEnabled(false); } catch (e) {}
        try { this._floor?.setEnabled(false); } catch (e) {}
        try { this._workPlane?.setEnabled(false); } catch (e) {}
        if (this._referenceVisible) {
            try { this._reference?.setEnabled(false); } catch (e) {}
        }
        return state;
    }

    /** Вернуть видимость вспомогательных элементов после снятия превью. */
    _restoreHelpers(state) {
        if (!state) return;
        try { if (state.frame !== null) this._frame?.setEnabled(state.frame); } catch (e) {}
        try { if (state.floor !== null) this._floor?.setEnabled(state.floor); } catch (e) {}
        try { if (state.workPlane !== null) this._workPlane?.setEnabled(state.workPlane); } catch (e) {}
        if (state.reference) {
            try { this._reference?.setEnabled(true); } catch (e) {}
        }
    }

    /** Публично спрятать вспомогательные элементы (для режима выделения
     *  превью — чтобы пользователь видел ровно то, что попадёт в обложку).
     *  Состояние сохраняется в this._helperHideState. */
    hideHelpersForPreview() {
        this._helperHideState = this._hideHelpers();
    }

    /** Публично вернуть вспомогательные элементы после режима выделения. */
    restoreHelpersAfterPreview() {
        this._restoreHelpers(this._helperHideState);
        this._helperHideState = null;
    }

    /** Снять PNG превью текущего viewport'а — для thumbnail_b64.
     *  Вспомогательные элементы (рамка/пол/рабочая плоскость) скрываются. */
    captureThumbnail(width = 128, height = 128) {
        // Babylon Tools.CreateScreenshot — callback API, оборачиваем в Promise.
        // Возвращает data URL PNG: "data:image/png;base64,iVBORw..."
        return new Promise((resolve) => {
            // Если helpers уже скрыты вручную (режим выделения превью) —
            // не трогаем их, чтобы не вернуть видимость раньше времени.
            const manualHide = !!this._helperHideState;
            const helperState = manualHide ? null : this._hideHelpers();
            try {
                Tools.CreateScreenshot(this.engine, this.camera,
                    { width, height }, (data) => {
                        if (!manualHide) this._restoreHelpers(helperState);
                        resolve(data || '');
                    });
            } catch (e) {
                console.warn('[VoxelModelScene] captureThumbnail failed:', e);
                if (!manualHide) this._restoreHelpers(helperState);
                resolve('');
            }
        });
    }

    /**
     * Снять снимок канваса в ЕГО ТЕКУЩЕМ разрешении (как видит пользователь).
     * Нужно для интерактивного выделения превью: пользователь выделяет
     * квадрат в координатах экрана, мы потом вырежем его из этого снимка.
     * Вспомогательные элементы (рамка/пол/рабочая плоскость) скрываются —
     * чтобы они не попадали в превью.
     *
     * Возвращает { dataUrl, width, height } — PNG data URL + размеры
     * картинки в пикселях рендера (canvas.width/height, не CSS-размер).
     */
    captureCanvasSnapshot() {
        return new Promise((resolve) => {
            // Если helpers уже скрыты вручную (режим выделения превью) —
            // не трогаем: они должны оставаться скрытыми пока юзер выделяет.
            const manualHide = !!this._helperHideState;
            const helperState = manualHide ? null : this._hideHelpers();
            try {
                // Рендер-разрешение канваса (с учётом DPR).
                const w = this.engine.getRenderWidth();
                const h = this.engine.getRenderHeight();
                Tools.CreateScreenshot(this.engine, this.camera,
                    { width: w, height: h }, (data) => {
                        if (!manualHide) this._restoreHelpers(helperState);
                        resolve({ dataUrl: data || '', width: w, height: h });
                    });
            } catch (e) {
                console.warn('[VoxelModelScene] captureCanvasSnapshot failed:', e);
                if (!manualHide) this._restoreHelpers(helperState);
                resolve({ dataUrl: '', width: 0, height: 0 });
            }
        });
    }

    /**
     * Вырезать квадратную область из снимка канваса и отмасштабировать
     * в outSize×outSize PNG. Координаты sx/sy/sSize — в пикселях исходного
     * снимка (того, что вернул captureCanvasSnapshot).
     *
     * @param {string} srcDataUrl — PNG data URL исходного снимка
     * @param {number} sx,sy — левый-верхний угол квадрата в пикселях снимка
     * @param {number} sSize — сторона квадрата в пикселях снимка
     * @param {number} outSize — сторона результата (по умолчанию 128)
     * @returns {Promise<string>} PNG data URL результата
     */
    static cropSquareThumbnail(srcDataUrl, sx, sy, sSize, outSize = 128) {
        return new Promise((resolve) => {
            if (!srcDataUrl) { resolve(''); return; }
            const img = new Image();
            img.onload = () => {
                try {
                    const canvas = document.createElement('canvas');
                    canvas.width = outSize;
                    canvas.height = outSize;
                    const ctx = canvas.getContext('2d');
                    // Клампим область в границы исходника.
                    let x = Math.max(0, Math.min(sx, img.width - 1));
                    let y = Math.max(0, Math.min(sy, img.height - 1));
                    let s = Math.max(1, Math.min(sSize, img.width - x, img.height - y));
                    ctx.imageSmoothingEnabled = true;
                    ctx.imageSmoothingQuality = 'high';
                    ctx.drawImage(img, x, y, s, s, 0, 0, outSize, outSize);
                    resolve(canvas.toDataURL('image/png'));
                } catch (e) {
                    console.warn('[VoxelModelScene] cropSquareThumbnail failed:', e);
                    resolve('');
                }
            };
            img.onerror = () => resolve('');
            img.src = srcDataUrl;
        });
    }

    _emit() {
        try { this._onChange?.(this.voxels.size); } catch (e) {}
    }

    // =============== Lifecycle ===============

    _startRender() {
        // Сразу подстраиваем размер канваса под CSS-размер (иначе картинка
        // растянутая, и pick'и попадают мимо — координаты воспринимаются
        // через canvas.width × canvas.height).
        try { this.engine.resize(); } catch (e) {}
        this.engine.runRenderLoop(() => {
            this._updateCameraFly();
            this.scene.render();
        });
        // resize-наблюдатель — следит за изменением CSS-размера canvas
        this._ro = new ResizeObserver(() => {
            try { this.engine.resize(); } catch (e) {}
        });
        try { this._ro.observe(this.canvas); } catch (e) {}
        // Ещё один resize через rAF — чтобы попасть после первого layout React'а.
        requestAnimationFrame(() => {
            try { this.engine.resize(); } catch (e) {}
        });
    }

    dispose() {
        try { this._ro?.disconnect(); } catch (e) {}
        this._detachInput();
        try { this.engine.stopRenderLoop(); } catch (e) {}
        try { this.scene.dispose(); } catch (e) {}
        try { this.engine.dispose(); } catch (e) {}
    }
}
