/**
 * RobloxTerrain — менеджер smooth-ландшафта в стиле Roblox.
 *
 * Параллельная подсистема к TerrainManager (legacy voxel). Они не пересекаются.
 *
 * Архитектура:
 *   - DensityGrid: Uint8Array(matId) + Uint8Array(density) хранилище, 4м/cell
 *   - Surface Nets mesher строит chunk-меши 16×16×16 ячеек (64×64×64 м)
 *   - При изменении density (brushes) затронутые chunks помечаются dirty
 *   - Render-loop перестраивает dirty chunks плавно (макс 4 за тик)
 *
 * Public API:
 *   loadFromGrid(grid)       — заменить grid + построить chunks вокруг камеры
 *   serialize()               — для save в БД
 *   loadFromState(obj)        — для load
 *   buildAround(camX, camZ)   — построить chunks в радиусе камеры
 *   updateStreaming(cx, cz)   — render-loop streaming
 *   flushDirty(maxN)          — перестроить dirty chunks
 *   getStats()                — debug
 */

import {
    Mesh, VertexData, StandardMaterial, ShaderMaterial, Effect, Color3, Texture, Vector3, BoundingInfo,
} from '@babylonjs/core';
import { DensityGrid, CELL_SIZE } from './DensityGrid';
import { buildSurfaceNetsMesh } from './SurfaceNetsMesher';
import { applyBrush } from './SmoothBrushes';
import { TERRAIN_MATERIALS } from '../TerrainManager';

/** Размер chunk'а в ячейках. 16×16×16 = 4096 cells = ~1-3K triangles. */
export const CHUNK_SIZE = 16;

export class RobloxTerrain {
    constructor(scene) {
        this.scene = scene;
        /** @type {DensityGrid|null} */
        this.grid = null;
        /** Map<"cx,cy,cz", Map<matKey, Mesh>> */
        this.chunks = new Map();
        /** Set<chunkKey> — to build (lazy) */
        this._pendingChunks = new Set();
        /** Set<chunkKey> — to rebuild after brush */
        this._dirtyChunks = new Set();
        /** Map<matKey, StandardMaterial> */
        this._materials = new Map();
        /**
         * Height cache для быстрой коллизии. Float32Array[gridSizeX * gridSizeZ].
         * heightAt(x, z) → world Y поверхности (или -Infinity если нет surface).
         * Заполняется при loadFromGrid через analytic density-traversal.
         * Это намного быстрее scene.pickWithRay по mesh-чанкам.
         */
        this._heightMap = null;
        this._heightMapSx = 0;
        this._heightMapSz = 0;
        this._heightMapOriginX = 0;   // world X of cell (0)
        this._heightMapOriginZ = 0;
        this._heightCellSize = 1;     // м/cell в height-map
    }

    /**
     * Заменить grid + поставить chunks как pending.
     * @param {object} options
     * @param {boolean} [options.skipEmpty=false] — если true, не добавлять
     *   chunk в pending если в нём НЕТ solid cells (density >= threshold).
     *   Используется при загрузке пустого grid для скульптинга с нуля —
     *   иначе 98 пустых chunks бы спамили mesher.
     */
    loadFromGrid(grid, options = {}) {
        this.disposeAll();
        this.grid = grid;
        const nx = Math.ceil(grid.size.x / CHUNK_SIZE);
        const ny = Math.ceil(grid.size.y / CHUNK_SIZE);
        const nz = Math.ceil(grid.size.z / CHUNK_SIZE);
        let pending = 0;
        for (let cx = 0; cx < nx; cx++) {
            for (let cy = 0; cy < ny; cy++) {
                for (let cz = 0; cz < nz; cz++) {
                    if (options.skipEmpty && !this._chunkHasSolidCells(grid, cx, cy, cz)) {
                        continue;
                    }
                    this._pendingChunks.add(`${cx},${cy},${cz}`);
                    pending++;
                }
            }
        }
        console.log(`[RobloxTerrain] loaded ${grid.size.x}×${grid.size.y}×${grid.size.z} grid, ${pending} pending chunks (of ${nx*ny*nz} total)`);
    }

