/**
 * PrimitiveManager — параметрические 3D-примитивы (как Part в Roblox).
 *
 * Каждый инстанс — отдельный mesh с настраиваемыми:
 *   - тип (cube/sphere/cylinder/cone/plane/torus/trigger/checkpoint)
 *   - позиция (x, y, z)
 *   - размер (sx, sy, sz)
 *   - цвет (#hex)
 *   - материал ('matte' | 'metal' | 'glass' | 'neon')
 *   - canCollide (bool) — участвует ли в физике коллизий
 *   - visible (bool)    — рисуется ли (anchored — пока заготовка)
 *
 * Хранится в `instances: Map<id, {mesh, ...metadata}>`.
 *
 * Triggers — отдельный slice через kind='trigger': mesh полупрозрачный
 * жёлтый куб (виден в редакторе, скрыт в Play), не участвует в коллизиях.
 *
 * Checkpoints — kind='checkpoint': зелёный полупрозрачный цилиндр.
 * При касании игроком обновляет spawnPoint сцены.
 */
import {
    MeshBuilder, StandardMaterial, Color3, Vector3, Vector4, PointLight,
    Mesh, VertexData,
} from '@babylonjs/core';
// CRA→Vite адаптация: оригинальный код в setTexture()/_applyMaterial()/
// _applyAssetTexture() использует require('@babylonjs/core/.../texture').
// require в Vite/ESM не работает (только в CRA/Webpack) → throw → catch →
// текстура НЕ накладывается, у материала остаётся дефолтный
// розово-чёрный паттерн «missing texture». Из-за этого все 444 примитива
// в Squid-Game (id=45) выглядели как красно-чёрная шахматка.
// Импортим статически.
import { DynamicTexture } from '@babylonjs/core/Materials/Textures/dynamicTexture';
import { Texture } from '@babylonjs/core/Materials/Textures/texture';
import { getPrimitiveType } from './PrimitiveTypes';

// === Материал «studs» (лего-кружки, задача 09) — паритет со студией ===
const STUDS_DIFFUSE_URL = '/kubikon-assets/materials/studs_v4_diffuse.png';
const STUDS_NORMAL_URL  = '/kubikon-assets/materials/studs_v4_normal.png';
const STUD_UNIT = 1;
const STUDS_GRID = 4;

// Вертикальный шаг между ступеньками лестницы (юниты). Полная высота
// лестницы = stepCount * LADDER_STEP_SPACING.
export const LADDER_STEP_SPACING = 0.45;

const _studsTexCache = new WeakMap();
function _getStudsTextures(scene) {
    let c = _studsTexCache.get(scene);
    if (!c) {
        c = { diffuse: new Texture(STUDS_DIFFUSE_URL, scene), normal: new Texture(STUDS_NORMAL_URL, scene) };
        _studsTexCache.set(scene, c);
    }
    return c;
}
function _studsTiling(type, sx, sy, sz, density) {
    // density — множитель плотности кружков (1=стандарт, 2=вдвое мельче/чаще).
    const d = density && density > 0 ? density : 1;
    const f = (STUD_UNIT * STUDS_GRID) / d;
    let u = Math.max(sx, sz) / f;
    let v = sy / f;
    if (type === 'cylinder') { u = (Math.PI * sx) / f; v = sy / f; }
    else if (type === 'sphere') { u = (Math.PI * sx) / f; v = (Math.PI * sy) / f; }
    else if (type === 'plane') { u = sx / f; v = sz / f; }
    return { u: Math.max(0.25, u), v: Math.max(0.25, v) };
}
/**
 * faceUV для куба со studs — КАЖДАЯ грань тайлится по СВОИМ реальным размерам,
 * чтобы кружки были одного размера на всех гранях (не растягивались на длинных).
 * Грани CreateBox: 0=front(z-) 1=back(z+) 2=right(x+) 3=left(x-) 4=top(y+) 5=bottom(y-).
 *   front/back  → ширина=sx, высота=sy
 *   left/right  → ширина=sz, высота=sy
 *   top/bottom  → ширина=sx, высота=sz
 * UV-диапазон грани = (0,0)..(кол-во_studs_по_ширине, кол-во_по_высоте).
 */
function _studsCubeFaceUV(sx, sy, sz, density) {
    const d = density && density > 0 ? density : 1;
    const f = (STUD_UNIT * STUDS_GRID) / d;
    const nx = Math.max(0.25, sx / f);   // studs вдоль X
    const ny = Math.max(0.25, sy / f);   // studs вдоль Y
    const nz = Math.max(0.25, sz / f);   // studs вдоль Z
    // Vector4(u0, v0, u1, v1)
    return [
        new Vector4(0, 0, nx, ny), // front  (z-): X×Y
        new Vector4(0, 0, nx, ny), // back   (z+): X×Y
        new Vector4(0, 0, nz, ny), // right  (x+): Z×Y
        new Vector4(0, 0, nz, ny), // left   (x-): Z×Y
        new Vector4(0, 0, nx, nz), // top    (y+): X×Z
        new Vector4(0, 0, nx, nz), // bottom (y-): X×Z
    ];
}

export class PrimitiveManager {
    constructor(scene) {
        this.scene = scene;
        this.instances = new Map();   // id → { mesh, type, x, y, z, sx, sy, sz, color, material, canCollide, visible }
        this._nextId = 1;
        this._onChange = null;
    }

    setOnChange(cb) { this._onChange = cb; }
    _notifyChange() { if (this._onChange) this._onChange(); }

