/* glbview.c —— **glb / gltf 查看器**。
 * glbview.c - a **glb / gltf viewer**.
 *
 * 用户口径（2026-10-08）：「接口实现完成后，先做个 glb 查看器，然后再做个游戏把 glb 用上」。
 * User's call (2026-10-08): "once the interfaces are done, first make a glb viewer, then make a game that uses glb".
 *
 * ══ 怎么用 ══════════════════════════════════════════════════════════════════
 *   手机：命令行页敲 `vml run examples/c/3d/glbview.c`
 *   桌面：`dotnet scripts/vmlcli/bin/Release/net10.0/vmlcli.dll Examples/c/3d/glbview.c --screen 393x757`
 *
 *   单指拖 = 转视角       两指开合 = 拉近拉远
 *   A = 自动旋转          B = 换一个模型（候选里**装得上**的那些之间循环）
 *   X = 输出变换循环       Y = 阴影开关（`#642` 平面投影阴影）
 *   SELECT = PBR 开关（**默认开**）—— 金属度-粗糙度贴图（`#661`）只在它开着时才起作用
 *   ↑/↓ = 环境强度（金属反射的亮度）           R = 复位视角        Esc = 退出
 *
 * ⚠ 手机上这几个键来自**自带手柄**（方向键 + X/Y/A/B → Win32 虚拟键），程序里的键盘分支直接认。
 *
 * ══ 它**不**做什么（如实记下，别指望）════════════════════════════════════════
 * · **不列目录**：`vml_readdir` 在本仓宿主上**没有实现**（实测返回 -8，C# 侧零命中）⇒ 候选是
 *   写死的一串（见 CANDIDATES）。想看你自己的模型，照那几条路径放，或改那张表。
 * · **不播动画**：glTF 的 `animations` 还没解析，带骨骼的模型显示的是**绑定姿势**。
 *   ⚠ 蒙皮接口（`#656`–`#660`）已经能用，但**装载器还没解析 `skins`** ⇒ 这里也不解。
 * · 不支持 Draco / KTX2（装载器的已知缺口）。
 *
 * ══ 为什么值得单独立一个程序 ═════════════════════════════════════════════════
 * `#643`（glTF 2.0 静态装载）落地时的验收靠三个大模型 + 逐条跟浏览器对表，**重**。
 * 这个查看器把"装得上吗、画得对吗"压成一个到处能跑的程序：换模型、转视角、切伽马、开阴影，
 * 屏幕上按一下就有 —— 它同时也是 `#643`/`#649`/`#642` 的一个**人工冒烟口**。
 */

#include <vml_3d.h>
#include <vml_ui.h>
#include <math.h>
#include <stdio.h>