    /** Быстрая проверка: есть ли хоть одна solid-cell в chunk'е (cx,cy,cz). */
    _chunkHasSolidCells(grid, cx, cy, cz) {
        const T = grid.matData; // shortcut
        // grid.matData — Uint8Array размером size.x*size.y*size.z
        // Проверяем mat-id != 0 (быстрее чем density compare)
        const x0 = cx * CHUNK_SIZE;
        const y0 = cy * CHUNK_SIZE;
        const z0 = cz * CHUNK_SIZE;
        const x1 = Math.min(x0 + CHUNK_SIZE, grid.size.x);
        const y1 = Math.min(y0 + CHUNK_SIZE, grid.size.y);
        const z1 = Math.min(z0 + CHUNK_SIZE, grid.size.z);
        const sx = grid.size.x;
        const sxy = sx * grid.size.y;
        for (let z = z0; z < z1; z++) {
            for (let y = y0; y < y1; y++) {
                for (let x = x0; x < x1; x++) {
                    if (T[z * sxy + y * sx + x] !== 0) return true;
                }
            }
        }
        return false;
    }

    /**
     * Заинициализировать heightmap нужного размера. Заполняется потом
     * из реальных mesh vertices в _buildChunk через _registerHeightFromMesh.
     */
    _buildHeightMap() {
        if (!this.grid) return;
        const g = this.grid;
        this._heightCellSize = 1;
        this._heightMapSx = g.size.x * CELL_SIZE;
        this._heightMapSz = g.size.z * CELL_SIZE;
        this._heightMapOriginX = g.origin.x * CELL_SIZE;
        this._heightMapOriginZ = g.origin.z * CELL_SIZE;
        const N = this._heightMapSx * this._heightMapSz;
        this._heightMap = new Float32Array(N);
        this._heightMap.fill(-Infinity);
    }

    /**
     * После построения mesh-chunk'а — заполняем heightmap из РЕАЛЬНЫХ
     * vertex Y координат. Гарантированно совпадает с тем что видит игрок.
     *
     * Для каждой vertex (x, y, z) находим ближайшую (hx, hz) ячейку
     * heightmap и обновляем её Y если новый Y выше (мы храним top surface).
     */
    _registerHeightFromMesh(positions) {
        if (!this._heightMap) return;
        const sx = this._heightMapSx;
        const sz = this._heightMapSz;
        const ox = this._heightMapOriginX;
        const oz = this._heightMapOriginZ;
        for (let i = 0; i < positions.length; i += 3) {
            const wx = positions[i];
            const wy = positions[i + 1];
            const wz = positions[i + 2];
            const hx = Math.floor(wx - ox);
            const hz = Math.floor(wz - oz);
            if (hx < 0 || hz < 0 || hx >= sx || hz >= sz) continue;
            const idx = hz * sx + hx;
            if (wy > this._heightMap[idx]) this._heightMap[idx] = wy;
        }
    }

    /**
     * Получить высоту поверхности под точкой (worldX, worldZ).
     * Билинейная интерполяция по heightmap для гладкости.
     * Возвращает -Infinity если точка вне карты.
     */
    getSurfaceHeight(worldX, worldZ) {
        if (!this._heightMap) return -Infinity;
        const fx = worldX - this._heightMapOriginX - 0.5;
        const fz = worldZ - this._heightMapOriginZ - 0.5;
        if (fx < 0 || fz < 0) return -Infinity;
        const hx0 = Math.floor(fx);
        const hz0 = Math.floor(fz);
        if (hx0 < 0 || hz0 < 0 || hx0 + 1 >= this._heightMapSx || hz0 + 1 >= this._heightMapSz) return -Infinity;
        const tx = fx - hx0;
        const tz = fz - hz0;
        const sx = this._heightMapSx;
        const h00 = this._heightMap[hz0 * sx + hx0];
        const h10 = this._heightMap[hz0 * sx + hx0 + 1];
        const h01 = this._heightMap[(hz0 + 1) * sx + hx0];
        const h11 = this._heightMap[(hz0 + 1) * sx + hx0 + 1];
        // Если хоть одно -Infinity (за пределами terrain) — возвращаем -Infinity
        if (h00 === -Infinity || h10 === -Infinity || h01 === -Infinity || h11 === -Infinity) {
            return -Infinity;
        }
        return (1 - tx) * (1 - tz) * h00
             + tx * (1 - tz) * h10
             + (1 - tx) * tz * h01
             + tx * tz * h11;
    }