    /**
     * Создать примитив. Все поля кроме type — опциональны.
     * Возвращает id или null.
     */
    addInstance(type, opts = {}) {
        const typeDef = getPrimitiveType(type);
        if (!typeDef) return null;

        // Если в opts передан id — используем его (нужно для loadFromArray
        // и шаблонов со скриптами, где target.id ссылается на конкретный id).
        // Иначе берём следующий из счётчика.
        let id;
        if (Number.isFinite(opts.id) && opts.id > 0 && !this.instances.has(opts.id)) {
            id = opts.id;
            if (id >= this._nextId) this._nextId = id + 1;
        } else {
            id = this._nextId++;
        }
        const sx = opts.sx ?? typeDef.defaultScale.x;
        let sy = opts.sy ?? typeDef.defaultScale.y;
        const sz = opts.sz ?? typeDef.defaultScale.z;
        // Лестница: высота ДЕРИВИРУЕТСЯ из stepCount (а не из sy) — даёт
        // корректный AABB для детекта касания и совпадает с геометрией меша.
        const isLadder = typeDef.id === 'ladder_vertical';
        const stepCount = isLadder
            ? Math.max(2, Math.min(40, Math.round(opts.stepCount != null ? opts.stepCount : 8)))
            : undefined;
        if (isLadder) sy = stepCount * LADDER_STEP_SPACING;
        const color = opts.color ?? typeDef.defaultColor;
        // GD-сущности (порталы/шипы/финиш/монеты) — neon (светятся) и без коллизии физики.
        // Логику касания обрабатывает GdPortalManager/GdLevelManager по дистанции.
        const isGdKind = typeDef.kind === 'gd_portal'
            || typeDef.kind === 'gd_finish' || typeDef.kind === 'gd_coin';
        const isGlowingGd = isGdKind;
        const isGdSpike = typeDef.kind === 'gd_spike';
        const material = opts.material ?? (isGlowingGd ? 'neon' : 'matte');
        // studDensity — плотность кружков studs (1=стандарт, >1 мельче/чаще).
        const studDensity = Number.isFinite(opts.studDensity) ? opts.studDensity : 1;
        // canCollide: для всех GD-сущностей и шипов — false (игрок проходит сквозь, логика по дистанции).
        // Лестница — тоже проходима (canCollide=false), чтобы игрок мог войти в её
        // объём и лезть (ladder-mode в PlayerController по детекту касания).
        const canCollide = opts.canCollide !== false && !isGdKind && !isGdSpike && !isLadder;
        const visible = opts.visible !== false;
        const anchored = opts.anchored !== false;          // по умолчанию заякорен
        // Масса по умолчанию = объём (sx*sy*sz), округлённый до 2 знаков.
        const rawMass = (opts.sx ?? typeDef.defaultScale.x)
            * (opts.sy ?? typeDef.defaultScale.y)
            * (opts.sz ?? typeDef.defaultScale.z);
        const defaultMass = Math.max(0.1, Math.round(rawMass * 100) / 100);
        const mass = opts.mass != null ? Number(opts.mass) : defaultMass;
        const x = opts.x ?? 0;
        const y = opts.y ?? 0.5;
        const z = opts.z ?? 0;
        const rotationX = opts.rotationX ?? 0;
        const rotationY = opts.rotationY ?? 0;
        const rotationZ = opts.rotationZ ?? 0;

        // Передаём stepCount в builder через временное поле (читается в
        // _buildLadderMesh внутри _createMeshForType).
        this._ladderStepCount = stepCount;
        const mesh = this._createMeshForType(typeDef, id, sx, sy, sz, material, studDensity);
        this._ladderStepCount = undefined;
        mesh.position = new Vector3(x, y, z);
        mesh.rotation = new Vector3(rotationX, rotationY, rotationZ);
        mesh.isPickable = true;
        // Тени: примитивы и принимают тени от других объектов, и сами их
        // отбрасывают (через addShadowCaster в refreshAllShadows).
        // Без receiveShadows=true на полу/стенах не видно теней персонажа.
        mesh.receiveShadows = true;
        mesh.metadata = {
            isPrimitive: true,
            primitiveId: id,
            primitiveType: type,
            primitiveKind: typeDef.kind,
            canCollide,   // нужен camera-clamp: камера не цепляется за зоны canCollide:false
        };

        // textureAsset — id картинки из AssetManager (пользовательская
        // текстура на гранях). Хранится в data, сериализуется, применяется
        // на материал поверх цвета.
        const textureAsset = typeof opts.textureAsset === 'string' ? opts.textureAsset : null;

        const data = {
            id, mesh, type, x, y, z, sx, sy, sz,
            rotationX, rotationY, rotationZ,
            color, material, canCollide, visible, anchored, mass,
            textureAsset, studDensity,
            // Лестница: число ступенек (высота). undefined для прочих.
            ...(isLadder ? { stepCount } : {}),
            // locked — объект защищён от выделения/перемещения в редакторе
            // (Фаза 5.11). На геймплей не влияет.
            locked: opts.locked === true,
            name: opts.name || null,
            folderId: opts.folderId ?? null,
        };
        // Размеры для тайлинга studs-материала (читается в _applyMaterial).
        mesh._studsDims = { type, sx, sy, sz, density: studDensity };
        this._applyMaterial(mesh, typeDef, color, material);
        this._applyVisible(mesh, visible, typeDef);
        // Пользовательская текстура — поверх базового материала.
        if (textureAsset) this._applyAssetTexture(data);

        // === Лампа: создаём привязанный PointLight ===
        if (typeDef.kind === 'light') {
            // brightness — яркость свечения, range — радиус действия (метры).
            data.brightness = Number.isFinite(opts.brightness) ? opts.brightness : 1.5;
            data.range = Number.isFinite(opts.range) ? opts.range : 12;
            const light = new PointLight(`primLight_${id}`,
                new Vector3(x, y, z), this.scene);
            light.diffuse = Color3.FromHexString(color || '#ffe9a0');
            light.intensity = data.brightness;
            light.range = data.range;
            data.light = light;
            // Маркер-сфера светится (neon-вид) — чтобы лампу было видно.
            if (mesh.material) {
                mesh.material.emissiveColor = Color3.FromHexString(color || '#ffe9a0');
            }
        }

        // === Эмиттер: создаём постоянную систему частиц ===
        if (typeDef.kind === 'emitter') {
            // effect — тип эффекта: 'fire' | 'smoke' | 'sparks' | 'magic'.
            data.effect = typeof opts.effect === 'string' ? opts.effect : 'fire';
            if (this.scene3d && this.scene3d.createEmitterParticles) {
                data.particles = this.scene3d.createEmitterParticles(
                    data.effect, { x, y, z }, color);
            }
            if (mesh.material) {
                mesh.material.emissiveColor = Color3.FromHexString(color || '#ff8833');
            }
        }

        // === 3D-табличка (billboard): натягиваем DynamicTexture с GUI ===
        if (typeDef.kind === 'billboard' && this.billboardUiManager) {
            // Сохраняем настройки билборда в data.billboardOpts чтобы
            // serialize мог записать их обратно в JSON проекта.
            const billboardOpts = {
                template: opts.template || 'shop-item',
                face: opts.face || 'fixed',
                content: opts.content || null,
                elements: opts.elements || null,
                rotationY: opts.rotationY,
            };
            this.billboardUiManager.applyToMesh(data, billboardOpts);
            // billboardOpts хранится в data.billboard после applyToMesh.
        }

        this.instances.set(id, data);
        // Авто-регистрация в shadow casters (Этап 4 теней).
        // Когда скрипт спавнит новый объект через scene.spawn(...) — раньше он
        // не отбрасывал тень пока не вызвали refreshAllShadows вручную.
        try {
            if (this.scene3d && typeof this.scene3d.addShadowCaster === 'function') {
                this.scene3d.addShadowCaster(mesh);
            }
        } catch (e) { /* ignore */ }
        this._notifyChange();
        return id;
    }

