/**
 * WorldGenerator — процедурная генерация мира по параметрам.
 *
 * Использует SimplexNoise для heightmap, biome map, density. Структуры
 * (деревья, скалы) расставляются детерминистически по координатам.
 *
 * Архитектура:
 *   - WorldGenerator(params) — создаёт инстанс с фиксированными параметрами
 *   - generateColumn(x, z) — высокоуровневая: возвращает данные одного
 *     столбца (массив voxel'ов с Y)
 *   - generateIntoLayer(layer, bbox) — основная: заполняет slice мира
 *     в указанном bbox (нужно для редактора)
 *   - sampleHeight(x, z) — только высота земли (для preview canvas)
 *   - sampleBiome(x, z) — только биом (для preview)
 *
 * Параметры (GeneratorParams):
 *   {
 *     seed: number,                  // глобальный seed
 *     worldSize: 'infinite' | { width, height },
 *     heightmap: {
 *         scale, octaves, persistence, lacunarity,
 *         amplitude,         // макс высота в метрах
 *         baseHeight,        // средний уровень
 *         exponent           // >1 = плоские долины + резкие пики
 *     },
 *     domainWarp: { enabled, scale, strength },
 *     biomes: [ {id, threshold:[lo,hi], topMaterial, softMaterial, hardMaterial,
 *                features: {trees, treeTypes, bushes, flowers, mushrooms, grass}} ],
 *     biomeMap: { scale, seed },
 *     structures: { trees: {enabled, density, minDistance}, ... },
 *     decorations: { grass: ..., flowers: ..., mushrooms: ... },
 *   }
 */

import { SimplexNoise } from './SimplexNoise';

// Дефолтные параметры — Voxlands-стиль земного мира.
export const DEFAULT_GENERATOR_PARAMS = {
    seed: 1337,
    worldSize: { width: 320, height: 320 },   // в voxel-units (160м × 0.25)
    heightmap: {
        scale: 0.012,       // плавнее (меньше частота → крупнее холмы)
        octaves: 3,         // меньше деталей — плавнее поверхность
        persistence: 0.45,
        lacunarity: 2.0,
        amplitude: 8,       // макс ~8 voxel = 2м. Уютные пологие холмы
        baseHeight: 1,
        exponent: 1.8,      // высокая экспонента → большая часть карты плоская
    },
    domainWarp: {
        enabled: false,
        scale: 0.05,
        strength: 5,
    },
    biomeMap: {
        scale: 0.005,        // крупные биомы (плавные переходы на больших картах)
        seedOffset: 100,
    },
    // Дефолтное распределение биомов: 80% травы (plain+forest), 10% песка,
    // 10% гор. Без снега по умолчанию — слишком серо для теста.
    biomes: [
        {
            id: 'desert',
            threshold: [0.0, 0.1],     // 10%
            topMaterial: 'sand',
            softMaterial: 'sand',
            hardMaterial: 'sand',
            features: { trees: 0.01, treeTypes: ['oak'], grass: 0 },
        },
        {
            id: 'plain',
            threshold: [0.1, 0.55],    // 45%
            topMaterial: 'grass',
            softMaterial: 'dirt',
            hardMaterial: 'rock',
            features: { trees: 0.3, treeTypes: ['oak'], grass: 0.2 },
        },
        {
            id: 'forest',
            threshold: [0.55, 0.85],   // 30%
            topMaterial: 'grass',
            softMaterial: 'dirt',
            hardMaterial: 'rock',
            features: { trees: 0.8, treeTypes: ['oak', 'autumn'], grass: 0.4 },
        },
        {
            id: 'mountain',
            threshold: [0.85, 1.0],    // 15% — горы в крайних местах карты
            topMaterial: 'rock',
            softMaterial: 'rock',
            hardMaterial: 'rock',
            heightBonus: 1.4,
            features: { trees: 0.1, treeTypes: ['autumn'] },
        },
    ],
    structures: {
        trees: { enabled: true, density: 0.3, minDistance: 8, sizeScale: 1.0 },
        rocks: { enabled: false, density: 0.1 },
        lakes: { enabled: true, threshold: -0.2 },
        // Декорации — Этап 6 voxel-движка. Цветы/грибы и трава имеют РАЗНУЮ
        // частоту: цветы — заметные, редкие; трава — частая россыпь.
        // PERFORMANCE: понижено grass с 15% до 8% — это ×2 меньше декораций.
        // При radius=25м для LOD это даст ~50К deco voxel'ов вместо ~100К.
        decorations: {
            enabled: true,
            flowersDensity: 0.015,   // 1.5% — цветы и грибы
            grassDensity: 0.05,      // 5% — пучки травы (было 8%, ещё снижено для FPS)
        },
    },
    decorations: {
        // Пока не используется до Этапа 6 (deco-слой 0.05м)
        grass: { enabled: false, density: 0.1 },
        flowers: { enabled: false, density: 0.04, colors: ['flower_red', 'flower_blue', 'flower_yellow'] },
        mushrooms: { enabled: false, density: 0.02 },
    },
};

