/**
 * 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, Texture, DynamicTexture,
} from '@babylonjs/core';
import { getPrimitiveType } from './PrimitiveTypes';

// === Материал «studs» (лего-кружки, задача 09) ===
// Серая diffuse-текстура с сеткой выпуклых кружков (multiply на цвет меша) +
// normal map для иллюзии выпуклости. 1 stud = STUD_UNIT юнитов → тайлинг
// считается из реального размера меша (куб 4×4 покажет 4×4 кружка).
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;            // 1 круглый stud на 1 юнит размера
const STUDS_GRID = 4;           // текстура содержит сетку 4×4 круглых studs
// Кэш текстур на сцену: чтобы не грузить один и тот же PNG для каждого меша.
// Map<scene, { diffuse: Texture, normal: Texture }>. Каждый меш получает свою
// материал-копию (свой цвет/тайлинг), но текстуры шарятся.
const _studsTexCache = new WeakMap();
function _getStudsTextures(scene) {
    let c = _studsTexCache.get(scene);
    if (!c) {
        const diffuse = new Texture(STUDS_DIFFUSE_URL, scene);
        // base — uScale=1 (для cube/trigger: тайлинг через faceUV геометрии).
        // tiled — кэш клонов по ключу 'u_v' для не-кубических форм, чтобы НЕ
        // плодить по текстуре на каждый меш (был источник FPS-просадки: десятки
        // клонов diffuse+normal = десятки GPU-ресурсов + дорогой bump на каждом).
        c = { diffuse, tiled: new Map() };
        _studsTexCache.set(scene, c);
    }
    return c;
}
// Общая diffuse-текстура с заданным тайлингом (u,v). Клон создаётся ОДИН раз
// на пару (u,v) и переиспользуется всеми мешами с тем же тайлингом.
function _getStudsTiledTexture(scene, u, v) {
    const c = _getStudsTextures(scene);
    // округляем до 0.05 — близкие тайлинги шарят одну текстуру
    const ru = Math.round(u * 20) / 20, rv = Math.round(v * 20) / 20;
    const key = ru + '_' + rv;
    let t = c.tiled.get(key);
    if (!t) {
        t = c.diffuse.clone();
        t.uScale = ru; t.vScale = rv;
        c.tiled.set(key, t);
    }
    return t;
}
/**
 * Посчитать тайлинг (uScale/vScale) для studs по размеру меша. Чтобы кружки не
 * растягивались: число кружков на грань = размер_грани / STUD_UNIT, делённое на
 * число кружков в самой текстуре (STUDS_GRID).
 * Для куба/плоскости тайлинг прямой; для сферы/цилиндра — приближённый.
 */