    /** Создать базовый mesh нужной формы (без материала). */
    _createMeshForType(typeDef, id, sx, sy, sz, material, studDensity) {
        const name = `prim_${typeDef.id}_${id}`;
        switch (typeDef.id) {
            case 'cube':
            case 'trigger': {
                const boxOpts = { width: sx, height: sy, depth: sz };
                // studs — per-face UV, чтобы кружки были одного размера на всех
                // гранях (не растягивались на длинной стороне).
                if (material === 'studs') boxOpts.faceUV = _studsCubeFaceUV(sx, sy, sz, studDensity);
                return MeshBuilder.CreateBox(name, boxOpts, this.scene);
            }
            case 'sphere':
                return MeshBuilder.CreateSphere(name,
                    { diameterX: sx, diameterY: sy, diameterZ: sz, segments: 24 }, this.scene);
            case 'cylinder':
            case 'checkpoint':
            // GD-порталы: высокий тонкий цилиндр-«столб» (как в Geometry Dash)
            case 'gd_cube':
            case 'gd_ship':
            case 'gd_ball':
            case 'gd_ufo':
            case 'gd_wave':
            case 'gd_robot':
                return MeshBuilder.CreateCylinder(name,
                    { diameter: sx, height: sy, tessellation: 24 }, this.scene);
            case 'cone':
            case 'gd_spike':
                // GD-шип = тот же cone (треугольный остриём вверх)
                return MeshBuilder.CreateCylinder(name,
                    { diameterTop: 0, diameterBottom: sx, height: sy, tessellation: 24 }, this.scene);
            case 'gd_finish':
                // GD-финиш = неоновый цилиндр-столб
                return MeshBuilder.CreateCylinder(name,
                    { diameter: sx, height: sy, tessellation: 24 }, this.scene);
            case 'gd_coin':
                // GD-монета = сфера
                return MeshBuilder.CreateSphere(name,
                    { diameterX: sx, diameterY: sy, diameterZ: sz, segments: 24 }, this.scene);
            case 'light':
            case 'emitter':
                // Лампа / эмиттер = маленькая сфера-маркер. Свет/частицы
                // создаются отдельно в addInstance.
                return MeshBuilder.CreateSphere(name,
                    { diameterX: sx, diameterY: sy, diameterZ: sz, segments: 12 }, this.scene);
            case 'billboard': {
                // 3D-табличка — плоскость с пропорциями таблички (sx × sy),
                // sz — толщина рамки (визуально-незаметная).
                // ВАЖНО: FRONTSIDE, не DOUBLESIDE — иначе сквозь back-side
                // видно зеркальную сторону UV (текст справа-налево).
                // BillboardMode разворачивает FRONT к камере.
                const m = MeshBuilder.CreatePlane(name,
                    { width: sx, height: sy, sideOrientation: Mesh.FRONTSIDE }, this.scene);
                return m;
            }
            case 'plane':
                return MeshBuilder.CreateBox(name,
                    { width: sx, height: sy, depth: sz }, this.scene);
            case 'torus':
                return MeshBuilder.CreateTorus(name,
                    { diameter: sx, thickness: sy * 0.5, tessellation: 24 }, this.scene);
            case 'wedge':
                return this._buildWedgeMesh(name, sx, sy, sz);
            case 'cornerwedge':
                return this._buildCornerWedgeMesh(name, sx, sy, sz);
            case 'ladder_vertical':
                // Лестница строится из stepCount ступенек — высота зависит от
                // количества ступенек, а не от sy.
                return this._buildLadderMesh(name, sx, sz, this._ladderStepCount || 8);
            default:
                return MeshBuilder.CreateBox(name,
                    { width: sx, height: sy, depth: sz }, this.scene);
        }
    }