/* ══ 候选模型 ══════════════════════════════════════════════════════════════
 * 依次试；**第一个装得上的**就是开场那个，`B` 在"装得上的"之间循环。
 *
 * ⚠⚠ **相对路径是按「程序的所在目录」解析的**（2026-10-08 实测 —— **不是按 cwd**）：
 *   本程序在 `<工作区>/examples/c/3d/`，所以**能命中的那条是 `models/xxx.glb`**（就近的那份）；写成
 *   `examples/c/3d/models/xxx.glb` 会被拼成 `…/c/3d/examples/c/3d/models/xxx.glb`
 *   （宿主日志原话，以当时的夹具为例：`gltf_load：找不到 …（解析成 …/c/3d/examples/c/3d/models/Fox.glb）`）。
 *   手机与桌面是**同一套解析**（同一份共享层代码）⇒ 头一条就够，后面几条是兜底。
 * ⚠ 2026-10-09：`Fox.glb` **已从仓库删除**（它文件内自带 `asset.copyright` = **CC-BY 4.0**，要署名，
 *   而仓里当时记成 CC0 ⇒ 无署名随包分发）。随仓库进的换成 **`rover_cutout.glb`**（我们自生成，无第三方权利）。
 *   Avocado / BoomBox / ToyCar 在本机磁盘上（`models/.gitignore` 拦在仓库外、但**会进 vml_lib.zip**），
 *   DamagedHelmet 从未入库。许可与署名逐份见 `models/CREDITS.md`。
 * ⚠ 2026-10-10：**大模型全部删掉、换成小的**（用户：「例子不需要大文件，打包太大」）——
 *   `models/` 从 ~69 MB 降到 ~450 KB。删的是 >5 MB 的那几个 + 两个许可有问题的
 *   （`DamagedHelmet` CC BY-**NC** 禁商用 / `BoxTextured` 要署名带商标限制）；
 *   换进来的是 Khronos 的 **CC0** 小样例（`AnimatedColorsCube` / `AnimatedMorphCube` /
 *   `BoxVertexColors` / `InterpolationTest` / `SimpleInstancing` / `TextureLinearInterpolationTest`）。
 *   ⚠ 2026-10-10 又换了一轮：`Duck`（Khronos README 写的是 **SCEA Shared Source License**，
 *   而本仓一直记成 CC0）与 `TextureCoordinateTest`（文件内 `asset.copyright` 写 **CC-BY 4.0**、
 *   README 却写 CC0 —— **两处冲突**）都**出局**了，见 `models/CREDITS.md`。
 *   逐份许可见 `models/CREDITS.md`。
 * ⚠ 2026-10-09: `Fox.glb` was **removed from the repo** ... see `CREDITS.md`.
 * ⚠ 2026-10-10: the big models were **dropped for small ones** (user: "examples don't need big files,
 *   the package is too big") - `models/` went from ~69 MB to ~450 KB. */
static const char* CANDIDATES[] = {
    /* ⚠⚠ **同一个相对路径在手机与桌面上指的不是同一个地方**（2026-10-08 实测，宿主日志原话）：
     *   手机：`models/xxx.glb` → `/…/workspace/models/xxx.glb`        ← 基准 = **工作区根**
     *   桌面：`models/xxx.glb` → `/…/Examples/c/3d/models/xxx.glb`   ← 基准 = **程序目录**
     *   （手机那侧的规矩来自 Android 那条铁律：「一切路径以工作区为根、只按相对路径工作」。）
     * ⇒ 每个模型都写**两种拼法**，哪端命中哪端算 —— 只写一种的话，另一端会静默找不到、
     *   然后**悄悄退回下一个候选**（症状正是"我明明放了模型，它却打开的是别的"）。
     *
     * 顺序 = 打开顺序（`B` 依次换）。**复杂的放前面**（用户口径：「再打开复杂一点的 glb」）。 */
    /* ⚠ 2026-10-10：换成**随包的小模型**（全部 CC0）——见文件头那条，理由与逐份许可在
     *   `models/CREDITS.md`。原先那些（DamagedHelmet/BoomBox/Avocado）已删（太大 / 许可）。
     *   ⚠ `MetalRoughSpheresNoTextures` 试过但**装不上**（102 个网格 > 引擎上限 64，实测
     *   `gltf_load：解析失败 —— 网格数 102 超过引擎上限 64`）⇒ 没进包。 */
    "models/AnimatedColorsCube.glb",                       /* 手机：<工作区>/models/… */
    "examples/c/3d/models/AnimatedColorsCube.glb",         /* 桌面：<程序目录>/models/… */
    "models/TextureLinearInterpolationTest.glb",
    "examples/c/3d/models/TextureLinearInterpolationTest.glb",
    "models/InterpolationTest.glb",
    "examples/c/3d/models/InterpolationTest.glb",
    "models/AnimatedMorphCube.glb",
    "examples/c/3d/models/AnimatedMorphCube.glb",
    "models/SimpleInstancing.glb",
    "examples/c/3d/models/SimpleInstancing.glb",
    "models/BoxVertexColors.glb",
    "examples/c/3d/models/BoxVertexColors.glb",
    /* ⚠ 随仓库进的那一份 = `rover_cutout.glb`（**本仓库自己生成**，无第三方权利）。
     *   2026-10-09 换的：原先这里兜底用的 `Fox.glb` 自带 `asset.copyright` = CC-BY 4.0（要署名），
     *   而仓里把它记成了 CC0 ⇒ 已删。逐份许可见 `models/CREDITS.md`。
     * ⚠ the one that **ships with the repo** is `rover_cutout.glb` (generated by us; no third-party rights). */
    "rover_cutout.glb",
    "models/rover_cutout.glb",
    "examples/c/3d/rover_cutout.glb",
    "third_party/vml/Examples/c/3d/rover_cutout.glb",
};
#define NCAND  ((int)(sizeof(CANDIDATES) / sizeof(CANDIDATES[0])))
#define MAXOK  16

