/**
 * PhysicsAABB — простая ручная физика столкновений для voxel-мира.
 *
 * AABB (Axis-Aligned Bounding Box) — игрок-капсула с прямоугольной коллизией.
 * Solver:
 *   1. Sub-stepping: разбиваем большое перемещение на маленькие шаги
 *      (≤0.25 единицы за шаг) — нельзя пропустить блок при высокой скорости.
 *   2. На каждом шаге двигаем независимо по осям X → Z → Y и при ударе
 *      прижимаемся к границе блока (slide-along-walls работает естественно).
 *   3. Floor (y=0) — фиксированная плоскость, ниже не пускаем.
 *
 * Это даёт плавное движение без рывков и без проваливаний при беге.
 */

import { buildOBBAxes, aabbIntersectsOBB } from './OBBCollision';
import { Ray, Vector3 } from '@babylonjs/core';

const SUB_STEP = 0.25;       // максимум 0.25 единицы за один шаг
const EPS = 0.0001;          // микроскопический отступ от границы блока

/**
 * Материалы террейна, СКВОЗЬ которые можно ходить (нет коллизии).
 *  - water: можно плавать, не блокирует движение
 *  - декорации (цветы/гриб/высокая трава/листья): не должны мешать игроку,
 *    как в Minecraft. Иначе персонаж упрётся в каждый кустик/каждое дерево.
 */
const NON_SOLID_TERRAIN = new Set([
    'water',
    'leaves',
    'leaves_orange',
    'flower_red',
    'flower_blue',
    'flower_yellow',
    'mushroom_red',
    'tall_grass',
]);

export class PhysicsAABB {
    constructor(blockManager) {
        this.blockManager = blockManager;
        this.primitiveManager = null;
        this.modelManager = null;
        this.terrainManager = null;
        // Размер voxel'а террейна в мире (берётся из TerrainManager при подключении).
        // Хранится здесь чтобы не делать import — модуль PhysicsAABB должен
        // оставаться независимым от concrete-движка террейна.
        this.terrainVoxelSize = 0.5;
        this.floorY = 0;
        // Граница видимого пола baseplate: 80x80 (от -40 до 40).
        // За её пределами пол НЕ работает — игрок проваливается в пустоту.
        this.floorHalf = 40;
        // Если false — пол baseplate физически отсутствует, игрок проваливается.
        this.floorEnabled = true;
    }

    /** Подключить PrimitiveManager — его cube/sphere/... тоже участвуют в физике. */
    setPrimitiveManager(pm) {
        this.primitiveManager = pm;
    }

    /** Подключить ModelManager — модели тоже могут быть твёрдыми (canCollide). */
    setModelManager(mm) {
        this.modelManager = mm;
    }

    /** Подключить UserModelManager (пользовательские voxel-модели). */
    setUserModelManager(um) {
        this.userModelManager = um;
    }

    /** Подключить TerrainManager. voxelSize — размер одной ячейки террейна в мире. */
    setTerrainManager(tm, voxelSize) {
        this.terrainManager = tm;
        if (typeof voxelSize === 'number' && voxelSize > 0) {
            this.terrainVoxelSize = voxelSize;
        }
    }

    /** Подключить VoxelWorld (Этап 4 voxel-движка) — chunk-based террейн.
     *  Когда установлен — физика проверяет коллизии через voxelWorld.layers.terrain
     *  в дополнение к legacy terrainManager (одно из них может быть пустым). */
    setVoxelWorld(world) {
        this.voxelWorld = world;
    }

    /**
     * Проверить пересечение AABB только с деревьями (без terrain/blocks).
     * Используется в smooth-terrain ветке X/Z коллизии: если игрок ВНУТРИ
     * tree-AABB, нельзя пропускать его как "склон под ногами".
     */
    _collidesTreeAt(cx, cy, cz, hw, hh, hd) {
        if (!this._smoothTreesGrid) return false;
        const CELL = this._smoothTreesGridCell;
        const minCx = Math.floor((cx - hw) / CELL);
        const maxCx = Math.floor((cx + hw) / CELL);
        const minCz = Math.floor((cz - hd) / CELL);
        const maxCz = Math.floor((cz + hd) / CELL);
        const topY = cy + hh;
        const bottomY = cy - hh;
        for (let gx = minCx; gx <= maxCx; gx++) {
            for (let gz = minCz; gz <= maxCz; gz++) {
                const arr = this._smoothTreesGrid.get(`${gx},${gz}`);
                if (!arr) continue;
                for (const t of arr) {
                    const tMinX = t.x - t.halfW;
                    const tMaxX = t.x + t.halfW;
                    const tMinZ = t.z - t.halfD;
                    const tMaxZ = t.z + t.halfD;
                    const tMinY = t.baseY;
                    const tMaxY = t.baseY + t.halfH * 2;
                    if (cx + hw > tMinX && cx - hw < tMaxX
                        && cz + hd > tMinZ && cz - hd < tMaxZ
                        && topY > tMinY && bottomY < tMaxY) {
                        return true;
                    }
                }
            }
        }
        return false;
    }

    /**
     * Установить список AABB деревьев smooth-decoration.
     * trees: Array<{x,z, halfW, halfH, halfD, baseY}> где
     *   - (x, baseY, z) — низ цилиндра
     *   - halfW/halfH/halfD — половины размеров AABB
     * Используется для коллизий — игрок не проходит сквозь деревья.
     */
    setSmoothDecoTrees(trees) {
        this.smoothDecoTrees = trees || null;
        if (trees && trees.length > 0) {
            // Spatial-индекс для быстрой выборки. Cell ~16м (достаточно крупный
            // чтобы поиск был O(1), но не слишком — иначе много candidates).
            const CELL = 16;
            this._smoothTreesGridCell = CELL;
            const grid = new Map();
            for (let i = 0; i < trees.length; i++) {
                const t = trees[i];
                const cx = Math.floor(t.x / CELL);
                const cz = Math.floor(t.z / CELL);
                const key = `${cx},${cz}`;
                let arr = grid.get(key);
                if (!arr) { arr = []; grid.set(key, arr); }
                arr.push(t);
            }
            this._smoothTreesGrid = grid;
        } else {
            this._smoothTreesGrid = null;
        }
    }

    /** Подключить RobloxTerrain (smooth-ландшафт через DensityGrid).
     *  Физика проверяет density-grid: solid ячейка = коллизия. */
    setRobloxTerrain(rt) {
        this.robloxTerrain = rt;
        console.log('[PhysicsAABB] setRobloxTerrain:', rt ? 'connected' : 'null');
    }

    /**
     * Найти Y поверхности RobloxTerrain под точкой (worldX, worldZ).
     * Raycast сверху-вниз через RobloxTerrain mesh-ы.
     * @returns {number|null} Y или null если нет terrain под точкой.
     */
    _sampleRobloxSurface(worldX, worldZ) {
        if (!this.robloxTerrain || !this.robloxTerrain.scene) return null;
        const scene = this.robloxTerrain.scene;
        const pickPred = (m) => !!(m.metadata && m.metadata._isRobloxTerrain);
        const ray = new Ray(
            new Vector3(worldX, 1000, worldZ),
            new Vector3(0, -1, 0),
            2000,
        );
        const hit = scene.pickWithRay(ray, pickPred);
        if (hit && hit.hit && hit.pickedPoint) return hit.pickedPoint.y;
        return null;
    }