    disposeAll() {
        if (!this.chunks) return;
        for (const [key, meshes] of this.chunks) {
            for (const m of meshes.values()) {
                try { m.dispose(); } catch (e) {}
            }
        }
        this.chunks.clear();
        this._pendingChunks.clear();
        this._dirtyChunks.clear();
        this.grid = null;
    }

    /** Построить один chunk. */
    _buildChunk(chunkKey) {
        if (!this.grid) return false;
        const [cx, cy, cz] = chunkKey.split(',').map(Number);
        const cx0 = cx * CHUNK_SIZE;
        const cy0 = cy * CHUNK_SIZE;
        const cz0 = cz * CHUNK_SIZE;
        const sizeX = Math.min(CHUNK_SIZE, this.grid.size.x - cx0);
        const sizeY = Math.min(CHUNK_SIZE, this.grid.size.y - cy0);
        const sizeZ = Math.min(CHUNK_SIZE, this.grid.size.z - cz0);
        if (sizeX <= 0 || sizeY <= 0 || sizeZ <= 0) return false;

        // Удалить старые меши этого chunk'а
        this._disposeChunk(chunkKey);

        const groups = buildSurfaceNetsMesh(this.grid, cx0, cy0, cz0, sizeX, sizeY, sizeZ);
        if (groups.size === 0) return false;

        const chunkMeshes = new Map();
        for (const [matKey, data] of groups) {
            const mesh = new Mesh(`__robloxMesh_${chunkKey}_${matKey}`, this.scene);
            const vd = new VertexData();
            vd.positions = data.positions;
            vd.normals = data.normals;
            vd.uvs = data.uvs;
            if (data.colors) vd.colors = data.colors;
            // 4 доп. материала через 2 vec2 attribute (uv2 + uv3):
            //   uvs2: (dirt, water) per vertex — Float32 длина = 2*verts
            //   uvs3: (wood, glacier) per vertex
            if (data.uvs2) vd.uvs2 = data.uvs2;
            if (data.uvs3) vd.uvs3 = data.uvs3;
            vd.indices = data.indices;
            vd.applyToMesh(mesh, false);
            // Если есть vertex colors → используем blend-shader.
            // Иначе — обычный StandardMaterial.
            if (data.colors) {
                mesh.material = this._getBlendMaterial();
            } else {
                mesh.material = this._getMaterial(matKey);
            }
            // Pickable нужно для коллизий через scene.pickWithRay (физика).
            mesh.isPickable = true;
            mesh.receiveShadows = true;
            mesh.alwaysSelectAsActiveMesh = false;
            mesh.metadata = {
                _isRobloxTerrain: true,
                chunkKey,
                chunkCenterX: (data.bbox.minX + data.bbox.maxX) * 0.5,
                chunkCenterZ: (data.bbox.minZ + data.bbox.maxZ) * 0.5,
                chunkHalfDiag: Math.sqrt(
                    Math.pow((data.bbox.maxX - data.bbox.minX) * 0.5, 2)
                    + Math.pow((data.bbox.maxZ - data.bbox.minZ) * 0.5, 2),
                ),
            };
            try {
                const pad = CELL_SIZE * 0.5;
                mesh.setBoundingInfo(new BoundingInfo(
                    new Vector3(data.bbox.minX - pad, data.bbox.minY - pad, data.bbox.minZ - pad),
                    new Vector3(data.bbox.maxX + pad, data.bbox.maxY + pad, data.bbox.maxZ + pad),
                ));
            } catch (e) {}
            try { mesh.freezeWorldMatrix(); } catch (e) {}
            chunkMeshes.set(matKey, mesh);
        }
        this.chunks.set(chunkKey, chunkMeshes);
        return true;
    }

    _disposeChunk(chunkKey) {
        const m = this.chunks.get(chunkKey);
        if (!m) return;
        for (const mesh of m.values()) {
            try { mesh.dispose(); } catch (e) {}
        }
        this.chunks.delete(chunkKey);
    }