/**
 * Простая хэш-функция: детерминированный псевдослучайный float [0..1)
 * по 2D-координатам и seed. Используется для расставления структур
 * (деревьев) детерминистически.
 */
function hash2(x, y, seed) {
    let h = (x * 374761393 + y * 668265263 + seed * 1442695040) | 0;
    h = Math.imul(h ^ (h >>> 13), 1274126177);
    return ((h ^ (h >>> 16)) >>> 0) / 4294967296;
}

export class WorldGenerator {
    /**
     * @param {Object} params - GeneratorParams (использует DEFAULT при отсутствии)
     */
    constructor(params = {}) {
        this.params = Object.assign({}, DEFAULT_GENERATOR_PARAMS, params);
        // Глубокое клонирование подобъектов чтобы не мутировать дефолты
        this.params.heightmap = { ...DEFAULT_GENERATOR_PARAMS.heightmap, ...(params.heightmap ?? {}) };
        this.params.domainWarp = { ...DEFAULT_GENERATOR_PARAMS.domainWarp, ...(params.domainWarp ?? {}) };
        this.params.biomeMap = { ...DEFAULT_GENERATOR_PARAMS.biomeMap, ...(params.biomeMap ?? {}) };
        this.params.biomes = params.biomes ?? DEFAULT_GENERATOR_PARAMS.biomes;
        this.params.structures = { ...DEFAULT_GENERATOR_PARAMS.structures, ...(params.structures ?? {}) };

        // Создаём 2 независимых noise: один для heightmap, второй для biomes
        this.heightNoise = new SimplexNoise(this.params.seed);
        this.biomeNoise = new SimplexNoise(this.params.seed + this.params.biomeMap.seedOffset);
    }

    /**
     * Получить «сырую» высоту в нормализованном [-1..1].
     * Применяется domain warping если включён.
     */
    rawHeight(x, z) {
        const hp = this.params.heightmap;
        let wx = x, wz = z;
        if (this.params.domainWarp.enabled) {
            const ws = this.params.domainWarp.scale;
            const wstr = this.params.domainWarp.strength;
            wx = x + this.heightNoise.noise2(x * ws, z * ws) * wstr;
            wz = z + this.heightNoise.noise2(x * ws + 100, z * ws + 100) * wstr;
        }
        const n = this.heightNoise.fbm2(
            wx * hp.scale,
            wz * hp.scale,
            hp.octaves, hp.persistence, hp.lacunarity,
        );
        return n;   // [-1, +1] примерно
    }

    /**
     * Получить итоговую высоту земли в Y-voxel-units (целое).
     *
     * Использует SMOOTH biome blending: heightBonus интерполируется между
     * соседними биомами в transition zone (зона BIOME_BLEND_WIDTH вокруг
     * threshold). Без этого карта имеет вертикальные обрывы на границах
     * биом-зон (например trees-биом с heightBonus=1 → mountain с
     * heightBonus=1.8 даёт ступеньку 15+ voxel'ов).
     */
    sampleHeight(x, z) {
        const hp = this.params.heightmap;
        let n = this.rawHeight(x, z);
        n = (n + 1) * 0.5;
        n = Math.pow(Math.max(0, Math.min(1, n)), hp.exponent);

        // SMOOTH biome bonus: вычисляем bonus как взвешенную сумму
        // соседних биомов по biome-noise значению.
        const bonus = this._smoothBiomeBonus(x, z);
        const h = Math.round(hp.baseHeight + n * hp.amplitude * bonus);
        return h;
    }