    /**
     * Клин — куб со срезанной верхней гранью (наклонная рампа).
     * Высокий край у +Z, остриё-ребро у -Z. Центрирован в (0,0,0),
     * как CreateBox. У каждой грани свои вершины (плоское затенение).
     */
    _buildWedgeMesh(name, sx, sy, sz) {
        const hx = sx / 2, hy = sy / 2, hz = sz / 2;
        // 6 уникальных точек клина:
        //   низ:  A(-hx,-hy,-hz) B(hx,-hy,-hz) C(hx,-hy,hz) D(-hx,-hy,hz)
        //   верх: E(-hx,hy,hz)  F(hx,hy,hz)   — только у задней (+Z) грани
        const positions = [];
        const indices = [];
        const normals = [];
        const uvs = [];
        let vi = 0;
        // Добавить грань (массив точек [x,y,z], нормаль, uv-набор) триангуляцией веером.
        const face = (pts, nrm, uv) => {
            const start = vi;
            for (let i = 0; i < pts.length; i++) {
                positions.push(pts[i][0], pts[i][1], pts[i][2]);
                normals.push(nrm[0], nrm[1], nrm[2]);
                uvs.push(uv[i][0], uv[i][1]);
                vi++;
            }
            for (let i = 1; i < pts.length - 1; i++) {
                indices.push(start, start + i, start + i + 1);
            }
        };
        // Низ (y=-hy), нормаль вниз — порядок по часовой при взгляде снизу
        face(
            [[-hx,-hy,-hz],[-hx,-hy,hz],[hx,-hy,hz],[hx,-hy,-hz]],
            [0,-1,0], [[0,0],[0,1],[1,1],[1,0]]
        );
        // Задняя вертикальная грань (z=+hz), нормаль +Z
        face(
            [[-hx,-hy,hz],[-hx,hy,hz],[hx,hy,hz],[hx,-hy,hz]],
            [0,0,1], [[0,0],[0,1],[1,1],[1,0]]
        );
        // Наклонная грань: от нижнего ребра (-Z) к верхнему (+Z).
        // Нормаль перпендикулярна склону в плоскости YZ: (0, sz, sy) норм.
        const slopeLen = Math.hypot(sy, sz) || 1;
        face(
            [[-hx,-hy,-hz],[hx,-hy,-hz],[hx,hy,hz],[-hx,hy,hz]],
            [0, sz / slopeLen, sy / slopeLen], [[0,0],[1,0],[1,1],[0,1]]
        );
        // Левый бок (x=-hx) — треугольник, нормаль -X
        face(
            [[-hx,-hy,-hz],[-hx,hy,hz],[-hx,-hy,hz]],
            [-1,0,0], [[0,0],[1,1],[1,0]]
        );
        // Правый бок (x=+hx) — треугольник, нормаль +X
        face(
            [[hx,-hy,-hz],[hx,-hy,hz],[hx,hy,hz]],
            [1,0,0], [[0,0],[1,0],[1,1]]
        );
        const mesh = new Mesh(name, this.scene);
        const vd = new VertexData();
        vd.positions = positions;
        vd.indices = indices;
        vd.normals = normals;
        vd.uvs = uvs;
        vd.applyToMesh(mesh);
        return mesh;
    }

    /**
     * Угловой клин — скос по двум осям, остриё в углу (-X,-Z).
     * Высокая точка у (+X,+Z). Для внутренних углов крыш.
     */
    _buildCornerWedgeMesh(name, sx, sy, sz) {
        const hx = sx / 2, hy = sy / 2, hz = sz / 2;
        const positions = [];
        const indices = [];
        const normals = [];
        const uvs = [];
        let vi = 0;
        const face = (pts, nrm, uv) => {
            const start = vi;
            for (let i = 0; i < pts.length; i++) {
                positions.push(pts[i][0], pts[i][1], pts[i][2]);
                normals.push(nrm[0], nrm[1], nrm[2]);
                uvs.push(uv[i][0], uv[i][1]);
                vi++;
            }
            for (let i = 1; i < pts.length - 1; i++) {
                indices.push(start, start + i, start + i + 1);
            }
        };
        // Низ — квадрат основания
        face(
            [[-hx,-hy,-hz],[-hx,-hy,hz],[hx,-hy,hz],[hx,-hy,-hz]],
            [0,-1,0], [[0,0],[0,1],[1,1],[1,0]]
        );
        // Верх — единственная высокая точка T(hx,hy,hz). Скос идёт к ней
        // из трёх нижних точек. Грани:
        const T = [hx, hy, hz];
        // Задняя грань (z=+hz): нижнее ребро [-hx..hx] поднимается к T справа
        face(
            [[-hx,-hy,hz],[hx,-hy,hz],T],
            [0,0,1], [[0,0],[1,0],[1,1]]
        );
        // Правая грань (x=+hx): нижнее ребро [-hz..hz] поднимается к T сзади
        face(
            [[hx,-hy,-hz],[hx,-hy,hz],T],
            [1,0,0], [[0,0],[1,0],[1,1]]
        );
        // Наклонная треугольная грань — от низкого угла (-X,-Z) к T.
        // Нормаль = нормаль плоскости через 3 точки.
        const p1 = [-hx,-hy,-hz], p2 = [hx,-hy,-hz], p3 = T;
        const u = [p2[0]-p1[0], p2[1]-p1[1], p2[2]-p1[2]];
        const v = [p3[0]-p1[0], p3[1]-p1[1], p3[2]-p1[2]];
        let n1 = [
            u[1]*v[2]-u[2]*v[1],
            u[2]*v[0]-u[0]*v[2],
            u[0]*v[1]-u[1]*v[0],
        ];
        let nl = Math.hypot(n1[0],n1[1],n1[2]) || 1;
        n1 = [n1[0]/nl, n1[1]/nl, n1[2]/nl];
        face([p1,p2,p3], n1, [[0,0],[1,0],[0.5,1]]);
        // Вторая наклонная грань — от (-X,-Z) к T со стороны -X.
        const q1 = [-hx,-hy,-hz], q2 = T, q3 = [-hx,-hy,hz];
        const u2 = [q2[0]-q1[0], q2[1]-q1[1], q2[2]-q1[2]];
        const v2 = [q3[0]-q1[0], q3[1]-q1[1], q3[2]-q1[2]];
        let n2 = [
            u2[1]*v2[2]-u2[2]*v2[1],
            u2[2]*v2[0]-u2[0]*v2[2],
            u2[0]*v2[1]-u2[1]*v2[0],
        ];
        let nl2 = Math.hypot(n2[0],n2[1],n2[2]) || 1;
        n2 = [n2[0]/nl2, n2[1]/nl2, n2[2]/nl2];
        face([q1,q2,q3], n2, [[0,0],[0.5,1],[1,0]]);
        const mesh = new Mesh(name, this.scene);
        const vd = new VertexData();
        vd.positions = positions;
        vd.indices = indices;
        vd.normals = normals;
        vd.uvs = uvs;
        vd.applyToMesh(mesh);
        return mesh;
    }