    /** Перестроить N pending chunks ближайших к (camX, camZ). */
    updateStreaming(camX, camZ, radius = 100, options) {
        const maxBuild = options?.maxBuild ?? 4;
        if (!this.grid) return { built: 0, total: 0 };

        if (this._pendingChunks.size > 0) {
            const candidates = [];
            for (const key of this._pendingChunks) {
                const [cx, , cz] = key.split(',').map(Number);
                const ccx = (this.grid.origin.x + cx * CHUNK_SIZE + CHUNK_SIZE * 0.5) * CELL_SIZE;
                const ccz = (this.grid.origin.z + cz * CHUNK_SIZE + CHUNK_SIZE * 0.5) * CELL_SIZE;
                const dx = ccx - camX, dz = ccz - camZ;
                const d2 = dx * dx + dz * dz;
                if (d2 <= radius * radius) candidates.push({ key, d2 });
            }
            candidates.sort((a, b) => a.d2 - b.d2);
            const limit = Math.min(maxBuild, candidates.length);
            let built = 0;
            for (let i = 0; i < limit; i++) {
                this._pendingChunks.delete(candidates[i].key);
                if (this._buildChunk(candidates[i].key)) built++;
            }
            return {
                built,
                total: this.chunks.size + this._pendingChunks.size,
                pending: this._pendingChunks.size,
            };
        }
        return { built: 0, total: this.chunks.size, pending: 0 };
    }

    /** Перестроить dirty chunks (после brush). */
    flushDirty(maxRebuilds = 4) {
        if (this._dirtyChunks.size === 0) return 0;
        const keys = [...this._dirtyChunks];
        let count = 0;
        for (const key of keys) {
            if (count >= maxRebuilds) break;
            this._dirtyChunks.delete(key);
            this._buildChunk(key);
            count++;
        }
        return count;
    }

    /**
     * Пометить chunks как dirty. Вызывается из brush-логики.
     * @param {Iterable<string>} keys — ключи "cx,cy,cz" (LOCAL, как в applyBrush)
     */
    markChunksDirty(keys) {
        for (const k of keys) this._dirtyChunks.add(k);
    }

    /**
     * Применить brush на DensityGrid + сразу перестроить dirty chunks.
     * Удобно для интерактивных кистей (драг мышью).
     */
    applyBrushAndRebuild(brushType, params) {
        if (!this.grid) {
            console.warn('[RobloxTerrain] applyBrushAndRebuild: no grid');
            return 0;
        }
        const dirty = applyBrush(this.grid, brushType, params);
        if (dirty.size === 0) {
            // Кисть не затронула ни одной cell — диагностика.
            const sample = this.grid.getDensity(
                Math.floor(params.center.x / 4),
                Math.floor(params.center.y / 4),
                Math.floor(params.center.z / 4),
            );
            console.log(`[RobloxTerrain] brush='${brushType}' NO CELLS CHANGED. `
                + `center=(${params.center.x.toFixed(1)},${params.center.y.toFixed(1)},${params.center.z.toFixed(1)}) `
                + `r=${params.radius} grid-origin=(${this.grid.origin.x},${this.grid.origin.y},${this.grid.origin.z}) `
                + `grid-size=(${this.grid.size.x},${this.grid.size.y},${this.grid.size.z}) `
                + `density-at-center=${sample}`);
            return 0;
        }
        this.markChunksDirty(dirty);
        const built = this.flushDirty(dirty.size);
        return built;
    }