    /**
     * Гладкая интерполяция heightBonus между биомами.
     * Берём biome-noise в (x,z) и считаем взвешенный bonus двух ближайших
     * биомов (того кому принадлежит точка + соседнего по threshold).
     */
    _smoothBiomeBonus(x, z) {
        const BLEND_WIDTH = 0.08;   // зона смешивания (в biome-noise space)
        const bm = this.params.biomeMap;
        const n = (this.biomeNoise.fbm2(x * bm.scale, z * bm.scale, 3, 0.5, 2.0) + 1) * 0.5;
        const biomes = this.params.biomes;

        // Найти текущий и соседний биом
        let currentIdx = -1;
        for (let i = 0; i < biomes.length; i++) {
            const b = biomes[i];
            if (n >= b.threshold[0] && n < b.threshold[1]) { currentIdx = i; break; }
        }
        if (currentIdx === -1) currentIdx = biomes.length - 1;

        const cur = biomes[currentIdx];
        const curBonus = cur.heightBonus ?? 1;
        const t0 = cur.threshold[0];
        const t1 = cur.threshold[1];

        // Близко ли к началу (граница с предыдущим биомом)?
        const distFromStart = n - t0;
        // Близко ли к концу (граница со следующим)?
        const distFromEnd = t1 - n;

        if (distFromStart < BLEND_WIDTH && currentIdx > 0) {
            // Близко к нижней границе — смешиваем с предыдущим
            const prev = biomes[currentIdx - 1];
            const prevBonus = prev.heightBonus ?? 1;
            // t: 0 на границе, 1 при distFromStart=BLEND_WIDTH
            const t = distFromStart / BLEND_WIDTH;
            // smoothstep для плавности
            const ts = t * t * (3 - 2 * t);
            return prevBonus * (1 - ts) + curBonus * ts;
        }
        if (distFromEnd < BLEND_WIDTH && currentIdx < biomes.length - 1) {
            // Близко к верхней границе — смешиваем со следующим
            const next = biomes[currentIdx + 1];
            const nextBonus = next.heightBonus ?? 1;
            const t = distFromEnd / BLEND_WIDTH;
            const ts = t * t * (3 - 2 * t);
            return nextBonus * (1 - ts) + curBonus * ts;
        }
        // Внутри биома — без интерполяции
        return curBonus;
    }

    /**
     * Какой биом в координате (x,z) — возвращает объект из biomes[].
     */
    sampleBiome(x, z) {
        const bm = this.params.biomeMap;
        // Используем второй noise с диапазоном [0..1]
        const n = (this.biomeNoise.fbm2(x * bm.scale, z * bm.scale, 3, 0.5, 2.0) + 1) * 0.5;
        for (const b of this.params.biomes) {
            if (n >= b.threshold[0] && n < b.threshold[1]) return b;
        }
        // fallback к последнему
        return this.params.biomes[this.params.biomes.length - 1];
    }

    /**
     * Будет ли тут стоять дерево? Детерминистически по (x,z) и параметрам.
     * Возвращает {type} или null.
     */
    sampleTreeAt(x, z) {
        const s = this.params.structures.trees;
        if (!s || !s.enabled) return null;
        const biome = this.sampleBiome(x, z);
        const treeProb = (biome.features?.trees ?? 0) * s.density;
        if (treeProb <= 0) return null;
        // density поделена на minDistance² чтобы плотность была интуитивной
        const grid = Math.max(2, s.minDistance);
        const gx = Math.floor(x / grid);
        const gz = Math.floor(z / grid);
        // Проверяем что в этой grid-ячейке стоит дерево
        const r = hash2(gx, gz, this.params.seed + 999);
        if (r >= treeProb) return null;
        // Точная позиция дерева внутри grid-ячейки (детерминистично)
        const ox = Math.floor(hash2(gx, gz, this.params.seed + 1001) * grid);
        const oz = Math.floor(hash2(gx, gz, this.params.seed + 1002) * grid);
        if (gx * grid + ox !== x || gz * grid + oz !== z) return null;
        // Выбираем тип дерева
        const treeTypes = biome.features?.treeTypes ?? ['oak'];
        const tt = Math.floor(hash2(gx, gz, this.params.seed + 1003) * treeTypes.length);
        return { type: treeTypes[tt % treeTypes.length] };
    }