    /**
     * Вертикальная лестница: 2 боковые стойки + N перекладин (ступенек).
     * Полная высота = stepCount * LADDER_STEP_SPACING. При изменении stepCount
     * лестница ПЕРЕСТРАИВАЕТСЯ. Меш центрирован по (0,0,0) как CreateBox.
     * sx — ширина, sz — глубина стоек/перекладин.
     */
    _buildLadderMesh(name, sx, sz, stepCount) {
        const n = Math.max(2, Math.min(40, Math.round(stepCount || 8)));
        const SPACING = LADDER_STEP_SPACING;
        const height = n * SPACING;
        const railW = Math.min(0.12, sx * 0.12);
        const railD = Math.max(0.06, sz);
        const rungH = Math.min(0.1, SPACING * 0.3);
        const halfH = height / 2;
        const railX = sx / 2 - railW / 2;
        const parts = [];
        const railL = MeshBuilder.CreateBox(name + '_railL',
            { width: railW, height, depth: railD }, this.scene);
        railL.position.x = -railX;
        parts.push(railL);
        const railR = MeshBuilder.CreateBox(name + '_railR',
            { width: railW, height, depth: railD }, this.scene);
        railR.position.x = railX;
        parts.push(railR);
        const rungWidth = sx - railW;
        for (let i = 0; i < n; i++) {
            const y = -halfH + SPACING * (i + 0.5);
            const rung = MeshBuilder.CreateBox(name + '_rung' + i,
                { width: rungWidth, height: rungH, depth: railD }, this.scene);
            rung.position.y = y;
            parts.push(rung);
        }
        const merged = Mesh.MergeMeshes(parts, true, true, undefined, false, true);
        if (merged) { merged.name = name; return merged; }
        return MeshBuilder.CreateBox(name, { width: sx, height, depth: sz }, this.scene);
    }

    /** Применить цвет и материал. */
    _applyMaterial(mesh, typeDef, color, material, textureUrl) {
        const matName = `${mesh.name}_mat`;
        const mat = new StandardMaterial(matName, this.scene);
        mat.diffuseColor = Color3.FromHexString(color || '#888888');

        // Если задан textureUrl — подгружаем PNG как diffuseTexture. Это
        // используется для GD-скинов куба (например /gd/skins/cube_smile.png).
        // Цвет работает как multiplier для текстуры — для нейтрального эффекта
        // ставим белый (или сам color, чтобы можно было тонировать).
        if (textureUrl) {
            try {
                // Texture импортирован статически вверху файла (см. комментарий
                // про CRA→Vite адаптацию). Раньше тут был require() —
                // в Vite/ESM он бросал исключение, и материал оставался
                // с дефолтной розово-чёрной шахматкой.
                const tex = new Texture(textureUrl, this.scene, true, false);
                tex.hasAlpha = true;
                mat.diffuseTexture = tex;
                // Для текстурных скинов цвет берём белый, чтобы не тонировать
                mat.diffuseColor = new Color3(1, 1, 1);
            } catch (e) {
                console.warn('[PrimitiveManager] не удалось загрузить текстуру', textureUrl, e);
            }
        }

        switch (material) {
            case 'metal':
                mat.specularColor = new Color3(0.7, 0.7, 0.7);
                mat.specularPower = 32;
                break;
            case 'glass':
                mat.alpha = 0.4;
                mat.specularColor = new Color3(0.8, 0.85, 0.9);
                mat.specularPower = 96;
                mat.backFaceCulling = false;
                break;
            case 'neon':
                mat.emissiveColor = Color3.FromHexString(color || '#888888');
                mat.specularColor = new Color3(0, 0, 0);
                break;
            case 'chrome': {
                const cc = Color3.FromHexString(color || '#cfd6e0');
                mat.diffuseColor = new Color3(cc.r * 0.6, cc.g * 0.6, cc.b * 0.6);
                mat.specularColor = new Color3(1, 1, 1);
                mat.specularPower = 128;
                mat.emissiveColor = new Color3(cc.r * 0.12, cc.g * 0.12, cc.b * 0.14);
                break;
            }
            case 'water': {
                const wc = Color3.FromHexString(color || '#3aa0ff');
                mat.diffuseColor = wc;
                mat.alpha = 0.55;
                mat.specularColor = new Color3(0.9, 0.95, 1.0);
                mat.specularPower = 64;
                mat.emissiveColor = new Color3(wc.r * 0.1, wc.g * 0.14, wc.b * 0.2);
                mesh._isWater = true;
                break;
            }
            case 'iridescent': {
                const ic = Color3.FromHexString(color || '#a06bff');
                mat.diffuseColor = ic;
                mat.emissiveColor = new Color3(ic.r * 0.5, ic.g * 0.35, ic.b * 0.6);
                mat.specularColor = new Color3(1, 1, 1);
                mat.specularPower = 96;
                break;
            }
            case 'studs': {
                // Лего-материал (паритет со студией): почти-белая diffuse × цвет
                // меша + normal map. emissive = доля цвета → сочность (Roblox-look).
                const tex = _getStudsTextures(this.scene);
                const dt = tex.diffuse.clone();
                const nt = tex.normal.clone();
                const dims = mesh._studsDims || { type: 'cube', sx: 1, sy: 1, sz: 1 };
                // Куб/триггер тайлятся через faceUV геометрии (uScale=1) — кружки
                // одного размера на всех гранях. Остальные формы — через uScale.
                if (dims.type === 'cube' || dims.type === 'trigger') {
                    dt.uScale = nt.uScale = 1;
                    dt.vScale = nt.vScale = 1;
                } else {
                    const tile = _studsTiling(dims.type, dims.sx, dims.sy, dims.sz, dims.density);
                    dt.uScale = nt.uScale = tile.u;
                    dt.vScale = nt.vScale = tile.v;
                }
                mat.diffuseTexture = dt;
                mat.bumpTexture = nt;
                const sc = Color3.FromHexString(color || '#cccccc');
                mat.diffuseColor = sc;
                mat.emissiveColor = new Color3(sc.r * 0.45, sc.g * 0.45, sc.b * 0.45);
                mat.specularColor = new Color3(0, 0, 0);
                break;
            }
            case 'matte':
            default:
                mat.specularColor = new Color3(0, 0, 0);
                break;
        }

        // Триггеры — всегда полупрозрачные жёлтые в редакторе
        if (typeDef.kind === 'trigger') {
            mat.diffuseColor = new Color3(1, 0.92, 0.2);
            mat.alpha = 0.35;
            mat.specularColor = new Color3(0, 0, 0);
            mat.emissiveColor = new Color3(0.3, 0.27, 0);
        }
        // Чекпоинты — зелёные полупрозрачные с эмиссией
        if (typeDef.kind === 'checkpoint') {
            mat.diffuseColor = new Color3(0.3, 0.95, 0.4);
            mat.alpha = 0.55;
            mat.emissiveColor = new Color3(0.1, 0.5, 0.15);
            mat.specularColor = new Color3(0, 0, 0);
        }

        mesh.material = mat;
    }