    /**
     * Blend-материал: **8 текстур** смешиваются через 3 vertex attribute.
     *   color (vec4):  R=grass, G=rock,  B=sand,  A=snow
     *   uv2 (vec2):    X=dirt,  Y=water
     *   uv3 (vec2):    X=wood,  Y=glacier
     *
     * Использовать `tangent` нельзя — Babylon пересчитывает его как tangent-frame
     * для normal-mapping (зависание на NaN если все нули). UV-attribute-ы
     * передаются "as-is" без преобразований.
     */
    _getBlendMaterial() {
        if (this._blendMaterial) return this._blendMaterial;
        const scene = this.scene;
        // Регистрируем shader code один раз
        if (!Effect.ShadersStore['robloxBlendVertexShader']) {
            Effect.ShadersStore['robloxBlendVertexShader'] = `
                precision highp float;
                attribute vec3 position;
                attribute vec3 normal;
                attribute vec2 uv;
                attribute vec2 uv2;
                attribute vec2 uv3;
                attribute vec4 color;
                uniform mat4 worldViewProjection;
                uniform mat4 world;
                uniform mat4 view;
                varying vec2 vUV;
                varying vec3 vNormalW;
                varying vec4 vColor;
                varying vec4 vExtra;   // dirt/water/wood/glacier weights
                varying vec3 vWorldPos;
                varying float vDepth;
                void main() {
                    vec4 wp = world * vec4(position, 1.0);
                    gl_Position = worldViewProjection * vec4(position, 1.0);
                    vUV = uv;
                    vNormalW = normalize((world * vec4(normal, 0.0)).xyz);
                    vColor = color;
                    // uv2.x=dirt, uv2.y=water, uv3.x=wood, uv3.y=glacier
                    vExtra = vec4(uv2.x, uv2.y, uv3.x, uv3.y);
                    vWorldPos = wp.xyz;
                    vDepth = -(view * wp).z;
                }
            `;
            // Фрагментный шейдер: 8-материал blend + Roblox-стиль освещение.
            Effect.ShadersStore['robloxBlendFragmentShader'] = `
                precision highp float;
                varying vec2 vUV;
                varying vec3 vNormalW;
                varying vec4 vColor;
                varying vec4 vExtra;
                varying vec3 vWorldPos;
                varying float vDepth;
                uniform sampler2D texGrass;
                uniform sampler2D texRock;
                uniform sampler2D texSand;
                uniform sampler2D texSnow;
                uniform sampler2D texDirt;
                uniform sampler2D texWater;
                uniform sampler2D texWood;
                uniform sampler2D texGlacier;
                uniform vec3 sunDir;
                uniform vec3 sunColor;
                uniform vec3 hemiSky;
                uniform vec3 hemiGround;
                uniform vec3 fogColor;
                uniform vec3 cameraPos;

                vec3 sample2(sampler2D t, vec2 uvBig, vec2 uvSmall) {
                    vec3 a = texture2D(t, uvBig).rgb;
                    vec3 b = texture2D(t, uvSmall).rgb;
                    return a * 0.6 + b * 0.4;
                }

                void main() {
                    vec2 uvBig = vWorldPos.xz * 0.25;
                    vec2 uvSmall = vWorldPos.xz * 0.07;

                    // Первая четвёрка (vColor)
                    vec3 cg = sample2(texGrass, uvBig, uvSmall);
                    vec3 cr = sample2(texRock,  uvBig, uvSmall);
                    vec3 cs = sample2(texSand,  uvBig, uvSmall);
                    vec3 cn = sample2(texSnow,  uvBig, uvSmall);
                    // Вторая четвёрка (vTangent)
                    vec3 cd = sample2(texDirt,    uvBig, uvSmall);
                    vec3 cw = sample2(texWater,   uvBig, uvSmall);
                    vec3 cwo= sample2(texWood,    uvBig, uvSmall);
                    vec3 cgl= sample2(texGlacier, uvBig, uvSmall);

                    float wG = vColor.r;
                    float wR = vColor.g;
                    float wS = vColor.b;
                    float wN = vColor.a;
                    float wD = vExtra.x;
                    float wW = vExtra.y;
                    float wWO= vExtra.z;
                    float wGL= vExtra.w;

                    float wSum = wG + wR + wS + wN + wD + wW + wWO + wGL + 0.0001;
                    vec3 base = (cg*wG + cr*wR + cs*wS + cn*wN
                              + cd*wD + cw*wW + cwo*wWO + cgl*wGL) / wSum;

                    // Снег приглушаем в лёгкую синеву
                    base *= mix(vec3(1.0), vec3(0.85, 0.88, 0.94), wN);
                    // Вода — тёмно-синяя альфа-микс (без real transparency пока)
                    base *= mix(vec3(1.0), vec3(0.55, 0.75, 0.95), wW);
                    // Glacier — голубоватая прозрачность
                    base *= mix(vec3(1.0), vec3(0.78, 0.90, 0.98), wGL);

                    // === Освещение ===
                    vec3 N = normalize(vNormalW);
                    vec3 L = -normalize(sunDir);
                    float ndl = max(dot(N, L), 0.0);
                    vec3 hemi = mix(hemiGround, hemiSky, N.y * 0.5 + 0.5);
                    vec3 lit = base * (sunColor * ndl + hemi);

                    float slopeDarken = mix(0.85, 1.0, max(N.y, 0.0));
                    lit *= slopeDarken;

                    vec3 V = normalize(cameraPos - vWorldPos);
                    float rim = pow(1.0 - max(dot(N, V), 0.0), 3.0);
                    lit += vec3(0.18, 0.22, 0.14) * rim * wG * 0.4;

                    float fogF = smoothstep(80.0, 350.0, vDepth);
                    lit = mix(lit, fogColor, fogF * 0.5);

                    gl_FragColor = vec4(lit, 1.0);
                }
            `;
        }
        const mat = new ShaderMaterial(
            '__robloxBlendMat',
            scene,
            { vertex: 'robloxBlend', fragment: 'robloxBlend' },
            {
                attributes: ['position', 'normal', 'uv', 'uv2', 'uv3', 'color'],
                uniforms: ['worldViewProjection', 'world', 'view',
                    'sunDir', 'sunColor', 'hemiSky', 'hemiGround',
                    'fogColor', 'cameraPos'],
                samplers: ['texGrass', 'texRock', 'texSand', 'texSnow',
                    'texDirt', 'texWater', 'texWood', 'texGlacier'],
            },
        );
        // Текстуры. BILINEAR без mipmaps — пиксельные 16×16 текстуры
        // ломаются в trilinear (mip-уровни усредняются до 1 пикселя серого).
        const mkTex = (path) => {
            const t = new Texture(path, scene, true, false, Texture.BILINEAR_SAMPLINGMODE);
            t.wrapU = Texture.WRAP_ADDRESSMODE;
            t.wrapV = Texture.WRAP_ADDRESSMODE;
            return t;
        };
        const grassDef = TERRAIN_MATERIALS.grass;
        const rockDef = TERRAIN_MATERIALS.rock;
        const sandDef = TERRAIN_MATERIALS.sand;
        const snowDef = TERRAIN_MATERIALS.snow;
        const dirtDef = TERRAIN_MATERIALS.dirt;
        const waterDef = TERRAIN_MATERIALS.water;
        const woodDef = TERRAIN_MATERIALS.wood;
        const glacierDef = TERRAIN_MATERIALS.glacier;
        mat.setTexture('texGrass', mkTex(grassDef.top || grassDef.texture));
        mat.setTexture('texRock', mkTex(rockDef.texture));
        mat.setTexture('texSand', mkTex(sandDef.texture));
        mat.setTexture('texSnow', mkTex(snowDef.texture));
        mat.setTexture('texDirt', mkTex(dirtDef.texture));
        mat.setTexture('texWater', mkTex(waterDef.texture));
        mat.setTexture('texWood', mkTex(woodDef.texture));
        mat.setTexture('texGlacier', mkTex(glacierDef.texture));
        // Lighting params: суммарно НЕ ДОЛЖНО превышать 1.0 чтобы не было
        // overexposure (выгорание ярких текстур типа sand в белое).
        //   directLight (max) = sunColor × 1.0 (ndl)
        //   ambient = hemi (mix sky/ground)
        //   total на верхушке = (sun + sky) ≤ ~(0.95, 1.0, 0.93)
        mat.setVector3('sunDir', new Vector3(-0.3, -1, -0.2).normalize());
        mat.setVector3('sunColor', new Vector3(0.55, 0.52, 0.45));
        mat.setVector3('hemiSky', new Vector3(0.42, 0.48, 0.58));
        mat.setVector3('hemiGround', new Vector3(0.24, 0.25, 0.27));
        // Fog цвет = небо, дистанция 80..350м (плавный переход).
        mat.setVector3('fogColor', new Vector3(0.62, 0.78, 0.95));
        mat.setVector3('cameraPos', new Vector3(0, 0, 0));
        mat.backFaceCulling = false;
        // cameraPos нужно обновлять каждый кадр для rim-эффекта.
        const cam = scene.activeCamera;
        if (cam) {
            mat.onBindObservable.add(() => {
                const c = scene.activeCamera;
                if (c && c.position) {
                    mat.setVector3('cameraPos', c.position);
                }
            });
        }
        this._blendMaterial = mat;
        return mat;
    }