    /**
     * Двигаем AABB на (dx, dy, dz) с sub-stepping.
     * Возвращает { x, y, z, hitX, hitY, hitZ, onGround }.
     */
    moveAABB(pos, halfW, halfH, halfD, dx, dy, dz) {
        // Если spatial-индекс устарел — перестраиваем ОДИН раз ЗДЕСЬ, перед
        // sub-step циклом. Иначе _getSpatialCandidates может среагировать на
        // dirty-флаг или 50мс-таймер ВНУТРИ bisection и пересобрать grid 568
        // примитивов прямо в середине физики прыжка → жёсткие фризы.
        const SPATIAL_CELL_SIZE = 8;
        const now = performance.now();
        if (!this._spatialBuildAt) this._spatialBuildAt = 0;
        const hasPrim = this.primitiveManager && this.primitiveManager.instances.size > 0;
        const hasModel = this.modelManager && this.modelManager.instances.size > 0;
        const hasUserModel = this.userModelManager && this.userModelManager.instances.size > 0;
        if ((hasPrim || hasModel || hasUserModel) && (this._spatialDirty || !this._spatialGrid
            || (now - this._spatialBuildAt) > 50)) {
            this._buildSpatialGrid(SPATIAL_CELL_SIZE);
            this._spatialBuildAt = now;
        }

        let { x, y, z } = pos;
        let hitX = false, hitY = false, hitZ = false;
        // Суммарная высота на которую auto-step «телепортировал» игрока
        // за этот вызов moveAABB. PlayerController использует это значение
        // чтобы плавно интерполировать визуальный mesh (без неё камера
        // и аватар «дёргаются» вверх рывком).
        let steppedUpBy = 0;
        // Высота на которую игрок автоматически залезает на препятствие
        // если движется горизонтально и упёрся в стену. Аналог Roblox/Minecraft
        // «полупрыжок» — позволяет бежать через voxel-террейн и низкие блоки
        // без застревания на каждой ступеньке.
        //
        // 0.55м = чуть выше 0.5м (один voxel или один блок). При желании можно
        // поднять до 1.05м (два voxel'а), но это делает игрока «летающим»
        // через низкие препятствия — менее естественно.
        //
        // Применяется ТОЛЬКО когда:
        //   1. Игрок на земле (wasOnGround) — нельзя степать в прыжке
        //   2. Игрок движется ВНИЗ или ровно (sy <= 0) — нельзя степать
        //      когда прыгаем вверх
        //   3. Препятствие имеет горизонтальный край ≤ stepHeight
        const STEP_UP_MAX = 0.55;
        // Проверка «был на земле в начале» — стандартный нижний raycast
        // (тот же что в конце функции для onGround результата)
        const wasOnGround = !this._collidesAt(x, y, z, halfW, halfH, halfD)
            && this._collidesAt(x, y - 0.05, z, halfW, halfH, halfD);

        // === UNSTUCK для smooth terrain: если raycast в текущей точке даёт
        // surface ВЫШЕ нашего bottomY — значит мы провалились в землю.
        // Просто телепортируемся на поверхность (как auto-spawn).
        // ВАЖНО: делается до общего UNSTUCK ниже, потому что raycast в этой
        // ситуации делает каждый _collidesAt true, и общий UNSTUCK не находит
        // выход через "up"/"horizontal".
        if (this.robloxTerrain && this.robloxTerrain.grid && this.robloxTerrain.scene) {
            const sY = this._sampleRobloxSurface(x, z);
            if (sY !== null && sY > y - halfH + 0.1) {
                // Поверхность выше bottomY → провалились. Поднимаемся на surface.
                y = sY + halfH + 0.01;
            }
        }

        // === UNSTUCK: если игрок УЖЕ застрял в препятствии (например движущаяся
        // платформа сдвинулась через него за прошлый кадр) — выталкиваем по
        // ближайшей оси, чтобы он мог двигаться.
        // ВАЖНО: для tree-collision НЕ перебираем 6 направлений (это
        // телепортировало игрока на ±3м вбок/назад). Вместо этого
        // выталкиваем по направлению ОТ ствола (centroid → player).
        if (this._collidesAt(x, y, z, halfW, halfH, halfD)) {
            const stuckInTree = this._collidesTreeAt(x, y, z, halfW, halfH, halfD);
            if (stuckInTree && this._smoothTreesGrid) {
                // Находим ближайшее дерево и выталкиваем в радиальном направлении
                const CELL = this._smoothTreesGridCell;
                const minCx = Math.floor((x - halfW) / CELL);
                const maxCx = Math.floor((x + halfW) / CELL);
                const minCz = Math.floor((z - halfD) / CELL);
                const maxCz = Math.floor((z + halfD) / CELL);
                let bestT = null;
                let bestD2 = Infinity;
                for (let gx = minCx; gx <= maxCx; gx++) {
                    for (let gz = minCz; gz <= maxCz; gz++) {
                        const arr = this._smoothTreesGrid.get(`${gx},${gz}`);
                        if (!arr) continue;
                        for (const t of arr) {
                            const dx2 = t.x - x;
                            const dz2 = t.z - z;
                            const d2 = dx2 * dx2 + dz2 * dz2;
                            if (d2 < bestD2) { bestD2 = d2; bestT = t; }
                        }
                    }
                }
                if (bestT) {
                    // Направление от центра дерева к игроку
                    let dx2 = x - bestT.x;
                    let dz2 = z - bestT.z;
                    const dlen = Math.sqrt(dx2 * dx2 + dz2 * dz2);
                    if (dlen < 0.01) { dx2 = 1; dz2 = 0; }   // fallback если точно в центре
                    else { dx2 /= dlen; dz2 /= dlen; }
                    // Выталкиваем небольшими шагами 0.05м, максимум 1м
                    const PS = 0.05;
                    for (let k = 1; k <= 20; k++) {
                        const tx = x + dx2 * k * PS;
                        const tz = z + dz2 * k * PS;
                        if (!this._collidesAt(tx, y, tz, halfW, halfH, halfD)) {
                            x = tx; z = tz;
                            break;
                        }
                    }
                }
            } else {
                // Стандартный 6-directional UNSTUCK для блоков/платформ.
                const PUSH_STEP = 0.1;
                const PUSH_MAX = 30;
                const dirs = [
                    [0,  1, 0],
                    [1,  0, 0], [-1, 0, 0],
                    [0,  0, 1], [0,  0, -1],
                    [0, -1, 0],
                ];
                let pushed = false;
                for (const [ux, uy, uz] of dirs) {
                    for (let k = 1; k <= PUSH_MAX; k++) {
                        const tx = x + ux * k * PUSH_STEP;
                        const ty = y + uy * k * PUSH_STEP;
                        const tz = z + uz * k * PUSH_STEP;
                        if (!this._collidesAt(tx, ty, tz, halfW, halfH, halfD)) {
                            x = tx; y = ty; z = tz;
                            pushed = true;
                            break;
                        }
                    }
                    if (pushed) break;
                }
            }
        }

        // Считаем сколько sub-шагов нужно
        const maxComponent = Math.max(Math.abs(dx), Math.abs(dy), Math.abs(dz));
        const steps = Math.max(1, Math.ceil(maxComponent / SUB_STEP));
        const sx = dx / steps;
        const sy = dy / steps;
        const sz = dz / steps;

        // Helper для step-up. При блокировании горизонтального движения
        // пробуем поднять AABB на STEP_UP_MAX. Если на этой высоте по
        // тому же направлению нет препятствия — значит это лестница/уступ,
        // делаем «полупрыжок»: возвращаем новую Y.
        //
        // Возвращает {ok: true, newY} если step-up удался, {ok: false} иначе.
        // tryX/tryZ — пробное продвижение по соответствующей оси (то что мы
        // пытались сделать но упёрлись).
        const tryStepUp = (cx, cy, cz, tryX, tryZ) => {
            if (!wasOnGround) return { ok: false };
            if (sy > 0) return { ok: false };           // прыгаем вверх — не степаем
            // Перебираем высоту от маленькой к большой (бинарный поиск избыточен,
            // достаточно фиксированных шагов: 0.15, 0.30, 0.45, 0.55м)
            const heights = [0.15, 0.30, 0.45, STEP_UP_MAX];
            for (const h of heights) {
                const nyTop = cy + h;
                // Сначала убедимся что на новой высоте «голова» не упирается в потолок
                if (this._collidesAt(cx, nyTop, cz, halfW, halfH, halfD)) continue;
                // Затем — на новой высоте можно продвинуться вперёд?
                if (this._collidesAt(cx + tryX, nyTop, cz + tryZ, halfW, halfH, halfD)) continue;
                // Можно. Проверяем что после step-up игрок «приземлится» на
                // что-то (не висит в воздухе) — иначе мы запрыгнули на
                // потолок узкой щели.
                if (!this._collidesAt(cx + tryX, nyTop - h - 0.05, cz + tryZ, halfW, halfH, halfD)) {
                    // Под нами пустота на полную высоту степа — значит
                    // это не уступ, а пролёт. Пропускаем.
                    continue;
                }
                return { ok: true, newY: nyTop, dx: tryX, dz: tryZ };
            }
            return { ok: false };
        };

        // Флаг: используем surface-follow для smooth terrain (raycast-based).
        // ВАЖНО: НЕ требуем wasOnGround. Хватит того что:
        //   а) есть smooth-terrain
        //   б) игрок не прыгает (sy <= 0, т.е. не движется вверх)
        //   в) поверхность РЯДОМ (в пределах STEP_UP_MAX от bottomY)
        //
        // wasOnGround не подходит: после прошлого surface-follow гравитация
        // даёт y ниже surface → _collidesAt(y) возвращает true → wasOnGround=false
        // → surface-follow ОТКЛЮЧАЕТСЯ → vy копится → onGround мерцает →
        // animation walk↔sprint каждый кадр (как видно в [Anim] логе).
        let useSurfaceFollow = false;
        if (this.robloxTerrain && this.robloxTerrain.grid
            && this.robloxTerrain.scene && sy <= 0) {
            const surfYStart = this._sampleRobloxSurface(x, z);
            if (surfYStart !== null) {
                const distToSurface = (y - halfH) - surfYStart;
                // Если ноги в пределах STEP_UP_MAX от поверхности → мы на склоне.
                // Включает случай "только что приземлился" (distToSurface отрицателен)
                // и "чуть-чуть оторвался" (positive but small).
                if (distToSurface > -STEP_UP_MAX && distToSurface < STEP_UP_MAX) {
                    useSurfaceFollow = true;
                }
            }
        }

        for (let i = 0; i < steps; i++) {
            // === X ===
            if (sx !== 0) {
                const nx = x + sx;
                // На smooth-terrain: НЕ применяем horizontal-collision если
                // single-point pickWithRay в новой точке даёт surface ниже
                // top AABB (т.е. это просто склон, не стена). В этом случае
                // принимаем движение, surface-follow внизу подгонит Y.
                // ВАЖНО: если коллизия С ДЕРЕВОМ (tree-AABB), surface-follow
                // НЕ обходит её — иначе игрок проскальзывает сквозь стволы.
                let horizBlocked = false;
                if (this._collidesAt(nx, y, z, halfW, halfH, halfD)) {
                    const hitTree = this._collidesTreeAt(nx, y, z, halfW, halfH, halfD);
                    if (useSurfaceFollow && !hitTree) {
                        // Пробуем surface в новой точке
                        const sY = this._sampleRobloxSurface(nx, z);
                        if (sY !== null && sY < y + halfH - 0.1) {
                            // Surface ниже головы → проходим, Y подгоним позже.
                            x = nx;
                        } else {
                            horizBlocked = true;
                        }
                    } else {
                        horizBlocked = true;
                    }
                } else {
                    x = nx;
                }
                if (horizBlocked) {
                    // Попытка step-up: может это просто уступ перед нами.
                    // НО для деревьев step-up отключаем — нельзя залезать на ствол.
                    const onTreeBlock = this._collidesTreeAt(x + sx, y, z, halfW, halfH, halfD);
                    const su = onTreeBlock ? { ok: false } : tryStepUp(x, y, z, sx, 0);
                    if (su.ok) {
                        steppedUpBy += su.newY - y;
                        x = nx;
                        y = su.newY;
                    } else {
                        let lo = 0, hi = sx;
                        for (let k = 0; k < 8; k++) {
                            const mid = (lo + hi) / 2;
                            if (this._collidesAt(x + mid, y, z, halfW, halfH, halfD)) hi = mid;
                            else lo = mid;
                        }
                        // Backoff: для дерева отступаем на 0.02м от границы.
                        // Иначе следующий кадр UNSTUCK сочтёт игрока застрявшим
                        // и телепортирует наружу (вправо/влево/назад на ±3м).
                        if (onTreeBlock) {
                            const sign = sx > 0 ? 1 : -1;
                            lo = Math.max(0, lo - 0.02) * sign / Math.abs(sign || 1);
                            // ↑ упрощённо: уменьшаем модуль на 0.02
                        }
                        x += lo;
                        hitX = true;
                    }
                }
            }

            // === Z ===
            if (sz !== 0) {
                const nz = z + sz;
                let horizBlocked = false;
                if (this._collidesAt(x, y, nz, halfW, halfH, halfD)) {
                    const hitTree = this._collidesTreeAt(x, y, nz, halfW, halfH, halfD);
                    if (useSurfaceFollow && !hitTree) {
                        const sY = this._sampleRobloxSurface(x, nz);
                        if (sY !== null && sY < y + halfH - 0.1) {
                            z = nz;
                        } else {
                            horizBlocked = true;
                        }
                    } else {
                        horizBlocked = true;
                    }
                } else {
                    z = nz;
                }
                if (horizBlocked) {
                    const onTreeBlock = this._collidesTreeAt(x, y, z + sz, halfW, halfH, halfD);
                    const su = onTreeBlock ? { ok: false } : tryStepUp(x, y, z, 0, sz);
                    if (su.ok) {
                        steppedUpBy += su.newY - y;
                        z = nz;
                        y = su.newY;
                    } else {
                        let lo = 0, hi = sz;
                        for (let k = 0; k < 8; k++) {
                            const mid = (lo + hi) / 2;
                            if (this._collidesAt(x, y, z + mid, halfW, halfH, halfD)) hi = mid;
                            else lo = mid;
                        }
                        if (onTreeBlock) {
                            lo = Math.max(0, Math.abs(lo) - 0.02) * (sz > 0 ? 1 : -1);
                        }
                        z += lo;
                        hitZ = true;
                    }
                }
            }

            // === Y ===
            if (sy !== 0) {
                const ny = y + sy;
                if (this._collidesAt(x, ny, z, halfW, halfH, halfD)) {
                    let lo = 0, hi = sy;
                    for (let k = 0; k < 8; k++) {
                        const mid = (lo + hi) / 2;
                        if (this._collidesAt(x, y + mid, z, halfW, halfH, halfD)) hi = mid;
                        else lo = mid;
                    }
                    y += lo;
                    hitY = true;
                } else {
                    y = ny;
                }
            }
        }

        // === Snap-down (для voxel-step-up) ===
        // Если игрок был на земле и НЕ прыгает (sy <= 0) — после движения
        // ищем поверхность под ногами в диапазоне до STEP_UP_MAX вниз.
        // Работает для voxel-уступов (smooth terrain уже обработан выше).
        if (wasOnGround && sy <= 0) {
            const maxSnap = STEP_UP_MAX + 0.05;
            if (!this._collidesAt(x, y - 0.01, z, halfW, halfH, halfD)) {
                let lo = 0, hi = maxSnap;
                for (let k = 0; k < 8; k++) {
                    const mid = (lo + hi) / 2;
                    if (this._collidesAt(x, y - mid, z, halfW, halfH, halfD)) hi = mid;
                    else lo = mid;
                }
                if (lo > 0) y -= lo;
            }
        }

        // === Surface-follow для smooth terrain ===
        // surfaceFollowed = true → PlayerController обнулит velocityY,
        // иначе гравитация будет копиться → вибрация на склоне.
        let surfaceFollowed = false;
        if (useSurfaceFollow) {
            const isMoving = (dx !== 0) || (dz !== 0);
            const surfY = this._sampleRobloxSurface(x, z);
            if (surfY !== null) {
                const desiredY = surfY + halfH;
                const delta = desiredY - y;
                if (isMoving) {
                    if (delta > -STEP_UP_MAX * 2 && delta < STEP_UP_MAX + 0.05) {
                        y = desiredY;
                        surfaceFollowed = true;
                    }
                } else {
                    if (delta > 0.1 && delta < STEP_UP_MAX + 0.05) {
                        y = desiredY;
                        surfaceFollowed = true;
                    }
                }
            }
        }

        // === Пол baseplate (y=0) ===
        // Если AABB опустилась ниже floorY+halfH — поднимаем,
        // но только в пределах видимого baseplate'а. За его границами
        // игрок проваливается в пустоту (как в Roblox).
        if (this.floorEnabled) {
            const onBaseplate = Math.abs(x) <= this.floorHalf && Math.abs(z) <= this.floorHalf;
            if (onBaseplate && y - halfH < this.floorY) {
                y = this.floorY + halfH + EPS;
                hitY = true;
            }
        }

        // === Определяем onGround ===
        // Игрок на земле если чуть-чуть ниже AABB есть препятствие.
        // ВАЖНО: при surface-follow на smooth-terrain считаем onGround=true
        // безусловно — иначе float-сравнения в raycast дают мерцание true/false,
        // PlayerController меняет анимацию idle→jump→walk → ноги дёргаются.
        const onGround = surfaceFollowed
            || this._collidesAt(x, y - 0.05, z, halfW, halfH, halfD);

        // === Определяем onCeiling ===
        // Игрок «приземлился» к потолку если чуть-чуть выше AABB есть препятствие.
        // Используется только в режиме перевёрнутой гравитации (Кубикон Dash
        // после blueOrb/gravityPortal). Когда gDir=-1, потолок становится «полом».
        const onCeiling = this._collidesAt(x, y + 0.05, z, halfW, halfH, halfD);

        // Если стоим на чём-то — пробуем определить ИМЕННО на каком объекте
        // (блок/примитив/модель). Для движущихся платформ это нужно чтобы
        // PlayerController смог трекать их положение и двигать игрока вместе.
        // При onCeiling берём «потолок» как ground-objet (для stick-эффекта).
        let groundData = null;
        if (onGround) {
            groundData = this._findGroundCandidate(x, y - 0.05, z, halfW, halfH, halfD);
        } else if (onCeiling) {
            groundData = this._findGroundCandidate(x, y + 0.05, z, halfW, halfH, halfD);
        }

        return { x, y, z, hitX, hitY, hitZ, onGround, onCeiling, groundData, steppedUpBy, surfaceFollowed };
    }