    /** Видимость. Триггеры в Play-режиме скрываются (флаг проставляет BabylonScene). */
    _applyVisible(mesh, visible, typeDef) {
        mesh.setEnabled(visible);
        // Доп. логика для триггеров — может ставиться в _updatePlayModeVisibility
    }

    /**
     * Применить пользовательскую текстуру (картинку из AssetManager) на
     * грани примитива. data.textureAsset — id ассета. dataURL берём из
     * assetManager. Цвет материала становится белым, чтобы не тонировать.
     */
    _applyAssetTexture(data) {
        if (!data || !data.mesh || !data.mesh.material) return;
        const assetId = data.textureAsset;
        if (!assetId || !this.assetManager) return;
        const dataUrl = this.assetManager.getDataUrl(assetId);
        if (!dataUrl) return;
        try {
            // Texture импортирован статически вверху файла.
            const mat = data.mesh.material;
            // Снимаем прежнюю diffuseTexture, чтобы не текла память.
            if (mat.diffuseTexture && mat.diffuseTexture.dispose) {
                try { mat.diffuseTexture.dispose(); } catch (e) { /* ignore */ }
            }
            const tex = new Texture(dataUrl, this.scene, true, false);
            tex.hasAlpha = true;
            mat.diffuseTexture = tex;
            mat.diffuseColor = new Color3(1, 1, 1);
            // neon-материал светится текстурой — emissive тоже из неё.
            if (data.material === 'neon') {
                mat.emissiveTexture = tex;
            }
        } catch (e) {
            console.warn('[PrimitiveManager] _applyAssetTexture failed', assetId, e);
        }
    }

    /**
     * Установить динамическую текстуру примитива из dataURL (base64-PNG).
     * Используется GD-скинами куба: canvas-фабрика в скрипте рисует лицо,
     * посылает dataURL сюда — мы создаём DynamicTexture и подменяем
     * diffuseTexture на материале примитива.
     */
    setTexture(id, dataUrl) {
        const data = this.instances.get(id);
        if (!data || !data.mesh || !data.mesh.material) return false;
        try {
            // DynamicTexture импортирован статически вверху файла
            // (см. комментарий про CRA→Vite адаптацию).
            const mat = data.mesh.material;

            // Очистка предыдущей текстуры если была
            if (mat.diffuseTexture && mat.diffuseTexture.dispose) {
                try { mat.diffuseTexture.dispose(); } catch (e) { /* ignore */ }
            }

            // Создаём 256×256 DynamicTexture, рисуем в неё через временное Image
            const dt = new DynamicTexture(`dt_${id}_${Date.now()}`, { width: 256, height: 256 }, this.scene, false);
            const img = new Image();
            img.onload = () => {
                if (dt.isDisposed && dt.isDisposed()) return;
                const ctx = dt.getContext();
                if (!ctx) return;
                ctx.clearRect(0, 0, 256, 256);
                ctx.drawImage(img, 0, 0, 256, 256);
                dt.update(false);
            };
            img.onerror = () => { try { dt.dispose(); } catch (e) {} };
            img.src = dataUrl;
            // Babylon CreateBox UV: V-ось перевёрнута относительно canvas → флипаем
            dt.vScale = -1;
            dt.vOffset = 1;
            mat.diffuseTexture = dt;
            mat.diffuseColor = new Color3(1, 1, 1);
            // Для neon — emissive из текстуры тоже
            if (data.material === 'neon') {
                mat.emissiveTexture = dt;
            }
            return true;
        } catch (e) {
            console.warn('[PrimitiveManager] setTexture failed', id, e);
            return false;
        }
    }

    /** Скрыть/показать триггеры (вызывается при enterPlayMode). */
    setTriggersVisible(visible) {
        for (const data of this.instances.values()) {
            if (data.type === 'trigger' && data.mesh) {
                if (data.visible !== false) {
                    data.mesh.setEnabled(visible);
                }
            }
        }
    }