    /**
     * Сгенерировать voxel-данные в указанный bbox (в voxel-units).
     * Заполняет уже существующий VoxelLayer.
     *
     * Использует SURFACE EXTRACTION (как в v5 Python-генератор): сохраняем
     * только voxel'ы, которые игрок может УВИДЕТЬ:
     *   - верхний слой (topMaterial) — h-1
     *   - 2 soft слоя под ним — h-2, h-3
     *   - hard на обрывах: y между min(neighbors) и h-3
     *
     * Внутренние массивы (глубокий камень) пропускаются — игрок их никогда
     * не увидит, а surface culling всё равно скроет грани. Это даёт
     * 5-10× меньше voxel'ов чем заполнение каждого столбца сплошняком.
     *
     * Без этой оптимизации карта 160×160м даёт 11М voxel'ов вместо ~1.5М.
     *
     * @param {VoxelLayer} layer - целевой слой terrain
     * @param {Object} bbox - {x0, z0, x1, z1} в voxel-units (целые)
     * @param {Function} [progressCb] - опциональный (done, total) callback
     * @returns {Object} stats
     */
    async generateIntoLayer(layer, bbox, progressCb = null) {
        const t0 = (typeof performance !== 'undefined' ? performance.now() : Date.now());
        const { x0, z0, x1, z1 } = bbox;
        const width = x1 - x0;
        const depth = z1 - z0;
        // 4 фазы: heightmap (1×), surface extraction (1×), trees (~0.3×).
        // Считаем total с весом фаз чтобы прогресс линейный.
        const totalCells = width * depth;
        const totalWeighted = totalCells * 2 + totalCells * 0.3;
        let done = 0;
        let stats = { columnsGenerated: 0, treesPlaced: 0 };

        // Yield (отдать управление браузеру) каждые ~30мс чтобы UI не висел.
        let lastYield = t0;
        const yieldToBrowser = async () => {
            const now = (typeof performance !== 'undefined' ? performance.now() : Date.now());
            if (now - lastYield > 30) {
                await new Promise(r => setTimeout(r, 0));
                lastYield = (typeof performance !== 'undefined' ? performance.now() : Date.now());
            }
        };

        // Шаг 1: heightmap. Самая дорогая фаза на больших картах
        // (3 noise-вызова на клетку × октавы).
        const heightmap = new Map();
        for (let z = z0; z < z1; z++) {
            for (let x = x0; x < x1; x++) {
                const h = this.sampleHeight(x, z);
                const biome = this.sampleBiome(x, z);
                heightmap.set(`${x},${z}`, { h, biome });
            }
            done += width;
            if ((z - z0) % 16 === 0) {
                if (progressCb) progressCb(done, totalWeighted, 'heightmap');
                await yieldToBrowser();
            }
        }

        // Шаг 2: surface extraction. Сохраняем только видимые voxel'ы.
        for (let z = z0; z < z1; z++) {
            for (let x = x0; x < x1; x++) {
                const info = heightmap.get(`${x},${z}`);
                const h = info.h;
                const biome = info.biome;

                // Соседние высоты (за пределами bbox → 0 = пустота, видимый обрыв)
                const nb = [];
                for (const [dx, dz] of [[-1, 0], [1, 0], [0, -1], [0, 1]]) {
                    const nbInfo = heightmap.get(`${x + dx},${z + dz}`);
                    nb.push(nbInfo ? nbInfo.h : 0);
                }
                const minNbH = Math.min(...nb);

                if (h <= 0) {
                    // Озеро / болото — только 1 voxel внизу
                    layer.setVoxel(x, 0, z, biome.softMaterial);
                    done++;
                    continue;
                }

                // Верхний — topMaterial (h-1)
                layer.setVoxel(x, h - 1, z, biome.topMaterial);

                // 2 soft слоя под верхом — только если они выше или равно
                // minNbH (видны на боковых стенах обрыва). Если в массиве —
                // невидимо, пропускаем.
                if (h >= 2 && (h - 2) >= minNbH) {
                    layer.setVoxel(x, h - 2, z, biome.softMaterial);
                }
                if (h >= 3 && (h - 3) >= minNbH) {
                    layer.setVoxel(x, h - 3, z, biome.softMaterial);
                }

                // Hard на обрывах: y между minNbH и h-3.
                // Если y < minNbH — соседний столбец той же высоты или выше
                // → voxel невидим, пропускаем (главная оптимизация surface
                // extraction, без неё карта в 10-20× больше).
                // Если y >= h-3 — это уже soft-слой, не пишем сюда hard.
                const bottomVisible = Math.max(0, minNbH);
                const topVisible = h - 3;   // exclusive
                for (let y = bottomVisible; y < topVisible; y++) {
                    layer.setVoxel(x, y, z, biome.hardMaterial);
                }
                // Гарантируем что Y=0 заполнено если столбец возвышается над пустотой
                // (нужно чтобы был «пол» под краями карты).
                if (minNbH <= 0 && !layer.getVoxel(x, 0, z)) {
                    layer.setVoxel(x, 0, z, biome.hardMaterial);
                }
                done++;
            }
            stats.columnsGenerated += width;
            if ((z - z0) % 16 === 0) {
                if (progressCb) progressCb(done, totalWeighted, 'surface');
                await yieldToBrowser();
            }
        }

        // Шаг 3: деревья (используем кешированную heightmap)
        if (this.params.structures.trees.enabled) {
            for (let z = z0; z < z1; z++) {
                for (let x = x0; x < x1; x++) {
                    const tree = this.sampleTreeAt(x, z);
                    if (!tree) continue;
                    const info = heightmap.get(`${x},${z}`);
                    if (!info || info.h < 1) continue;
                    if (info.biome.topMaterial !== 'grass') continue;
                    this._placeTree(layer, x, info.h - 1, z, tree.type);
                    stats.treesPlaced++;
                }
                done += width * 0.3;
                if ((z - z0) % 32 === 0) {
                    if (progressCb) progressCb(done, totalWeighted, 'trees');
                    await yieldToBrowser();
                }
            }
        }

        // Шаг 4: deco — мини-воксельные цветы/грибы/трава по биомам.
        // Используются ДВА порога:
        //   flowersDensity — цветы и грибы (редкие, заметные ~1-3%)
        //   grassDensity — пучки травы (частые ~10-20%)
        // Это разделение даёт разный визуальный эффект: цветы как акценты,
        // трава как фоновая россыпь.
        const decoParams = this.params.structures.decorations ?? {};
        const flowersDensity = decoParams.flowersDensity ?? 0.015;
        const grassDensity = decoParams.grassDensity ?? 0.15;
        stats.decorations = [];
        if ((flowersDensity > 0 || grassDensity > 0) && decoParams.enabled !== false) {
            // Раздельные пулы — цветы и трава отдельно
            const FLOWERS_BY_BIOME = {
                grass:    ['daisy', 'cornflower', 'poppy', 'dandelion'],
                forest:   ['daisy', 'fly_mushroom', 'brown_mushroom', 'fly_mushroom'],
                plain:    ['dandelion', 'cornflower', 'daisy'],
            };
            // Пул моделей травы с весами (одинаковые повторы = чаще).
            // Дублирует GRASS_MODELS_POOL из DecoModels.js — здесь захардкожен
            // чтобы не импортировать UI-зависимости.
            const GRASS_POOL = [
                'grass_short', 'grass_short', 'grass_short', 'grass_short',
                'grass_tuft', 'grass_tuft', 'grass_tuft',
                'grass_blade', 'grass_blade',
                'grass_wide', 'grass_wide',
                'grass_tall', 'grass_clump', 'grass_dry',
            ];

            for (let z = z0; z < z1; z++) {
                for (let x = x0; x < x1; x++) {
                    const info = heightmap.get(`${x},${z}`);
                    if (!info || info.h < 1) continue;
                    if (info.biome.topMaterial !== 'grass') continue;
                    const topY = info.h - 1;
                    const aboveMat = layer.getVoxel(x, topY + 1, z);
                    if (aboveMat) continue;
                    // Мировые координаты
                    const wx = (x + 0.5) * 0.25;
                    const wy = info.h * 0.25;
                    const wz = (z + 0.5) * 0.25;

                    // Сначала пробуем цветок (приоритет — он более заметный).
                    const rFlower = hash2(x, z, this.params.seed + 5001);
                    if (rFlower < flowersDensity) {
                        const biomeId = info.biome.id;
                        const pool = FLOWERS_BY_BIOME[biomeId] || FLOWERS_BY_BIOME.plain;
                        if (pool && pool.length > 0) {
                            const modelIdx = Math.floor(hash2(x, z, this.params.seed + 5002) * pool.length);
                            const modelId = pool[modelIdx];
                            const rotation = Math.floor(hash2(x, z, this.params.seed + 5003) * 4);
                            stats.decorations.push({ x: wx, y: wy, z: wz, modelId, rotation });
                            continue;   // не ставим траву поверх цветка
                        }
                    }
                    // Иначе — пробуем траву.
                    // Случайно выбираем модель из пула (5-7 разных)
                    // + случайный scale 0.7-1.4 для разнообразия размеров.
                    const rGrass = hash2(x, z, this.params.seed + 5004);
                    if (rGrass < grassDensity) {
                        const modelIdx = Math.floor(hash2(x, z, this.params.seed + 5005) * GRASS_POOL.length);
                        const modelId = GRASS_POOL[modelIdx];
                        const rotation = Math.floor(hash2(x, z, this.params.seed + 5006) * 4);
                        // Scale 0.7..1.4 — каждый кустик чуть другого размера
                        const scale = 0.7 + hash2(x, z, this.params.seed + 5007) * 0.7;
                        stats.decorations.push({ x: wx, y: wy, z: wz, modelId, rotation, scale });
                    }
                }
                if ((z - z0) % 32 === 0) await yieldToBrowser();
            }
        }

        const dt = (typeof performance !== 'undefined' ? performance.now() : Date.now()) - t0;
        stats.timeMs = Math.round(dt);
        if (progressCb) progressCb(totalWeighted, totalWeighted, 'done');
        return stats;
    }