    /**
     * Найти data объекта под AABB (если он не блок-сетка). Возвращает
     * { kind: 'primitive'|'model', data } или null. Используется для
     * «прилипания» игрока к движущимся платформам.
     * Блоки воксельной сетки не двигаются — для них null (стандартное поведение).
     */
    _findGroundCandidate(cx, cy, cz, hw, hh, hd) {
        const candidates = this._getSpatialCandidates(cx, cz, hw, hd);
        if (!candidates) return null;
        // Низ AABB игрока (вызывающий код передал cy = y - 0.05, поэтому
        // фактический низ ноги игрока ≈ cy + 0.05 - hh).
        const playerBottom = cy + 0.05 - hh;
        // Допустимый зазор между ногой игрока и верхом платформы — 0.25м.
        // Если больше — значит игрок касается БОКА платформы, а не стоит
        // на ней сверху. В этом случае нельзя считать платформу «грунтом»
        // (иначе stick-механика тащит игрока в бок и он застревает).
        const TOP_TOLERANCE = 0.25;
        for (const item of candidates) {
            if (item.kind === 'primitive') {
                const data = item.data;
                if (data.canCollide === false) continue;
                if (data.type === 'trigger' || data.type === 'checkpoint') continue;
                if (!this._aabbIntersectsPrimitive(cx, cy, cz, hw, hh, hd, data)) continue;
                // Верх примитива (без учёта вращения — для платформ с
                // rotation это приближение, но для типичных платформ ОК).
                const platTop = (data.y || 0) + (data.sy || 1) * 0.5;
                if (Math.abs(playerBottom - platTop) <= TOP_TOLERANCE) {
                    return { kind: 'primitive', data };
                }
                // Иначе — это бок/верх не совпадает; игнорируем как ground.
            } else if (item.kind === 'model') {
                const data = item.data;
                if (data.canCollide === false) continue;
                if (!this._aabbIntersectsModel(cx, cy, cz, hw, hh, hd, data)) continue;
                const local = data.localAABB;
                const platTop = (data.y || 0) + ((local && local.maxY) ?? 1);
                if (Math.abs(playerBottom - platTop) <= TOP_TOLERANCE) {
                    return { kind: 'model', data };
                }
            }
        }
        return null;
    }