    /** Обновить параметры примитива (позиция / размер / цвет / материал / флаги). */
    updateInstance(id, patch) {
        const data = this.instances.get(id);
        if (!data) return;

        // Позиция
        if (patch.x !== undefined) data.x = patch.x;
        if (patch.y !== undefined) data.y = patch.y;
        if (patch.z !== undefined) data.z = patch.z;
        data.mesh.position.set(data.x, data.y, data.z);

        // Поворот
        let rotChanged = false;
        if (patch.rotationX !== undefined) { data.rotationX = patch.rotationX; rotChanged = true; }
        if (patch.rotationY !== undefined) { data.rotationY = patch.rotationY; rotChanged = true; }
        if (patch.rotationZ !== undefined) { data.rotationZ = patch.rotationZ; rotChanged = true; }
        if (rotChanged) {
            data.mesh.rotation.set(data.rotationX || 0, data.rotationY || 0, data.rotationZ || 0);
        }

        // Размер — только через scaling, чтобы не пересоздавать mesh
        let scaleChanged = false;
        if (patch.sx !== undefined) { data.sx = patch.sx; scaleChanged = true; }
        if (patch.sy !== undefined) { data.sy = patch.sy; scaleChanged = true; }
        if (patch.sz !== undefined) { data.sz = patch.sz; scaleChanged = true; }
        // Плотность studs (мелкие/крупные кружки) — требует пересоздания меша
        // (faceUV для куба зашит в геометрию).
        if (patch.studDensity !== undefined) {
            data.studDensity = Number.isFinite(patch.studDensity) ? patch.studDensity : 1;
            scaleChanged = true;
        }
        // Лестница: смена числа ступенек → пересборка меша. Высота (sy)
        // деривируется из stepCount, поэтому AABB касания остаётся корректным.
        if (patch.stepCount !== undefined && data.type === 'ladder_vertical') {
            const sc = Math.max(2, Math.min(40, Math.round(patch.stepCount)));
            data.stepCount = sc;
            data.sy = sc * LADDER_STEP_SPACING;
            scaleChanged = true;
        }
        if (scaleChanged) {
            // Поскольку MeshBuilder уже создал mesh с базовыми размерами,
            // изменения через scaling кажутся правильными. Простой способ —
            // пересоздать mesh:
            this._recreateMesh(data);
        }

        // Пользовательская текстура: id картинки из AssetManager или null/''
        // (снять текстуру). Меняем data.textureAsset — переприменение ниже.
        let textureChanged = false;
        if (patch.textureAsset !== undefined) {
            data.textureAsset = patch.textureAsset || null;
            textureChanged = true;
        }

        // Цвет / материал
        let matChanged = false;
        if (patch.color !== undefined) { data.color = patch.color; matChanged = true; }
        if (patch.material !== undefined) { data.material = patch.material; matChanged = true; }
        if (matChanged) {
            const typeDef = getPrimitiveType(data.type);
            // Удаляем старый material
            if (data.mesh.material) {
                try { data.mesh.material.dispose(); } catch (e) { /* ignore */ }
            }
            data.mesh._studsDims = { type: data.type, sx: data.sx, sy: data.sy, sz: data.sz };
            this._applyMaterial(data.mesh, typeDef, data.color, data.material);
        }
        // Текстуру переприменяем если: сменили саму текстуру, или
        // пересоздали материал (matChanged) / mesh (scaleChanged) —
        // в этих случаях прежняя текстура потеряна.
        if (data.textureAsset && (textureChanged || matChanged || scaleChanged)) {
            this._applyAssetTexture(data);
        } else if (textureChanged && !data.textureAsset && matChanged === false) {
            // Текстуру сняли — пересоздаём чистый материал из цвета.
            const typeDef = getPrimitiveType(data.type);
            if (data.mesh.material) {
                try { data.mesh.material.dispose(); } catch (e) { /* ignore */ }
            }
            data.mesh._studsDims = { type: data.type, sx: data.sx, sy: data.sy, sz: data.sz };
            this._applyMaterial(data.mesh, typeDef, data.color, data.material);
        }

        if (patch.canCollide !== undefined) {
            data.canCollide = patch.canCollide;
            if (data.mesh?.metadata) data.mesh.metadata.canCollide = patch.canCollide;
        }
        if (patch.locked !== undefined) data.locked = !!patch.locked;
        if (patch.visible !== undefined) {
            data.visible = patch.visible;
            data.mesh.setEnabled(data.visible);
        }
        // Прозрачность: 1 = непрозрачно, 0 = невидимо. Меняет material.alpha.
        if (patch.opacity !== undefined) {
            const op = Number(patch.opacity);
            if (Number.isFinite(op)) {
                data.opacity = Math.max(0, Math.min(1, op));
                if (data.mesh.material) data.mesh.material.alpha = data.opacity;
            }
        }

        // Billboard: пересоздать GUI-текстуру при изменении template/content/face/elements
        if (patch.billboardOpts && this.billboardUiManager && data.type === 'billboard') {
            this.billboardUiManager.applyToMesh(data, patch.billboardOpts);
        }

        // === Лампа: синхронизируем привязанный PointLight ===
        if (data.light) {
            // позиция света — за маркером
            data.light.position.set(data.x, data.y, data.z);
            if (patch.color !== undefined) {
                data.light.diffuse = Color3.FromHexString(data.color || '#ffe9a0');
                if (data.mesh.material) {
                    data.mesh.material.emissiveColor = Color3.FromHexString(data.color || '#ffe9a0');
                }
            }
            if (patch.brightness !== undefined) {
                const b = Number(patch.brightness);
                if (Number.isFinite(b) && b >= 0) {
                    data.brightness = b;
                    data.light.intensity = b;
                }
            }
            if (patch.range !== undefined) {
                const r = Number(patch.range);
                if (Number.isFinite(r) && r > 0) {
                    data.range = r;
                    data.light.range = r;
                }
            }
        }

        // === Эмиттер: синхронизируем систему частиц ===
        if (data.effect !== undefined && this.scene3d) {
            // позиция частиц — за маркером
            if (data.particles && data.particles.emitter) {
                data.particles.emitter.set(data.x, data.y, data.z);
            }
            // смена типа эффекта или цвета — пересоздаём систему
            if (patch.effect !== undefined || patch.color !== undefined) {
                if (patch.effect !== undefined && typeof patch.effect === 'string') {
                    data.effect = patch.effect;
                }
                // dispose(false) — НЕ удалять particleTexture: она расшарена
                // между всеми эмиттерами (_particleTex). dispose(true) убил бы
                // текстуру для всех остальных систем частиц.
                try { if (data.particles) data.particles.dispose(false); } catch (e) {}
                if (this.scene3d.createEmitterParticles) {
                    data.particles = this.scene3d.createEmitterParticles(
                        data.effect, { x: data.x, y: data.y, z: data.z }, data.color);
                }
                if (patch.color !== undefined && data.mesh.material) {
                    data.mesh.material.emissiveColor = Color3.FromHexString(data.color || '#ff8833');
                }
            }
        }
        if (patch.anchored !== undefined) data.anchored = patch.anchored;
        if (patch.mass !== undefined) {
            const m = Number(patch.mass);
            if (Number.isFinite(m) && m > 0) data.mass = m;
        }

        this._notifyChange();
    }

    /** Пересоздать mesh при смене размера (т.к. Box-builder работает в base-size). */
    _recreateMesh(data) {
        const oldMesh = data.mesh;
        const oldPos = oldMesh.position.clone();
        const oldRot = oldMesh.rotation?.clone();
        const oldMat = oldMesh.material;

        const typeDef = getPrimitiveType(data.type);
        // Лестница: передаём актуальный stepCount в builder.
        this._ladderStepCount = data.stepCount;
        const newMesh = this._createMeshForType(typeDef, data.id, data.sx, data.sy, data.sz, data.material, data.studDensity);
        this._ladderStepCount = undefined;
        newMesh.position = oldPos;
        if (oldRot) newMesh.rotation = oldRot;
        // studs — материал пересоздаём заново (свежий faceUV/тайлинг). Иначе перенос.
        if (data.material === 'studs') {
            newMesh._studsDims = { type: data.type, sx: data.sx, sy: data.sy, sz: data.sz, density: data.studDensity };
            this._applyMaterial(newMesh, typeDef, data.color, data.material);
            try { oldMat?.dispose(); } catch (e) { /* ignore */ }
        } else {
            newMesh.material = oldMat;
        }
        newMesh.isPickable = true;
        newMesh.metadata = { ...oldMesh.metadata };
        newMesh.setEnabled(data.visible);

        // Удаляем старый
        try { oldMesh.dispose(/*doNotRecurse*/ true, /*disposeMaterial*/ false); }
        catch (e) { /* ignore */ }

        data.mesh = newMesh;
        // _studsDims и материал studs уже выставлены выше.
    }