    /**
     * Поставить voxel-дерево в указанной точке.
     * Тип: 'oak' | 'birch' | 'autumn'.
     *
     * Архитектура:
     *   1. Ствол — основной столб + утолщение у основания (для oak/autumn)
     *      + ветви, отходящие в стороны на 60-80% высоты
     *   2. Крона — пятнистая, mix двух материалов (leaves + leaves_orange)
     *      в разных пропорциях для типа дерева. Шум определяет какой
     *      voxel какого цвета — даёт «пятна» как на Roblox-скринах.
     *   3. Для крупных деревьев — несколько шаров кроны на ветвях.
     *
     * Палитра по типам:
     *   oak    — trunk + leaves (mix 5% leaves_orange = редкие жёлтые пятна)
     *   birch  — trunk_white + leaves (mix 10% leaves_orange = лёгкая желтизна)
     *   autumn — trunk + leaves_orange (mix 25% leaves = тёмные зелёные кляксы)
     */
    _placeTree(layer, tx, topY, tz, type) {
        const r1 = hash2(tx, tz, this.params.seed + 2001);
        const r2 = hash2(tx, tz, this.params.seed + 2002);
        const r3 = hash2(tx, tz, this.params.seed + 2003);
        const sizeScale = Math.max(0.3, Math.min(5.0, this.params.structures?.trees?.sizeScale ?? 1.0));

        // === Параметры по типу ===
        let primaryTrunk, secondaryTrunk;
        let primaryLeaf, secondaryLeaf;
        let secondaryLeafChance;
        let trunkH, crownR, crownSquish;
        // Толщина ствола: 0=один voxel, 1=крест 3×3 без углов, 2=полный 3×3, 3=5×5
        let trunkThickness;

        if (type === 'birch') {
            primaryTrunk = 'trunk_white';
            secondaryTrunk = 'trunk_white';
            primaryLeaf = 'leaves';
            secondaryLeaf = 'leaves_orange';
            secondaryLeafChance = 0.10;
            trunkH = Math.round((10 + r1 * 4) * sizeScale);
            crownR = Math.round(3 * sizeScale);
            crownSquish = 1.5;
            // Берёза тонкая по жизни
            trunkThickness = sizeScale < 1.5 ? 0 : 1;
        } else if (type === 'autumn') {
            primaryTrunk = 'trunk';
            secondaryTrunk = 'wood';
            primaryLeaf = 'leaves_orange';
            secondaryLeaf = 'leaves';
            secondaryLeafChance = 0.25;
            trunkH = Math.round((9 + r1 * 4) * sizeScale);
            crownR = Math.round((4 + r2 * 2) * sizeScale);
            crownSquish = 0.85;
            // Толщина растёт с размером
            trunkThickness = sizeScale < 0.8 ? 0 : sizeScale < 1.5 ? 1 : sizeScale < 2.5 ? 2 : 3;
        } else { // oak
            primaryTrunk = 'trunk';
            secondaryTrunk = 'wood';
            primaryLeaf = 'leaves';
            secondaryLeaf = 'leaves_orange';
            secondaryLeafChance = 0.07;
            trunkH = Math.round((8 + r1 * 4) * sizeScale);
            crownR = Math.round((3 + r2 * 2) * sizeScale);
            crownSquish = 1.0;
            trunkThickness = sizeScale < 0.8 ? 0 : sizeScale < 1.5 ? 1 : sizeScale < 2.5 ? 2 : 3;
        }
        if (trunkH < 3) trunkH = 3;
        if (crownR < 2) crownR = 2;

        const placeLeaf = (px, py, pz, noise) => {
            const isSecondary = noise < secondaryLeafChance;
            layer.setVoxel(px, py, pz, isSecondary ? secondaryLeaf : primaryLeaf);
        };
        const placeTrunk = (px, py, pz, noise) => {
            const isSecondary = (type !== 'birch') && noise < 0.05;
            layer.setVoxel(px, py, pz, isSecondary ? secondaryTrunk : primaryTrunk);
        };

        // === Толстый ствол ===
        // Шаблоны толщины — какие voxel'и вокруг (tx, tz) считать стволом.
        //   thickness=0: только (0,0)
        //   thickness=1: крест-3×3 (без углов)
        //   thickness=2: полный 3×3
        //   thickness=3: 5×5 крест без 4-х угловых
        const trunkOffsets = this._trunkOffsets(trunkThickness);

        // Высота сужения — на верхушке ствол снова тонкий перед кроной
        const taperStart = Math.floor(trunkH * 0.7);

        for (let dy = 1; dy <= trunkH; dy++) {
            // На вершине ствол сужается до thickness 0 (это даёт силуэт «бутылки»)
            const thicknessAtY = dy < taperStart
                ? trunkThickness
                : Math.max(0, trunkThickness - Math.floor((dy - taperStart) * 0.5));
            const offsets = this._trunkOffsets(thicknessAtY);
            for (const [ox, oz] of offsets) {
                const noise = hash2(tx + ox * 13, tz + oz * 17 + dy * 7, this.params.seed + 2100);
                placeTrunk(tx + ox, topY + dy, tz + oz, noise);
            }
        }

        // === Утолщение у основания (только для thickness >= 2) ===
        if (trunkThickness >= 2) {
            const baseH = Math.min(3, Math.floor(trunkH / 5));
            for (let dy = 1; dy <= baseH; dy++) {
                // Дополнительное кольцо корней
                for (const [ox, oz] of [[2, 0], [-2, 0], [0, 2], [0, -2], [1, 1], [-1, -1], [1, -1], [-1, 1]]) {
                    if (hash2(tx + ox * 13, tz + oz * 17 + dy, this.params.seed + 2200) > 0.55) continue;
                    placeTrunk(tx + ox, topY + dy, tz + oz, 0.5);
                }
            }
        }

        // === Ветви — зигзаг из 4-7 voxel'ов ===
        const branchTips = [];
        // Количество ветвей зависит от размера дерева
        const numBranches = type === 'birch'
            ? (sizeScale < 1.5 ? 2 : 3)
            : Math.max(2, Math.min(8, Math.round((3 + r3 * 2) * Math.sqrt(sizeScale))));

        const branchLenBase = Math.max(2, Math.floor(crownR * 0.8 + sizeScale));
        for (let b = 0; b < numBranches; b++) {
            // Угол + jitter
            const baseAngle = (b / numBranches) * Math.PI * 2;
            const angleJitter = (hash2(tx + b * 31, tz + b * 53, this.params.seed + 2300) - 0.5) * 0.6;
            const angle = baseAngle + angleJitter + r1 * 0.5;
            // Высота старта ветви — 50-95% от ствола
            const startYFrac = 0.5 + (b / numBranches) * 0.45;
            const branchStartY = Math.max(2, Math.floor(trunkH * startYFrac));
            // Длина ветви с jitter
            const branchLen = branchLenBase + Math.floor(hash2(tx + b, tz, this.params.seed + 2400) * 3);

            // Зигзаг — отклоняемся в плоскости с каждым шагом
            let bx = tx, by = topY + branchStartY, bz = tz;
            const dirX = Math.cos(angle);
            const dirZ = Math.sin(angle);
            for (let step = 1; step <= branchLen; step++) {
                // Идём в основном направлении + случайное отклонение
                bx = Math.round(tx + dirX * step);
                bz = Math.round(tz + dirZ * step);
                // Y — поднимается + случайные изломы
                const upBias = step * 0.4;
                const jitterY = (hash2(bx * 7, bz * 11, this.params.seed + 2500 + step) - 0.3) * 1.5;
                by = topY + branchStartY + Math.floor(upBias + jitterY);

                const noise = hash2(bx, bz + step * 3, this.params.seed + 2310);
                placeTrunk(bx, by, bz, noise);

                // Иногда ставим второй voxel рядом (утолщение ветви для толстых деревьев)
                if (trunkThickness >= 2 && step <= branchLen / 2) {
                    // Боковой voxel перпендикулярно направлению
                    const sx = Math.round(bx - dirZ * 0.7);
                    const sz = Math.round(bz + dirX * 0.7);
                    if (sx !== bx || sz !== bz) {
                        placeTrunk(sx, by, sz, noise + 0.1);
                    }
                }
            }
            branchTips.push({ x: bx, y: by, z: bz });
        }

        // === Раздробленная крона ===
        // Главная сфера — меньше чем была, потому что её дополняют сферы на ветвях.
        const crownCy = topY + trunkH;
        const mainCrownR = Math.max(2, Math.floor(crownR * 0.85));
        this._placeCrown(layer, tx, crownCy, tz, mainCrownR, crownSquish, placeLeaf);

        // Сферы кроны на каждой ветке — это и есть «крона хаотично расходится»
        // как ты хотел. Размер 50-80% главной сферы.
        const subCrownRBase = Math.max(2, Math.floor(crownR * 0.6));
        for (const tip of branchTips) {
            const sizeJitter = hash2(tip.x, tip.z, this.params.seed + 2600) * 0.4 - 0.2;
            const subR = Math.max(2, Math.round(subCrownRBase * (1.0 + sizeJitter)));
            this._placeCrown(layer, tip.x, tip.y + 1, tip.z, subR, crownSquish, placeLeaf);
        }
    }