    /**
     * Проверка пересечения AABB c voxel-блоками или полом.
     */
    _collidesAt(cx, cy, cz, hw, hh, hd) {
        // Пол baseplate — действует только в пределах видимого квадрата 80x80.
        // За его границами «пола нет», игрок свободно падает.
        if (this.floorEnabled) {
            const onBaseplate = Math.abs(cx) <= this.floorHalf && Math.abs(cz) <= this.floorHalf;
            if (onBaseplate && cy - hh < this.floorY - EPS) return true;
        }

        // Если blockManager отсутствует — пропускаем блок-проверку, но
        // terrain/primitives/modelы всё равно надо проверять ниже.
        if (this.blockManager) {

        // Координаты клеток которые AABB может пересекать.
        // Блок (gx) занимает X: gx-0.5..gx+0.5. AABB X: cx-hw..cx+hw.
        // Пересечение если gx-0.5 < cx+hw И gx+0.5 > cx-hw → gx-0.5 < cx+hw, gx+0.5 > cx-hw.
        // → gx > cx-hw-0.5, gx < cx+hw+0.5 → gxMin = ceil(cx-hw-0.5+EPS), gxMax = floor(cx+hw+0.5-EPS).
        // Для надёжности используем floor границ:
        const gxMin = Math.floor(cx - hw + 0.5 + EPS);
        const gxMax = Math.floor(cx + hw + 0.5 - EPS);
        const gzMin = Math.floor(cz - hd + 0.5 + EPS);
        const gzMax = Math.floor(cz + hd + 0.5 - EPS);
        // Блок (gy) занимает Y: gy..gy+1. AABB Y: cy-hh..cy+hh.
        // Пересечение если gy < cy+hh, gy+1 > cy-hh.
        const gyMin = Math.floor(cy - hh + EPS);
        const gyMax = Math.floor(cy + hh - EPS);

        for (let gx = gxMin; gx <= gxMax; gx++) {
            for (let gy = gyMin; gy <= gyMax; gy++) {
                if (gy < 0) continue;
                for (let gz = gzMin; gz <= gzMax; gz++) {
                    const blockMesh = this.blockManager.blocks.get(`${gx},${gy},${gz}`);
                    if (!blockMesh) continue;
                    // Unanchored блоки в Play двигаются независимо — их клетка
                    // больше не статичное препятствие. Они проверяются ниже как
                    // dynamic body по реальной позиции mesh.
                    if (blockMesh.metadata?.anchored === false) continue;
                    if (blockMesh.metadata?.canCollide === false) continue;
                    return true;
                }
            }
        }
        } // end if (this.blockManager)

        // Voxel-террейн. Каждая ячейка — куб со стороной voxelSize, не 1.
        // У террейна voxel-индексы x,y,z целые, мировой куб занимает
        // [x*S..(x+1)*S] по каждой оси (где S = voxelSize).
        // AABB X: cx-hw..cx+hw → пересекает voxel-индексы:
        //   gxMin = floor((cx - hw + EPS) / S)
        //   gxMax = floor((cx + hw - EPS) / S)
        // Аналогично для Y/Z. Никакого +0.5 как для блоков — там 1×1×1.
        if (this.terrainManager && this.terrainManager.voxels?.size) {
            const S = this.terrainVoxelSize;
            const tgxMin = Math.floor((cx - hw + EPS) / S);
            const tgxMax = Math.floor((cx + hw - EPS) / S);
            const tgyMin = Math.floor((cy - hh + EPS) / S);
            const tgyMax = Math.floor((cy + hh - EPS) / S);
            const tgzMin = Math.floor((cz - hd + EPS) / S);
            const tgzMax = Math.floor((cz + hd - EPS) / S);
            const tvox = this.terrainManager.voxels;
            for (let gx = tgxMin; gx <= tgxMax; gx++) {
                for (let gy = tgyMin; gy <= tgyMax; gy++) {
                    if (gy < 0) continue;
                    for (let gz = tgzMin; gz <= tgzMax; gz++) {
                        // Воду пропускаем — сквозь неё можно ходить (можно
                        // плавать; коллизия воды слишком ограничит игрока).
                        // Декорации (цветы/гриб/высокая трава/листья) — тоже
                        // проходим насквозь, как в Minecraft: они не должны
                        // мешать движению, иначе игрок упрётся в кустик.
                        const mat = tvox.get(`${gx},${gy},${gz}`);
                        if (!mat) continue;
                        if (NON_SOLID_TERRAIN.has(mat)) continue;
                        return true;
                    }
                }
            }
        }

        // RobloxTerrain — scene.pickWithRay по центру AABB.
        //
        // Раньше пробовали heightmap-cache, но vertex'ы Surface Nets
        // разреженные — heightmap имел дыры (-Infinity) и коллизии
        // отключались. Возвращаемся к raycast — он надёжен, а на 1
        // точке достаточно дёшев (~100мкс).
        //
        // 1 точка (центр AABB) + step-up + snap-down достаточны для
        // ходьбы по холмам.
        if (this.robloxTerrain && this.robloxTerrain.grid && this.robloxTerrain.scene) {
            const scene = this.robloxTerrain.scene;
            const topY = cy + hh;
            const bottomY = cy - hh;
            const rayStartY = topY + 50;
            const rayLen = rayStartY - (bottomY - 0.5);
            const rayDir = new Vector3(0, -1, 0);
            const pickPred = (m) => !!(m.metadata && m.metadata._isRobloxTerrain);
            const ray = new Ray(new Vector3(cx, rayStartY, cz), rayDir, rayLen);
            const hit = scene.pickWithRay(ray, pickPred);
            if (hit && hit.hit && hit.pickedPoint) {
                if (hit.pickedPoint.y > bottomY) return true;
            }
        }

        // Smooth-deco trees — узкие AABB-цилиндры вокруг стволов.
        // Используем spatial grid (16м-cells) чтобы не перебирать всё дерево.
        if (this._smoothTreesGrid) {
            const CELL = this._smoothTreesGridCell;
            const minCx = Math.floor((cx - hw) / CELL);
            const maxCx = Math.floor((cx + hw) / CELL);
            const minCz = Math.floor((cz - hd) / CELL);
            const maxCz = Math.floor((cz + hd) / CELL);
            const topY = cy + hh;
            const bottomY = cy - hh;
            for (let gx = minCx; gx <= maxCx; gx++) {
                for (let gz = minCz; gz <= maxCz; gz++) {
                    const arr = this._smoothTreesGrid.get(`${gx},${gz}`);
                    if (!arr) continue;
                    for (const t of arr) {
                        // AABB-overlap: проверяем XZ + Y вертикальное пересечение
                        const tMinX = t.x - t.halfW;
                        const tMaxX = t.x + t.halfW;
                        const tMinZ = t.z - t.halfD;
                        const tMaxZ = t.z + t.halfD;
                        const tMinY = t.baseY;
                        const tMaxY = t.baseY + t.halfH * 2;
                        if (cx + hw > tMinX && cx - hw < tMaxX
                            && cz + hd > tMinZ && cz - hd < tMaxZ
                            && topY > tMinY && bottomY < tMaxY) {
                            return true;
                        }
                    }
                }
            }
        }

        // VoxelWorld (Этап 4 voxel-движка) — chunk-based террейн.
        // Когда генератор использовал WorldGenerator → terrain в чанках,
        // а не в legacy terrainManager.voxels. Проверяем коллизии тут.
        if (this.voxelWorld) {
            const terrainLayer = this.voxelWorld.getLayer('terrain');
            if (terrainLayer && terrainLayer.chunks.size > 0) {
                const S = terrainLayer.voxelSize;
                const vgxMin = Math.floor((cx - hw + EPS) / S);
                const vgxMax = Math.floor((cx + hw - EPS) / S);
                const vgyMin = Math.floor((cy - hh + EPS) / S);
                const vgyMax = Math.floor((cy + hh - EPS) / S);
                const vgzMin = Math.floor((cz - hd + EPS) / S);
                const vgzMax = Math.floor((cz + hd - EPS) / S);
                for (let gx = vgxMin; gx <= vgxMax; gx++) {
                    for (let gy = vgyMin; gy <= vgyMax; gy++) {
                        if (gy < 0) continue;
                        for (let gz = vgzMin; gz <= vgzMax; gz++) {
                            const matId = terrainLayer.getVoxel(gx, gy, gz);
                            if (!matId) continue;
                            if (NON_SOLID_TERRAIN.has(matId)) continue;
                            return true;
                        }
                    }
                }
            }
        }

        // Unanchored блоки — их позиция уже не привязана к клетке (они летают
        // как тела). Проверяем AABB-vs-AABB по реальной mesh.position.
        // ОПТИМИЗАЦИЯ: BlockManager поддерживает Set _unanchoredBlocks — обходим
        // только их (обычно 0..несколько штук), а не всю Map (~500 блоков).
        if (this.blockManager?._unanchoredBlocks?.size) {
            for (const mesh of this.blockManager._unanchoredBlocks) {
                if (!mesh.metadata) continue;
                if (mesh.metadata.canCollide === false) continue;
                const bx = mesh.position.x, by = mesh.position.y, bz = mesh.position.z;
                if (cx + hw > bx - 0.5 && cx - hw < bx + 0.5
                 && cy + hh > by - 0.5 && cy - hh < by + 0.5
                 && cz + hd > bz - 0.5 && cz - hd < bz + 0.5) return true;
            }
        }

        // Примитивы и модели — обходим через spatial-bucket grid.
        // Без него каждый sub-step делает O(N) пробег по ВСЕМ инстансам,
        // что на средней карте (50+ примитивов + 10+ моделей) даёт сотни
        // итераций per-frame только из физики игрока.
        const candidates = this._getSpatialCandidates(cx, cz, hw, hd);
        if (candidates) {
            for (const item of candidates) {
                if (item.kind === 'primitive') {
                    const data = item.data;
                    if (data.type === 'trigger' || data.type === 'checkpoint') continue;
                    if (data.canCollide === false) continue;
                    if (this._aabbIntersectsPrimitive(cx, cy, cz, hw, hh, hd, data)) {
                        return true;
                    }
                } else if (item.kind === 'model') {
                    const data = item.data;
                    if (data.canCollide === false) continue;
                    if (this._aabbIntersectsModel(cx, cy, cz, hw, hh, hd, data)) {
                        return true;
                    }
                } else if (item.kind === 'userModel') {
                    const data = item.data;
                    if (data.canCollide === false) continue;
                    if (this._aabbIntersectsUserModel(cx, cy, cz, hw, hh, hd, data)) {
                        return true;
                    }
                }
            }
        }

        return false;
    }