    /** Получить (создать) StandardMaterial для matKey. */
    _getMaterial(matKey) {
        let mat = this._materials.get(matKey);
        if (mat) return mat;
        const def = TERRAIN_MATERIALS[matKey];
        mat = new StandardMaterial(`__robloxMat_${matKey}`, this.scene);
        mat.specularColor = new Color3(0, 0, 0);
        mat.ambientColor = new Color3(1, 1, 1);
        // backFaceCulling=false временно — Surface Nets winding не вылизан.
        // Когда стабилизируем, включим обратно.
        mat.backFaceCulling = false;
        if (def) {
            const texPath = def.top || def.texture || def.side;
            if (texPath) {
                const tex = new Texture(texPath, this.scene, true, false, Texture.NEAREST_SAMPLINGMODE);
                tex.wrapU = Texture.WRAP_ADDRESSMODE;
                tex.wrapV = Texture.WRAP_ADDRESSMODE;
                mat.diffuseTexture = tex;
            } else if (def.color) {
                mat.diffuseColor = Color3.FromHexString(def.color);
            }
        } else {
            mat.diffuseColor = new Color3(0.4, 0.7, 0.3);
        }
        try { mat.freeze(); } catch (e) {}
        this._materials.set(matKey, mat);
        return mat;
    }