    /**
     * Возвращает массив [dx, dz] смещений для ствола заданной толщины.
     *   0: один voxel
     *   1: крест 5 voxel'ов (центр + 4 стороны)
     *   2: полный 3×3 (9 voxel'ов)
     *   3: 5×5 без 4-х углов (~21 voxel)
     */
    _trunkOffsets(thickness) {
        if (thickness <= 0) return [[0, 0]];
        if (thickness === 1) return [[0, 0], [1, 0], [-1, 0], [0, 1], [0, -1]];
        if (thickness === 2) {
            return [
                [0, 0], [1, 0], [-1, 0], [0, 1], [0, -1],
                [1, 1], [-1, -1], [1, -1], [-1, 1],
            ];
        }
        // thickness >= 3: 5×5 без 4-х углов
        const out = [];
        for (let dx = -2; dx <= 2; dx++) {
            for (let dz = -2; dz <= 2; dz++) {
                // Пропускаем 4 угла
                if (Math.abs(dx) === 2 && Math.abs(dz) === 2) continue;
                out.push([dx, dz]);
            }
        }
        return out;
    }

    /**
     * Сфера кроны радиусом r с пятнистой раскраской.
     * placeLeaf(x, y, z, noise) — callback который ставит voxel
     * листвы. noise ∈ [0..1) — детерминированный шум для выбора цвета.
     */
    _placeCrown(layer, cx, cy, cz, r, squish, placeLeaf) {
        const r2sq = r * r;
        const innerR2 = r > 2 ? (r - 2) * (r - 2) : 0;
        const yRange = Math.floor(r * squish) + 2;
        for (let dx = -r - 1; dx <= r + 1; dx++) {
            for (let dy = -Math.floor(r / 2); dy <= yRange; dy++) {
                for (let dz = -r - 1; dz <= r + 1; dz++) {
                    const dyEff = dy / squish;
                    const d2 = dx * dx + dyEff * dyEff + dz * dz;
                    if (d2 > r2sq + 2) continue;
                    if (d2 < innerR2) continue;   // полая крона (экономия voxel'ов)
                    const edgeThresh = r2sq - 1.5;
                    if (d2 > edgeThresh) {
                        // Лохматый край — пропускаем часть voxel'ов
                        if (hash2(cx + dx, cz + dz, this.params.seed + 3000 + dy) > 0.5) continue;
                    }
                    if (dx === 0 && dz === 0 && dy < 0) continue;
                    // Шум для выбора цвета листа — даёт пятна
                    const colorNoise = hash2(cx + dx, cz + dz + dy * 11, this.params.seed + 3500);
                    placeLeaf(cx + dx, cy + dy, cz + dz, colorNoise);
                }
            }
        }
    }

    /**
     * Полная регенерация мира в указанный bbox — очищает слой и
     * генерирует заново. Используется при изменении параметров в UI.
     */
    async regenerateLayer(layer, bbox, progressCb = null) {
        layer.clear();
        return await this.generateIntoLayer(layer, bbox, progressCb);
    }
}