    /** AABB-vs-voxel пересечение игрока с пользовательской voxel-моделью.
     *
     *  Двухфазная проверка:
     *    1. BROAD — быстрый AABB-vs-AABB по общему bbox модели. 99% кадров
     *       игрок не касается модели вообще → мгновенный выход.
     *    2. NARROW — если broad прошёл И есть voxelMask, проверяется
     *       пересечение с КОНКРЕТНЫМИ занятыми вокселями. Это позволяет
     *       заходить в проёмы / ходить по «дорожке» арочного моста — там,
     *       где раньше был сплошной AABB-короб.
     *
     *  Производительность narrow-фазы: итерируются только воксели в пределах
     *  AABB игрока (на касании это 1-3 слоя), не вся модель. Маска —
     *  Uint8Array, доступ O(1). Учитывается scale и rotationY.
     */
    _aabbIntersectsUserModel(cx, cy, cz, hw, hh, hd, data) {
        const local = data.localAABB
            || { minX: -0.5, maxX: 0.5, minY: 0, maxY: 1, minZ: -0.5, maxZ: 0.5 };
        const s = data.scale || 1;
        // Умножаем local-bbox на scale (равномерный)
        let lminX = local.minX * s, lmaxX = local.maxX * s;
        let lminY = local.minY * s, lmaxY = local.maxY * s;
        let lminZ = local.minZ * s, lmaxZ = local.maxZ * s;
        // Учитываем rotationY — расширяем XZ-bbox по 4 углам.
        const rotY = data.rotationY || 0;
        if (rotY !== 0) {
            const c = Math.cos(rotY), si = Math.sin(rotY);
            const corners = [
                { x: lminX, z: lminZ }, { x: lmaxX, z: lminZ },
                { x: lmaxX, z: lmaxZ }, { x: lminX, z: lmaxZ },
            ];
            let nminX = Infinity, nmaxX = -Infinity, nminZ = Infinity, nmaxZ = -Infinity;
            for (const cr of corners) {
                const rx = cr.x * c - cr.z * si;
                const rz = cr.x * si + cr.z * c;
                if (rx < nminX) nminX = rx;
                if (rx > nmaxX) nmaxX = rx;
                if (rz < nminZ) nminZ = rz;
                if (rz > nmaxZ) nmaxZ = rz;
            }
            lminX = nminX; lmaxX = nmaxX;
            lminZ = nminZ; lmaxZ = nmaxZ;
        }
        const minX = data.x + lminX, maxX = data.x + lmaxX;
        const minY = data.y + lminY, maxY = data.y + lmaxY;
        const minZ = data.z + lminZ, maxZ = data.z + lmaxZ;
        // BROAD: не пересекает общий bbox → точно нет коллизии.
        const broadHit = (
            cx + hw > minX && cx - hw < maxX &&
            cy + hh > minY && cy - hh < maxY &&
            cz + hd > minZ && cz - hd < maxZ
        );
        if (!broadHit) return false;

        // NARROW: есть воксельная маска → проверяем конкретные воксели.
        const mask = data.voxelMask;
        if (!mask || mask.count === 0) {
            // Маски нет (старая модель / битый data) — fallback на broad AABB.
            return true;
        }
        return this._aabbIntersectsVoxelMask(cx, cy, cz, hw, hh, hd, data, mask, s, rotY);
    }