    // ============================================================
    // BRUSH API (минимальный для теста; полный — Этап 3)
    // ============================================================

    /**
     * Поставить density+matKey в сфере radius=R вокруг точки world (wx, wy, wz).
     * @param {number} delta — изменение density (>0 add, <0 erase). Применяется
     *                          плавно по расстоянию от центра.
     */
    brushSphere(wx, wy, wz, radius, delta, matKey) {
        if (!this.grid) return;
        const r = radius / CELL_SIZE;   // в cell-units
        const cx = wx / CELL_SIZE - this.grid.origin.x;
        const cy = wy / CELL_SIZE - this.grid.origin.y;
        const cz = wz / CELL_SIZE - this.grid.origin.z;
        const ri = Math.ceil(r);
        const r2 = r * r;
        for (let dz = -ri; dz <= ri; dz++) {
            for (let dy = -ri; dy <= ri; dy++) {
                for (let dx = -ri; dx <= ri; dx++) {
                    const d2 = dx * dx + dy * dy + dz * dz;
                    if (d2 > r2) continue;
                    const ix = Math.floor(cx + dx);
                    const iy = Math.floor(cy + dy);
                    const iz = Math.floor(cz + dz);
                    if (!this.grid.inBounds(ix, iy, iz)) continue;
                    // Falloff: 1 в центре, 0 на краях
                    const t = 1.0 - Math.sqrt(d2) / r;
                    const change = (delta * t) | 0;
                    const cur = this.grid.getDensity(ix, iy, iz);
                    const newD = Math.max(0, Math.min(255, cur + change));
                    this.grid.set(ix, iy, iz, newD, matKey);
                    // Пометить chunk и соседей dirty
                    this._markDirty(ix, iy, iz);
                }
            }
        }
    }

    _markDirty(ix, iy, iz) {
        const cx = Math.floor(ix / CHUNK_SIZE);
        const cy = Math.floor(iy / CHUNK_SIZE);
        const cz = Math.floor(iz / CHUNK_SIZE);
        this._dirtyChunks.add(`${cx},${cy},${cz}`);
        // Если на границе — пометить соседа
        const lx = ix - cx * CHUNK_SIZE;
        const ly = iy - cy * CHUNK_SIZE;
        const lz = iz - cz * CHUNK_SIZE;
        if (lx === 0) this._dirtyChunks.add(`${cx-1},${cy},${cz}`);
        if (lx === CHUNK_SIZE - 1) this._dirtyChunks.add(`${cx+1},${cy},${cz}`);
        if (ly === 0) this._dirtyChunks.add(`${cx},${cy-1},${cz}`);
        if (ly === CHUNK_SIZE - 1) this._dirtyChunks.add(`${cx},${cy+1},${cz}`);
        if (lz === 0) this._dirtyChunks.add(`${cx},${cy},${cz-1}`);
        if (lz === CHUNK_SIZE - 1) this._dirtyChunks.add(`${cx},${cy},${cz+1}`);
    }

    // ============================================================
    // STATS / SERIALIZE
    // ============================================================

    getStats() {
        let totalTris = 0;
        for (const meshes of this.chunks.values()) {
            for (const m of meshes.values()) {
                if (!m.isEnabled()) continue;
                totalTris += (m.getTotalIndices?.() ?? 0) / 3;
            }
        }
        return {
            chunks: this.chunks.size,
            pending: this._pendingChunks.size,
            dirty: this._dirtyChunks.size,
            triangles: totalTris | 0,
            solidCells: this.grid ? this.grid.countSolid() : 0,
        };
    }

    serialize() {
        if (!this.grid) return null;
        return this.grid.serialize();
    }

    loadFromState(obj) {
        if (!obj) return;
        const grid = DensityGrid.deserialize(obj);
        this.loadFromGrid(grid);
    }
}