function _studsTiling(type, sx, sy, sz, density) {
    // density — множитель плотности кружков (1=стандарт, 2=вдвое мельче/чаще).
    const d = density && density > 0 ? density : 1;
    const f = (STUD_UNIT * STUDS_GRID) / d;
    // По умолчанию (cube/plane/wedge) — горизонтальный размер по U, вертикаль по V.
    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;
        const sy = opts.sy ?? typeDef.defaultScale.y;
        const sz = opts.sz ?? typeDef.defaultScale.z;
        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 (игрок проходит сквозь, логика по дистанции)
        const canCollide = opts.canCollide !== false && !isGdKind && !isGdSpike;
        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;

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

        // 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,
            // Подпись над объектом (задача 10) — восстанавливается из project_data.
            label: opts.label || null,
            // 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');
            }
        }

        // === Стрелка-указатель (задача 08): сохраняем настройки в data ===
        // В редакторе это маркер-сфера; реальный beam создаётся при Play через
        // activatePointers (см. ниже) / при превью.
        if (typeDef.kind === 'pointer') {
            data.pointerPreset = opts.pointerPreset || 'guide';
            data.pointerFrom = opts.pointerFrom || 'player';       // 'player' | id | ''
            data.pointerTo = opts.pointerTo || '';                 // id цели | ''
            data.textureSpeed = Number.isFinite(opts.textureSpeed) ? opts.textureSpeed : 3;
            data.curved = !!opts.curved;
            data.curveHeight = Number.isFinite(opts.curveHeight) ? opts.curveHeight : 2;
            if (mesh.material) {
                mesh.material.emissiveColor = Color3.FromHexString(color || '#ff3a3a');
            }
        }

        // === 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 теней).
        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':
            case 'pointer':
                // Лампа / эмиттер / стрелка = маленькая сфера-маркер.
                // Свет/частицы/beam создаются отдельно (light — в addPrimitive,
                // pointer — при enterPlayMode через activatePointers).
                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);
            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;
    }

    /** Применить цвет и материал. */
    _applyMaterial(mesh, typeDef, color, material, textureUrl) {
        const matName = `${mesh.name}_mat`;
        const mat = new StandardMaterial(matName, this.scene);
        mat.diffuseColor = Color3.FromHexString(color || '#888888');
        // ambient = (1,1,1) — пассивный, реагирует на scene.ambientColor.
        // Юзер крутит «Заливку теней» (sceneAmbient) → тени светлеют.
        // На прямом свете diffuse доминирует — пересвета нет если
        // sceneAmbient в разумных пределах (0..0.5).
        mat.ambientColor = new Color3(1, 1, 1);

        // Если задан textureUrl — подгружаем PNG как diffuseTexture. Это
        // используется для GD-скинов куба (например /gd/skins/cube_smile.png).
        // Цвет работает как multiplier для текстуры — для нейтрального эффекта
        // ставим белый (или сам color, чтобы можно было тонировать).
        if (textureUrl) {
            try {
                // Lazy-import: класс Texture в babylon достаём через require.
                // Babylon уже импортирован выше — используем глобал.
                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.5, 0.5, 0.5);
                break;
            case 'neon':
                mat.emissiveColor = Color3.FromHexString(color || '#888888');
                mat.specularColor = new Color3(0, 0, 0);
                break;
            case 'studs': {
                // Лего-материал: почти белая diffuse-текстура с лёгкими кружками
                // умножается на цвет меша → цвет остаётся СОЧНЫМ (как в Roblox).
                // emissive = доля цвета → цвет «светится», не тускнеет в тени.
                // Объём студов запечён в diffuse v4 (baked-тени) — bumpTexture НЕ
                // используем: normal-mapping удваивает стоимость шейдера на каждом
                // меше и почти не виден на маленьких студах. Текстуры ШАРЯТСЯ
                // (общая для cube, кэш-клон по тайлингу для форм) — без этого
                // десятки клонов роняли FPS.
                const dims = mesh._studsDims || { type: 'cube', sx: 1, sy: 1, sz: 1 };
                let dt;
                if (dims.type === 'cube' || dims.type === 'trigger') {
                    // uScale=1 — тайлинг через faceUV геометрии. Общая текстура.
                    dt = _getStudsTextures(this.scene).diffuse;
                } else {
                    const tile = _studsTiling(dims.type, dims.sx, dims.sy, dims.sz, dims.density);
                    dt = _getStudsTiledTexture(this.scene, tile.u, tile.v);
                }
                mat.diffuseTexture = dt;
                const sc = Color3.FromHexString(color || '#cccccc');
                mat.diffuseColor = sc;
                // Сочность: подмешиваем цвет в emissive (45%) — Roblox-look,
                // насыщенный даже без прямого света. specular убираем (он белит).
                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':
                mat.specularColor = new Color3(0, 0, 0);
                break;
            case 'glossy':
            default: {
                // Roblox Plastic — слабый specular, без emissive.
                // diffuse=#cccccc должно выглядеть СЕРЫМ (как в Roblox).
                // ambient (от scene 0.3 × mat.ambient 0.4) даёт цвет в тенях,
                // но не убивает контраст.
                mat.specularColor = new Color3(0.05, 0.05, 0.05);
                mat.specularPower = 64;
                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 {
            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 {
            // Lazy-import DynamicTexture
            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;

        // Позиция / поворот / размер — нужно расфризить world matrix,
        // иначе freezeStaticPrimitives() сделает mesh.position.set бессмысленным.
        const positionChanged = patch.x !== undefined || patch.y !== undefined || patch.z !== undefined;
        const transformChanged = positionChanged
            || patch.rotationX !== undefined || patch.rotationY !== undefined || patch.rotationZ !== undefined
            || patch.sx !== undefined || patch.sy !== undefined || patch.sz !== undefined;
        if (transformChanged && data._worldMatrixFrozen) {
            try { data.mesh.unfreezeWorldMatrix?.(); } catch (_) {}
            data._worldMatrixFrozen = false;
        }
        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;
        }
        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;
        }

        // === Стрелка-указатель (задача 08): настройки из инспектора ===
        // Сохраняем в data, чтобы serialize записал их в project_data, а при
        // Play _activatePointers() построил реальную стрелку с этими параметрами.
        if (data.type === 'pointer') {
            if (patch.pointerPreset !== undefined) data.pointerPreset = patch.pointerPreset;
            if (patch.pointerFrom !== undefined) data.pointerFrom = patch.pointerFrom;
            if (patch.pointerTo !== undefined) data.pointerTo = patch.pointerTo;
            if (patch.textureSpeed !== undefined) {
                const s = Number(patch.textureSpeed);
                if (Number.isFinite(s)) data.textureSpeed = s;
            }
            if (patch.curved !== undefined) data.curved = !!patch.curved;
            if (patch.curveHeight !== undefined) {
                const h = Number(patch.curveHeight);
                if (Number.isFinite(h)) data.curveHeight = h;
            }
        }

        // Задача 10: подпись над объектом (label) — редактируется в инспекторе,
        // сериализуется, при Play создаётся биндинг без скрипта.
        // data.label = { enabled, binding:'static'|'timer'|'save'|'hp'|'formula',
        //   params:{...}, preset, height }.
        if (patch.label !== undefined) {
            data.label = patch.label || null;
        }

        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);
        const newMesh = this._createMeshForType(typeDef, data.id, data.sx, data.sy, data.sz, data.material, data.studDensity);
        newMesh.position = oldPos;
        if (oldRot) newMesh.rotation = oldRot;
        // studs — материал пересоздаём заново (свежий faceUV/тайлинг + текстура
        // могла быть временно спрятана во время scale-drag). Иначе переносим старый.
        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 уже выставлены выше (через _applyMaterial
        // на новом меше с правильным faceUV/тайлингом). Для не-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())
            // Исключаем скриптовые спавны — они эфемерные и не должны
            // попадать в project_data (иначе при каждом Play копятся дубли).
            .filter(d => !d._scriptSpawned)
            .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,
            // folderId — принадлежность к папке. БЕЗ него примитивы вываливались
            // из папки после Play/Stop (снапшот терял группировку). Баг 2026-06-05.
            ...(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 } : {}),
            // Подпись над объектом (задача 10) — если включена.
            ...(d.label && d.label.enabled ? { label: d.label } : {}),
            // Параметры лампы (только для type='light', иначе undefined)
            ...(d.light ? { brightness: d.brightness, range: d.range } : {}),
            // Параметр эмиттера (только для type='emitter')
            ...(d.effect !== undefined ? { effect: d.effect } : {}),
            // Параметры билборда (только для 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();
    }
}