    /** NARROW-фаза: пересечение AABB игрока с воксельной маской модели.
     *
     *  Алгоритм:
     *    - Переводим AABB игрока в ЛОКАЛЬНУЮ систему модели (вычитаем
     *      позицию инстанса, применяем обратный поворот вокруг Y, делим
     *      на scale → получаем диапазон в "мировых-без-трансформа" единицах).
     *    - Конвертируем в воксельные индексы (через VOXEL_SIZE_USER_MODEL).
     *    - Пробегаем только воксели в этом диапазоне; первый занятый = hit.
     *
     *  Размер voxel совпадает с UserModelManager.VOXEL_SIZE (0.0625 м).
     */
    _aabbIntersectsVoxelMask(cx, cy, cz, hw, hh, hd, data, mask, s, rotY) {
        const VS = 0.0625;   // VOXEL_SIZE — совпадает с UserModelManager
        // AABB игрока в мировых координатах.
        let pMinX = cx - hw, pMaxX = cx + hw;
        const pMinY = cy - hh, pMaxY = cy + hh;
        let pMinZ = cz - hd, pMaxZ = cz + hd;

        // Переводим в систему координат модели ДО масштаба и поворота.
        // rootNode.position = (data.x, data.y, data.z), rotation.y = rotY,
        // scaling = s. Меш-вершины модели = voxelIndex * VS.
        // Значит мировая точка P → локальная: invRot((P - pos)) / s.
        const ox = data.x, oy = data.y, oz = data.z;

        // Сначала вычитаем позицию.
        let aMinX = pMinX - ox, aMaxX = pMaxX - ox;
        const aMinY = pMinY - oy, aMaxY = pMaxY - oy;
        let aMinZ = pMinZ - oz, aMaxZ = pMaxZ - oz;

        // Обратный поворот вокруг Y: расширяем XZ-диапазон по 4 углам
        // (повёрнутый AABB → axis-aligned обёртка в локальной системе).
        if (rotY !== 0) {
            const c = Math.cos(-rotY), si = Math.sin(-rotY);
            const corners = [
                [aMinX, aMinZ], [aMaxX, aMinZ], [aMaxX, aMaxZ], [aMinX, aMaxZ],
            ];
            let nMinX = Infinity, nMaxX = -Infinity, nMinZ = Infinity, nMaxZ = -Infinity;
            for (const cr of corners) {
                const rx = cr[0] * c - cr[1] * si;
                const rz = cr[0] * si + cr[1] * c;
                if (rx < nMinX) nMinX = rx;
                if (rx > nMaxX) nMaxX = rx;
                if (rz < nMinZ) nMinZ = rz;
                if (rz > nMaxZ) nMaxZ = rz;
            }
            aMinX = nMinX; aMaxX = nMaxX;
            aMinZ = nMinZ; aMaxZ = nMaxZ;
        }

        // Делим на масштаб → координаты в "единицах вершин модели".
        const invS = 1 / s;
        // Конвертируем в воксельные индексы. Вершина voxel'а v занимает
        // [v*VS, (v+1)*VS]. Индекс в mask.grid = voxelIndex - mask.minX и т.д.
        let vx0 = Math.floor((aMinX * invS) / VS) - mask.minX;
        let vx1 = Math.floor((aMaxX * invS) / VS) - mask.minX;
        let vy0 = Math.floor((aMinY * invS) / VS) - mask.minY;
        let vy1 = Math.floor((aMaxY * invS) / VS) - mask.minY;
        let vz0 = Math.floor((aMinZ * invS) / VS) - mask.minZ;
        let vz1 = Math.floor((aMaxZ * invS) / VS) - mask.minZ;

        // Клампим к границам сетки.
        if (vx1 < 0 || vy1 < 0 || vz1 < 0) return false;
        if (vx0 >= mask.sx || vy0 >= mask.sy || vz0 >= mask.sz) return false;
        if (vx0 < 0) vx0 = 0;
        if (vy0 < 0) vy0 = 0;
        if (vz0 < 0) vz0 = 0;
        if (vx1 >= mask.sx) vx1 = mask.sx - 1;
        if (vy1 >= mask.sy) vy1 = mask.sy - 1;
        if (vz1 >= mask.sz) vz1 = mask.sz - 1;

        const grid = mask.grid;
        const sx = mask.sx, sz = mask.sz;
        // Пробег по вокселям в диапазоне AABB игрока — первый занятый = hit.
        for (let vy = vy0; vy <= vy1; vy++) {
            const yBase = vy * sz;
            for (let vz = vz0; vz <= vz1; vz++) {
                const zBase = (yBase + vz) * sx;
                for (let vx = vx0; vx <= vx1; vx++) {
                    if (grid[zBase + vx] !== 0) return true;
                }
            }
        }
        return false;
    }