/** 装得上的那些：候选表下标 + 显示名（只取路径最后一段，HUD 上放不下整条路径）。
 *  ⚠ 名字摊平成**一维**数组按 96 一段手算下标 —— **别用二维 `char[MAXOK][96]`**：
 *    实测这个 C 前端写进去读回来是空串（`printf` 打出 `(null)`），而**不报任何错**。 */
#define NAMELEN 96
static int  OKIDX[MAXOK];
static char OKNAME[MAXOK * NAMELEN];
static int  NOK;

/** 输出变换四档（`#649`）：同一个场景在线性 / sRGB / Reinhard / ACES 下各长什么样。 */
static const char* GAMMA_NAME[] = { "线性 Linear", "sRGB", "Reinhard+sRGB", "ACES+sRGB" };

/** 底色五档。⚠ **白底那一档最容易看出模型自己的颜色对不对** —— 它是 sRGB 那套的判据载体。 */
static const int BGS[] = { 0xFF101018, 0xFF303038, 0xFF9A9AA2, 0xFFD8D8E0, 0xFF000000 };
#define NBG  ((int)(sizeof(BGS) / sizeof(BGS[0])))

/** `0xAARRGGBB` 的构造（`g3_all.c` 里那个同名助手的精简版）—— 只在这里用得上。 */
static int argb(int r, int g, int b) { return -16777216 + r * 65536 + g * 256 + b; }

/** 取路径最后一段（`a/b/c.glb` → `c.glb`）。 */
static void tail_name(const char* p, char* out, int cap) {
    int n; int i; int b;
    n = 0; while (p[n] != 0) n = n + 1;
    b = 0;
    for (i = 0; i < n; i = i + 1) if (p[i] == '/' || p[i] == '\\') b = i + 1;
    i = 0;
    while (p[b + i] != 0 && i < cap - 1) { out[i] = p[b + i]; i = i + 1; }
    out[i] = 0;
}