    /** Удалить инстанс. */
    removeInstance(id) {
        const data = this.instances.get(id);
        if (!data) return false;
        try {
            // У лампы есть привязанный PointLight — освобождаем его.
            if (data.light) data.light.dispose();
            // У эмиттера — система частиц (dispose(false) — текстура расшарена).
            if (data.particles) data.particles.dispose(false);
            if (data.mesh.material) data.mesh.material.dispose();
            data.mesh.dispose();
        } catch (e) { /* ignore */ }
        this.instances.delete(id);
        this._notifyChange();
        return true;
    }

    /** Удалить инстанс по mesh (после raycast). */
    removeInstanceByMesh(mesh) {
        const id = mesh?.metadata?.primitiveId;
        if (id != null) return this.removeInstance(id);
        return false;
    }

    getInstanceCount() {
        return this.instances.size;
    }

    /** Все инстансы как массив (для Hierarchy). */
    getAll() {
        return Array.from(this.instances.values()).map(d => ({
            id: d.id, type: d.type,
            x: d.x, y: d.y, z: d.z,
            sx: d.sx, sy: d.sy, sz: d.sz,
            rotationX: d.rotationX || 0,
            rotationY: d.rotationY || 0,
            rotationZ: d.rotationZ || 0,
            color: d.color, material: d.material,
            canCollide: d.canCollide, visible: d.visible,
            anchored: d.anchored,
            mass: d.mass,
            name: d.name || null,
            ...(d.folderId != null ? { folderId: d.folderId } : {}),   // папка (парность со студией)
            // locked — защита от выделения/перемещения (Фаза 5.11).
            ...(d.locked ? { locked: true } : {}),
            // id пользовательской текстуры (картинка из AssetManager).
            ...(d.textureAsset ? { textureAsset: d.textureAsset } : {}),
            // Плотность studs (если не 1) — мелкие/крупные кружки.
            ...(d.studDensity && d.studDensity !== 1 ? { studDensity: d.studDensity } : {}),
            // Параметры лампы (только для type='light', иначе undefined)
            ...(d.light ? { brightness: d.brightness, range: d.range } : {}),
            // Параметр эмиттера (только для type='emitter')
            ...(d.effect !== undefined ? { effect: d.effect } : {}),
            // Число ступенек лестницы (только для type='ladder_vertical')
            ...(d.type === 'ladder_vertical' ? { stepCount: d.stepCount } : {}),
            // Параметры билборда (только для type='billboard')
            ...(d.billboard ? {
                template: d.billboard.template,
                face: d.billboard.face,
                content: d.billboard.content,
                ...(d.billboard.elements ? { elements: d.billboard.elements } : {}),
            } : {}),
        }));
    }

    /**
     * Заморозить worldMatrix у статичных примитивов на старте Play —
     * Babylon перестанет пересчитывать матрицы каждый кадр, frustum-test
     * по ним идёт без world-matrix recompute. Огромный буст FPS на сценах
     * с сотнями декоративных примитивов (Only Up клон, паркур-башни).
     *
     * Не замораживаем: примитивы с anchored=false (физика), и помеченные
     * скриптом как «движущиеся» (через мета-флаг или имя в moving-списке).
     * Скрипт всё равно зовёт scene.move через self.move/scene.move которые
     * в GameRuntime НЕ требуют world-matrix unfreezed (сам ставит position
     * без trigger reset). Но на всякий случай — пропускаем те у кого
     * имя начинается с MX_/MZ_/PEND_/PIST_ (наши соглашения для движущихся).
     */
    freezeStaticPrimitives() {
        for (const data of this.instances.values()) {
            if (data._worldMatrixFrozen) continue;
            if (data.anchored === false) continue;
            if (!data.mesh) continue;
            const name = data.name || '';
            // Пропускаем известные типы движущихся платформ
            if (name.startsWith('MX_') || name.startsWith('MZ_')
                || name.startsWith('PEND_') || name.startsWith('PIST_')) continue;
            // Пропускаем примитивы внутри папок — их может вращать setFolderYaw
            // (например, голова куклы Squid Game в папке DollHead).
            if (data.folderId != null) continue;
            try {
                data.mesh.computeWorldMatrix(true);
                data.mesh.freezeWorldMatrix();
                // НЕ ставим doNotSyncBoundingInfo=true — на больших удалённых
                // примитивах (кукла Squid Game на z=90) это ломало frustum-cull
                // и mesh пропадал из видимости.
                data._worldMatrixFrozen = true;
            } catch (e) { /* ignore */ }
        }
    }

    unfreezeStaticPrimitives() {
        for (const data of this.instances.values()) {
            if (!data._worldMatrixFrozen) continue;
            try {
                data.mesh?.unfreezeWorldMatrix?.();
                data._worldMatrixFrozen = false;
            } catch (e) { /* ignore */ }
        }
    }

    /** Найти все mesh-чекпоинты (для проверки игроком). */
    getCheckpoints() {
        const out = [];
        for (const data of this.instances.values()) {
            if (data.type === 'checkpoint') out.push(data);
        }
        return out;
    }

    serialize() {
        return this.getAll();
    }

    loadFromArray(arr) {
        this.clear();
        for (const item of arr) {
            this.addInstance(item.type, item);
        }
    }

    clear() {
        const cb = this._onChange;
        this._onChange = null;
        const had = this.instances.size > 0;
        for (const id of Array.from(this.instances.keys())) {
            this.removeInstance(id);
        }
        this._onChange = cb;
        if (had) this._notifyChange();
    }

    dispose() {
        this.clear();
    }
}