    /**
     * Spatial-bucket grid для primitives и models. Ячейка = SPATIAL_CELL_SIZE
     * мировых единиц (8 по умолчанию). Объект может попасть в несколько ячеек,
     * если перекрывает их границы. Хранится lazy: первый запрос строит индекс,
     * setSpatialDirty() сбрасывает его при изменениях.
     *
     * Возвращает Set candidates пересекающих AABB вокруг (cx, cz, hw, hd) или
     * null если индекс не нужен (нет ни primitives ни models — fast path).
     */
    _getSpatialCandidates(cx, cz, hw, hd) {
        const hasPrim = this.primitiveManager && this.primitiveManager.instances.size > 0;
        const hasModel = this.modelManager && this.modelManager.instances.size > 0;
        const hasUserModel = this.userModelManager && this.userModelManager.instances.size > 0;
        if (!hasPrim && !hasModel && !hasUserModel) return null;

        // Rebuild делается в moveAABB перед sub-step циклом (один раз за кадр).
        // Здесь только lazy-init на самый первый вызов (вне moveAABB — например,
        // getOverlappingPrimitives при триггерах).
        const SPATIAL_CELL_SIZE = 8;
        if (!this._spatialGrid) {
            this._buildSpatialGrid(SPATIAL_CELL_SIZE);
            this._spatialBuildAt = performance.now();
        }

        const grid = this._spatialGrid;
        const cs = SPATIAL_CELL_SIZE;
        const x0 = Math.floor((cx - hw) / cs);
        const x1 = Math.floor((cx + hw) / cs);
        const z0 = Math.floor((cz - hd) / cs);
        const z1 = Math.floor((cz + hd) / cs);

        // Используем переиспользуемый Set для аккумуляции — экономит GC
        if (!this._spatialQuerySet) this._spatialQuerySet = new Set();
        const out = this._spatialQuerySet;
        out.clear();
        for (let gx = x0; gx <= x1; gx++) {
            for (let gz = z0; gz <= z1; gz++) {
                const cell = grid.get(gx * 100003 + gz);
                if (!cell) continue;
                for (const item of cell) out.add(item);
            }
        }
        return out;
    }

    /**
     * Сбросить кэш spatial-grid — следующий moveAABB пересоберёт его.
     * Нужно когда у примитива сменился canCollide в рантайме (Фаза 5.9):
     * иначе grid держит старое состояние до 50мс и UNSTUCK не видит
     * объект, ставший твёрдым.
     */
    invalidateSpatialGrid() {
        this._spatialBuildAt = 0;
    }