int main(void) {
    int sw; int sh; int vp; int i;
    int scene; int cur; int none; int bg; int gamma; int shadowOn; int autoOn; int pbrOn; int root;
    float envK;
    float camYaw; float camPitch; float camZoom; float modelR;
    int touch[4]; int t0[4]; int t1[4];
    int dragging; int pinching; int lastTx; int lastTy; float lastPinch;
    char hud[200];

    /* ⚠ 问尺寸必须用 **_ex**：`ui_scr_get_w()` 按**默认** chrome 估算，与本程序实际拿到的画布
     *   对不上 ⇒ 视口贴左上角（`solar3d.c` 实测踩过）。 */
    ui_win_open_ex("glb 查看器", 0, 0, VML_WIN_ROTATABLE, VML_WIN_NO_GAMEPAD);
    sw = ui_scr_get_w_ex(ui_scr_get_orientation(), VML_CHROME_NONE);
    sh = ui_scr_get_h_ex(ui_scr_get_orientation(), VML_CHROME_NONE);
    if (sw <= 0) sw = 360;
    if (sh <= 0) sh = 640;

    vp = ui_3d_viewport_create(0, 0, sw, sh, 0);
    if (vp <= 0) {
        /* ⚠ **拿不到设备要如实报**：既不画、也不说，是最坏的一类失败。 */
        printf("NO_3D_DEVICE=1\n");
        return 0;
    }
    ui_3d_viewport_set_bg(vp, BGS[0]);
    /* 主光 + 一侧补光（`#637`）：只有一盏时模型暗面是纯黑、看不出形状。 */
    ui_3d_viewport_set_light(vp, 0.45f, 0.85f, 0.35f, 0xFFFFFFFF, 0.30f);
    ui_3d_viewport_set_light_at(vp, 1, -0.6f, 0.30f, -0.5f, 0x40FFFFFF, 0.0f);
    /* ⚠⚠ **PBR 默认开着**（`#652`）。理由：glTF 的材质**天生是 PBR 的** —— 金属度/粗糙度那张图
     *   （`#661`）**只在 PBR 那条路上起作用**，关着 PBR 时它一个字节都进不了着色器 ⇒
     *   一个金属头盔会被画成**漫反射色块**（用户 2026-10-08 实测就是这么报的：
     *   「材质效果差很多了 / 玻璃贴图没出来」）。固定管线那条路是给**老程序**保底用的，
     *   不是给 glTF 用的。 */
    ui_3d_viewport_set_pbr(vp, 1);
    /* ⚠⚠ **环境强度也要一起设**（`#662`）。PBR 里**金属的亮度几乎全部来自环境反射**，
     *   而环境贴图是 8 位图（每通道封顶 1.0）、金属的 F0 又是暗色基色 ⇒ 只设 PBR 的话金属是
     *   "暗的镜子"，看着发闷（用户 2026-10-08 口径：「还缺一些效果」「玻璃效果没有实现」）。
     *   实测把天空画亮之后黑软管/金属边条立刻有了正确反射 ⇒ **根因就是这个倍率**。
     *   ⚠ 真实渲染器靠 **HDR 环境贴图**（太阳几百、天空几）；本仓没有，所以用倍率代替
     *     （与 three.js 的 `scene.environmentIntensity` 同一个旋钮）。 */
    envK = 1.8f;
    ui_3d_viewport_set_env_intensity(vp, envK);

    /* ── 探一遍候选 ────────────────────────────────────────────────────────
     * ⚠ `ui_3d_gltf_load` 是"**边解析边建物体**"，没有"只解析不建"的形态 ⇒ 试一条就得**立刻释放**
     *   （不释放的话每试一条，屏幕上就多叠一份模型）。
     * ⚠ 这一趟的代价 = 每个**真的存在**的候选解析一次；不存在的（大多数）在打开文件那一步就
     *   失败了，几乎不要钱。 */
    /* ⚠ **第一个装上的直接留着用**，后面探到的立刻还回去 —— 这样每个模型只解析一次
     *   （探路 + 正式装 = 两趟的写法会让启动慢一倍，实测 Fox 那 163 KB 要几秒）。 */
    NOK = 0; scene = 0; cur = -1;
    for (i = 0; i < NCAND && NOK < MAXOK; i = i + 1) {
        int s;
        s = ui_3d_gltf_load(vp, (char*)CANDIDATES[i]);
        if (s <= 0) continue;
        if (NOK == 0) scene = s; else ui_3d_gltf_free(s);
        OKIDX[NOK] = i;
        tail_name(CANDIDATES[i], &OKNAME[NOK * NAMELEN], NAMELEN);
        NOK = NOK + 1;
    }
    if (NOK > 0) cur = 0;
    none = (NOK == 0 || scene <= 0);
    if (none) { scene = 0; cur = -1; }
    printf("GLBVIEW models=%d first=%s\n", NOK,
           none ? "(none)" : &OKNAME[(cur < 0 ? 0 : cur) * NAMELEN]);
    if (none) {
        /* 一个都没装上：**说清楚试过哪些**，别留一块空屏让人猜。 */
        printf("NO_MODEL=1\n");
        for (i = 0; i < NCAND; i = i + 1) printf("  tried: %s\n", CANDIDATES[i]);
    }

    /* ── 程序化天空（**金属要靠它才不发黑**）─────────────────────────────────
     * ⚠⚠ 这是本仓的一条硬事实：PBR 的金属**没有漫反射**，全部能量走镜面 —— 而镜面要有东西可反射。
     *   `DamagedHelmet` 的面罩是 `metalness = 1`，视口**没设天空盒**时环境反射那段一条指令都不走
     *   ⇒ 读对了金属度贴图**反而更暗**（实测：一整块黑疙瘩）。真实查看器靠 IBL 照环境，这里靠
     *   `#640` 那张天空盒（本仓的环境反射是"按反射方向采天空盒 + 按粗糙度挑 mip 级"的近似）。
     * ⇒ 画一块**竖直渐变** → 用 `ui_2d_get_image` 抓成图像 → 当天空盒（等距圆柱）。
     *   ⚠ 抓的是**当时 2D 画布上的内容**，所以必须在画 HUD **之前**抓、抓完清屏。 */
    {
        int band; int skyH; int sky; int horizon;
        skyH = sh / 2;
        horizon = skyH * 45 / 100;                     /* 地平线放在 45% 高度处 */
        ui_2d_clear(0);
        /* ⚠⚠ **环境要有结构**，不能只是一条渐变：金属**没有漫反射**，它的样子**完全**来自
         *   反射到的东西 —— 一片纯渐变天空反到抛光面罩上，看起来就是**一块发闷的平色**
         *   （用户 2026-10-08 实测：「还缺一些效果」）。给天空加**地平线 + 地面 + 一个太阳**
         *   之后，同一块面罩上立刻能看出"天在上、地在下的弧"和那个亮点 ⇒ **一眼就是镜子**。
         *   （真实查看器靠 HDRI 环境贴图，这里用几笔画一个"认得出的世界"。） */
        for (band = 0; band < 96; band = band + 1) {
            int y; int t; int r0; int g0; int b0;
            y = band * skyH / 96;
            if (y < horizon) {
                /* 上半：天顶深蓝 → 地平线灰白（地平线附近最亮） */
                t = (horizon > 0) ? (y * 255 / horizon) : 0;
                r0 = 40 + (205 - 40) * t / 255;
                g0 = 62 + (215 - 62) * t / 255;
                b0 = 110 + (228 - 110) * t / 255;
            } else {
                /* 下半：**地面**（暖灰，比天空暗一截）—— 没有它金属反射就只有"上面亮下面也亮" */
                /* ⚠⚠ **地面要压得比天空暗很多** —— "玻璃/金属"的观感来自**对比**（亮的天 + 暗的地
                 *   在抛光面上切开一条清晰的地平线），而不是来自整体亮度。地面和天空一样亮的话，
                 *   反射出来还是一块平色（前面几版就是这么"提不亮也压不暗"的）。 */
                t = (skyH - 1 - horizon > 0) ? ((y - horizon) * 255 / (skyH - 1 - horizon)) : 0;
                r0 = 46 - (46 - 12) * t / 255;
                g0 = 47 - (47 - 13) * t / 255;
                b0 = 50 - (50 - 16) * t / 255;
            }
            ui_2d_rect(0, y, sw, skyH / 96 + 1, argb(r0, g0, b0), 1, 0, 0);
        }
        /* ── 让环境**有高频结构**（这一条决定"像不像玻璃"）──────────────────────
         * ⚠⚠ 只画一条**竖直渐变**的话，光滑球面上反射出来**还是一条渐变** —— 看着就是
         *   "一块平色 + 没有光泽"（用户 2026-10-08 反复说的「玻璃效果没出来」）。
         *   真查看器靠 **HDRI 环境贴图**（天空、云、建筑、地面，全是有形状的东西），
         *   弯曲的镜面把那堆形状**扭出可辨认的轮廓**，大脑立刻读成"玻璃"。
         * ⇒ 这里用几笔画一个有形状的世界：**太阳 + 云 + 地平线亮带 + 暗地面**。
         *   ⚠ 顺序要紧：先地面/天空（底），再云，最后太阳（压在最上层）。 */
        {
            int k;
            /* 地平线亮带：抛光面上那条最亮的水线就是它 */
            ui_2d_rect(0, horizon - skyH / 40, sw, skyH / 20, argb(255, 248, 232), 1, 0, 0);
            /* 几朵"云"：圆角矩形 = 有形状的亮块（半径给到一半高就够圆了） */
            for (k = 0; k < 5; k = k + 1) {
                int cx; int cy; int cw; int ch;
                /* ⚠⚠ **云要贴近地平线**：面罩（抛光面）反射到的主要是**地平线上下那一带**，
                 *   云画在天顶附近的话，镜面里**什么都反射不到** ⇒ 还是一块平色。 */
                cx = (sw * (12 + k * 19)) / 100;
                cy = horizon * (56 + ((k % 3) * 13)) / 100;
                cw = sw / (7 + (k % 3));
                ch = skyH / (16 + (k % 4));
                ui_2d_rect(cx, cy, cw, ch, argb(232, 236, 244), 1, 0, ch / 2);
                ui_2d_rect(cx + cw / 4, cy + ch / 2, cw * 3 / 5, ch * 2 / 3,
                           argb(214, 220, 232), 1, 0, ch / 3);
            }
            /* 地面上的几块暗斑：让"下半"也有形状（否则金属的下半球还是一块平色） */
            for (k = 0; k < 4; k = k + 1) {
                int gx; int gy; int gw; int gh;
                gx = (sw * (8 + k * 26)) / 100;
                gy = horizon + (skyH - horizon) * (20 + ((k % 2) * 40)) / 100;
                gw = sw / 5; gh = skyH / 18;
                ui_2d_rect(gx, gy, gw, gh, argb(38, 40, 44), 1, 0, gh / 2);
            }
            /* 太阳（最亮的那一点）—— 抛光面上那记高光就是它；偏左上，转起来才有变化 */
            /* ⚠ **太阳要压到地平线附近**（夕阳位）：低太阳在抛光面上拉出一条**又长又亮**的反射，
             *   那是"金属/玻璃"最强的视觉信号；画在高空只会反射不到。 */
            ui_2d_rect(sw * 24 / 100, horizon - skyH / 12, sw / 7, skyH / 8, argb(255, 252, 238), 1, 0, 0);
            ui_2d_rect(sw * 20 / 100, horizon - skyH / 9, sw / 5, skyH / 6, argb(196, 204, 216), 1, 0, sw / 14);
        }
        sky = ui_2d_get_image(0, 0, sw, skyH);
        ui_2d_clear(0);
        if (sky > 0) ui_3d_viewport_set_skybox(vp, sky);
    }

    /* ── 自动取景：**先把模型归一化到固定大小**，再按这个固定半径取景 ──────────
     * ⚠ 模型的大小是**数据**（Avocado 是 ±0.01、Fox 是 87.7、头盔是 1.6），而相机距离是**屏幕的函数**
     *   ⇒ 半径必须问 `#648` 要，不能在程序里硬写。
     * ⚠⚠ **归一化不只是为了好看**：天空盒的半径是宿主常量（180 世界单位），而相机距离由 `fit`
     *   按半径推 —— 一个半径 87.7 的模型会把相机推到 **~175**，离 180 只剩一点点余量；
     *   再大一点模型就跑到天空盒**外面**、被盒子的后壁挡住（"模型突然不见了"）。
     *   统一缩到 10 之后相机约 23，任何模型都稳稳地在盒子里。 */
    modelR = 10.0f;
    root = 0;
    if (scene > 0) {
        float bnd[4]; float r; float s;
        bnd[0] = 0.0f; bnd[1] = 0.0f; bnd[2] = 0.0f; bnd[3] = 0.0f;
        r = 1.0f;
        if (ui_3d_gltf_bounds(scene, (int)&bnd[0]) == 0 && bnd[3] > 0.0f) r = bnd[3];
        s = 10.0f / r;
        root = ui_3d_gltf_root(scene);
        if (root > 0) ui_3d_obj_set_scale(root, s);
        modelR = r;                                /* HUD 上显示**原始**半径（那是数据的样子） */
    }
    ui_3d_viewport_fit(vp, 11.5f);

    camYaw = 30.0f; camPitch = 18.0f; camZoom = 1.0f;
    autoOn = 1; gamma = 0; bg = 0; shadowOn = 0; pbrOn = 1;
    dragging = 0; pinching = 0; lastTx = 0; lastTy = 0; lastPinch = 0.0f;

    while (ui_win_is_closed() == 0) {
        int k;

        /* ⚠ 自动旋转**累加到 yaw 上**（不是给物体设 `#623` 自转）—— 转的是**相机**，
         *   于是模型背后那一面也能看到，而且不需要知道模型的朝向。 */
        if (autoOn) camYaw = camYaw + 0.35f;
        if (camYaw > 360.0f) camYaw = camYaw - 360.0f;

        /* ── 单指拖 = 转视角 ───────────────────────────────────────────────
         * 用「本帧 − 上一帧」的**增量**累加，不用绝对位移：手指从边缘重新落下时绝对量会跳。 */
        if (ui_touch_read(0, touch)) {
            if (touch[2]) {
                if (dragging) {
                    /* ⚠ **横向取负号**：`set_orbit` 的 yaw 是球坐标标准式（与 three.js 的
                     *   `OrbitControls` 同一条），而那边拖右是"把画面往右带" ⇒ θ 减小。
                     *   竖直不用翻：pitch 增大让相机升高、画面跟着手指往下走。 */
                    camYaw = camYaw - (touch[0] - lastTx) * 0.5f;
                    camPitch = camPitch + (touch[1] - lastTy) * 0.5f;
                    if (camPitch > 88.0f) camPitch = 88.0f;
                    if (camPitch < -88.0f) camPitch = -88.0f;
                }
                dragging = 1; lastTx = touch[0]; lastTy = touch[1];
            } else dragging = 0;
        } else dragging = 0;

        /* ── 两指开合 = 缩放 ───────────────────────────────────────────────
         * 用**两指间距的比值**累乘（同"增量"那条理由）。⚠ 比值要挡小值：两指几乎重合时
         * `lastPinch` 接近 0 ⇒ 除零得 inf ⇒ 画面直接没了。 */
        if (ui_touch_read(0, t0) && ui_touch_read(1, t1) && t0[2] && t1[2]) {
            float ddx; float ddy; float dd;
            ddx = (float)(t0[0] - t1[0]);
            ddy = (float)(t0[1] - t1[1]);
            dd = (float)sqrt((double)(ddx * ddx + ddy * ddy));
            if (pinching && dd > 8.0f && lastPinch > 8.0f) {
                camZoom = camZoom * (lastPinch / dd);
                if (camZoom < 0.15f) camZoom = 0.15f;
                if (camZoom > 6.0f) camZoom = 6.0f;
            }
            lastPinch = dd; pinching = 1;
        } else pinching = 0;
        ui_3d_viewport_set_zoom(vp, camZoom);
        ui_3d_viewport_set_orbit(vp, camYaw, camPitch);

        /* ── 键盘（手机上来自自带手柄）───────────────────────────────────── */
        if (ui_key_is_down(VML_KEY_PAD_A)) autoOn = 1 - autoOn;
        if (ui_key_is_down(VML_KEY_SELECT)) {
            pbrOn = 1 - pbrOn;
            ui_3d_viewport_set_pbr(vp, pbrOn);
        }
        if (ui_key_is_down(VML_KEY_PAD_Y)) {
            shadowOn = 1 - shadowOn;
            ui_3d_viewport_set_shadow(vp, shadowOn);
        }
        if (ui_key_is_down(VML_KEY_PAD_X)) {
            gamma = gamma + 1; if (gamma > 3) gamma = 0;
            ui_3d_viewport_set_gamma(vp, gamma);
        }
        /* ↑/↓ = 环境强度（`#662`）。⚠ 这两个键原来调**背景色**，而天空盒（`#640`）一开就把背景
         *   **整个盖住**了 ⇒ 它们成了**死键**（按下去画面纹丝不动）。
         *   改成调环境强度 —— 这一屏上**最值得调**的就它（金属亮不亮全看这个数）。 */
        if (ui_key_is_down(VML_KEY_UP)) {
            envK = envK + 0.4f; if (envK > 8.0f) envK = 8.0f;
            ui_3d_viewport_set_env_intensity(vp, envK);
        }
        if (ui_key_is_down(VML_KEY_DOWN)) {
            envK = envK - 0.4f; if (envK < 0.0f) envK = 0.0f;
            ui_3d_viewport_set_env_intensity(vp, envK);
        }
        if (ui_key_is_down(82)) {                 /* 'R' —— 复位视角 */
            camYaw = 30.0f; camPitch = 18.0f; camZoom = 1.0f;
        }
        /* B = 换模型：释放当前、装下一个能装上的 */
        if (ui_key_is_down(VML_KEY_PAD_B) && NOK > 1) {
            int nxt; int s; int nroot;
            nxt = cur + 1; if (nxt >= NOK) nxt = 0;
            s = ui_3d_gltf_load(vp, (char*)CANDIDATES[OKIDX[nxt]]);
            if (s > 0) {
                if (scene > 0) ui_3d_gltf_free(scene);   /* ⚠ 先装新的、装上了再放旧的：
                                                          *   反过来的话新模型装失败就**全空**了 */
                scene = s; cur = nxt;
                {
                    float bnd[4]; float r; float s;
                    bnd[3] = 0.0f;
                    r = 1.0f;
                    if (ui_3d_gltf_bounds(scene, (int)&bnd[0]) == 0 && bnd[3] > 0.0f) r = bnd[3];
                    s = 10.0f / r;
                    nroot = ui_3d_gltf_root(scene);
                    if (nroot > 0) ui_3d_obj_set_scale(nroot, s);
                    modelR = r;
                }
                ui_3d_viewport_fit(vp, 11.5f);
                camZoom = 1.0f;
            }
        }

        /* ── HUD：2D 画在 3D **之上** ─────────────────────────────────────
         * `ui_2d_clear` 用全透明 —— 出图时 3D 那一层是底、2D 图元直接叠上去。 */
        ui_2d_clear(0);
        ui_2d_text(14, 12, "glb 查看器", -1, 17, VML_ANCHOR_LEFT);
        if (none) {
            ui_2d_text(14, 40, "没有找到模型 —— 把 .glb 放到 examples/models/ 再跑", -65536, 12,
                       VML_ANCHOR_LEFT);
        } else {
            int n;
            n = ui_3d_gltf_object_count(scene);
            sprintf(hud, "%s   物体 %d   半径 %.1f", &OKNAME[cur * NAMELEN], n, (double)modelR);
            ui_2d_text(14, 40, hud, -8355712, 12, VML_ANCHOR_LEFT);
        }
        sprintf(hud, "%s%s   PBR%s   阴影%s   环境 ×%.1f",
                GAMMA_NAME[gamma], autoOn ? "   自动旋转" : "", pbrOn ? "开" : "关",
                shadowOn ? "开" : "关", (double)envK);
        ui_2d_text(14, 60, hud, -8355712, 12, VML_ANCHOR_LEFT);
        ui_2d_text(14, 80, "拖动旋转 · 两指缩放 · A 自转 · B 换模型 · X 伽马 · Y 阴影 · SELECT PBR · ↑↓ 环境 · R 复位",
                   -8355712, 11, VML_ANCHOR_LEFT);
        ui_2d_present();

        /* ⚠ 这一句只是"等输入、最多等这么久"；**上屏节奏由宿主管**
         *   （`ui_2d_present` 出来时宿主已经跟着显示走了一帧）—— 别靠改这个数去调帧率。 */
        k = ui_msg_fetch_wait(40);
        (void)k;
    }

    if (scene > 0) ui_3d_gltf_free(scene);
    ui_3d_viewport_free(vp);
    return 0;
}