    _buildSpatialGrid(cellSize) {
        const grid = new Map();
        const addToCells = (item, minX, maxX, minZ, maxZ) => {
            const x0 = Math.floor(minX / cellSize);
            const x1 = Math.floor(maxX / cellSize);
            const z0 = Math.floor(minZ / cellSize);
            const z1 = Math.floor(maxZ / cellSize);
            for (let gx = x0; gx <= x1; gx++) {
                for (let gz = z0; gz <= z1; gz++) {
                    const key = gx * 100003 + gz;
                    let cell = grid.get(key);
                    if (!cell) { cell = []; grid.set(key, cell); }
                    cell.push(item);
                }
            }
        };
        if (this.primitiveManager) {
            for (const data of this.primitiveManager.instances.values()) {
                // Не индексируем декоративные примитивы (canCollide=false) —
                // они никогда не участвуют в коллизиях игрока.
                // На больших сценах с сотнями декораций это даёт огромный
                // выигрыш по FPS (раньше каждая большая декоративная сфера
                // занимала десятки клеток индекса и засоряла поиск).
                if (data.canCollide === false) continue;
                // Используем приближённый AABB по позиции и размерам, с
                // запасом 3м для движущихся платформ.
                const r = Math.max(data.sx || 1, data.sy || 1, data.sz || 1) * 0.5 + 3;
                addToCells({ kind: 'primitive', data },
                    data.x - r, data.x + r, data.z - r, data.z + r);
            }
        }
        if (this.modelManager) {
            for (const data of this.modelManager.instances.values()) {
                if (data.canCollide === false) continue;
                const r = 2;
                addToCells({ kind: 'model', data },
                    data.x - r, data.x + r, data.z - r, data.z + r);
            }
        }
        if (this.userModelManager) {
            for (const data of this.userModelManager.instances.values()) {
                if (data.canCollide === false) continue;
                // Радиус по реальному localAABB (с учётом scale)
                let r = 2;
                if (data.localAABB) {
                    const s = data.scale || 1;
                    const dx = Math.max(Math.abs(data.localAABB.minX), Math.abs(data.localAABB.maxX)) * s;
                    const dz = Math.max(Math.abs(data.localAABB.minZ), Math.abs(data.localAABB.maxZ)) * s;
                    r = Math.max(dx, dz);
                }
                addToCells({ kind: 'userModel', data },
                    data.x - r, data.x + r, data.z - r, data.z + r);
            }
        }
        this._spatialGrid = grid;
        this._spatialDirty = false;
    }

    /** Пометить spatial-индекс как устаревший. Должно вызываться при
     *  add/remove/move инстанса в primitiveManager / modelManager. */
    setSpatialDirty() {
        this._spatialDirty = true;
    }

    /** AABB-vs-AABB пересечение игрока с моделью. */
    _aabbIntersectsModel(cx, cy, cz, hw, hh, hd, data) {
        // Берём кешированный локальный AABB модели; если ещё не посчитан — fallback 1×1×1
        let local = data.localAABB;
        if (!local && this.modelManager) {
            this.modelManager._computeLocalAABB?.(data);
            local = data.localAABB;
        }
        if (!local) {
            local = { minX: -0.5, maxX: 0.5, minY: 0, maxY: 1, minZ: -0.5, maxZ: 0.5 };
        }
        // Учёт rotationY: расширяем localAABB до axis-aligned обёртки повёрнутого bbox.
        // Это упрощённый подход (немного «толще» чем реальный OBB), но избавляет от
        // визуального «съезда» коллайдера у домов с rotationY != 0.
        let lminX = local.minX, lmaxX = local.maxX;
        let lminZ = local.minZ, lmaxZ = local.maxZ;
        const rotY = data.rotationY || 0;
        if (rotY !== 0) {
            const c = Math.cos(rotY), s = Math.sin(rotY);
            // Поворачиваем 4 угла local-bbox в плоскости XZ и берём новый AABB
            const corners = [
                { x: local.minX, z: local.minZ },
                { x: local.maxX, z: local.minZ },
                { x: local.maxX, z: local.maxZ },
                { x: local.minX, z: local.maxZ },
            ];
            let nminX = Infinity, nmaxX = -Infinity, nminZ = Infinity, nmaxZ = -Infinity;
            for (const cr of corners) {
                const rx = cr.x * c - cr.z * s;
                const rz = cr.x * s + cr.z * c;
                if (rx < nminX) nminX = rx;
                if (rx > nmaxX) nmaxX = rx;
                if (rz < nminZ) nminZ = rz;
                if (rz > nmaxZ) nmaxZ = rz;
            }
            lminX = nminX; lmaxX = nmaxX;
            lminZ = nminZ; lmaxZ = nmaxZ;
        }
        const minX = data.x + lminX, maxX = data.x + lmaxX;
        const minY = data.y + local.minY, maxY = data.y + local.maxY;
        const minZ = data.z + lminZ, maxZ = data.z + lmaxZ;
        return (
            cx + hw > minX && cx - hw < maxX &&
            cy + hh > minY && cy - hh < maxY &&
            cz + hd > minZ && cz - hd < maxZ
        );
    }

    /**
     * Пересечение AABB игрока с примитивом.
     * Если примитив повёрнут — используем точную OBB-проверку через SAT.
     * Иначе — быстрая AABB-vs-AABB.
     */
    _aabbIntersectsPrimitive(cx, cy, cz, hw, hh, hd, p) {
        const phw = p.sx / 2, phh = p.sy / 2, phd = p.sz / 2;
        const rx = p.mesh?.rotation?.x || 0;
        const ry = p.mesh?.rotation?.y || 0;
        const rz = p.mesh?.rotation?.z || 0;

        // Без поворота — быстрая axis-aligned проверка
        if (rx === 0 && ry === 0 && rz === 0) {
            return (
                cx + hw > p.x - phw && cx - hw < p.x + phw &&
                cy + hh > p.y - phh && cy - hh < p.y + phh &&
                cz + hd > p.z - phd && cz - hd < p.z + phd
            );
        }

        // С поворотом — SAT (точное пересечение AABB×OBB)
        const axes = buildOBBAxes(rx, ry, rz);
        return aabbIntersectsOBB(
            cx, cy, cz, hw, hh, hd,
            p.x, p.y, p.z, phw, phh, phd, axes
        );
    }

    /**
     * Найти все триггеры и чекпоинты которых сейчас касается AABB игрока.
     * Возвращает массив data-объектов. Использует тот же spatial-grid что
     * и _collidesAt — без него O(N) каждый кадр.
     */
    getOverlappingPrimitives(cx, cy, cz, hw, hh, hd) {
        const out = [];
        if (!this.primitiveManager) return out;
        const candidates = this._getSpatialCandidates(cx, cz, hw, hd);
        if (!candidates) return out;
        for (const item of candidates) {
            if (item.kind !== 'primitive') continue;
            const data = item.data;
            if (data.type !== 'trigger' && data.type !== 'checkpoint') continue;
            if (this._aabbIntersectsPrimitive(cx, cy, cz, hw, hh, hd, data)) {
                out.push(data);
            }
        }
        return out;
    }
}
