/* level.c —— 常用小程序⑦：**水平仪 / 指南针**（第 4 层：`ui_*` 图形界面）
 * level.c -- Everyday utility #7: a **spirit level and compass** (layer 4: the `ui_*` graphical interface)
 *
 * 干什么：一屏两个仪表。上面是**圆形气泡水平仪** —— 外圈 + 中心十字 + 一个随倾斜移动的气泡，
 * What it does: two instruments on one screen. Above is a **circular bubble level** -- an outer ring,
 * 气泡停进中间那个圈就是水平；右边三行是加速度计三个轴的读数（单位**毫克**，1000 = 1g，
 * a crosshair and a bubble that moves with the tilt; the bubble resting in the middle circle means level.
 * 放平时 z ≈ 1000），下面一句"水平 / 倾斜 n°"的判定。
 * To the right, three lines read the accelerometer axes (in **milli-g**, 1000 = 1g, so lying flat gives
 * z ~ 1000), with a "level / tilted n degrees" verdict underneath.
 * 下面是**罗盘**：指北针跟着融合姿态的方位角转，旁边是 0-359 的度数。
 * Below is a **compass**: the needle follows the heading of the fused attitude, labelled in whole degrees.
 * 点「校准」按钮 = `ui_sensor_calibrate(VML_SENS_ACCEL)`（把**当前姿态**当零点）——
 * Tapping "Calibrate" calls `ui_sensor_calibrate(VML_SENS_ACCEL)`, making the **current attitude** the
 * 躺着玩时"水平"本来就是错的，靠它纠正。
 * zero point -- "level" is wrong when you are lying down, and this corrects it.
 * 键盘上回车/空格等效于按那个按钮（桌面上不用鼠标也能验）。
 * Enter or space does the same thing as the button (so the desktop can be checked without a mouse).
 *
 * ## ⚠ 手机上才有 —— 桌面（vmlcli）上"这台设备没有传感器"才是**正确输出**
 * ## ⚠ Phone-only -- on the desktop (vmlcli) "this device has no sensor" is the **correct output**
 *
 * 真机上几乎每台手机都有加速度计；而桌面宿主**一个传感器都没有**：实测
 * Almost every real phone has an accelerometer, while the desktop host **has none**: measured,
 * `ui_sensor_is_available(0)` 返回 0、`ui_sensor_read` 也返回 0。所以本文件的**第一条分支**不是
 * `ui_sensor_is_available(0)` returns 0 and `ui_sensor_read` returns 0 too. So the first branch here is
 * "读到的值是 0"，而是"**这台机器没有这项能力**"，界面上必须**明说**，
 * not "the value read is 0" but "**this machine lacks the capability**", and the screen must **say so**
 * 而不是画一个全 0 的假仪表（那会让用户以为"手机放平了"）。
 * instead of drawing a fake all-zero gauge (which would make the user think the phone is lying flat).
 * 判据是**看图就能分辨**：没有传感器时圆盘里写着「本机没有传感器（手机上才有）」且**不画气泡**；
 * The criterion is visible in the PNG at a glance: with no sensor the dial reads "No sensor on this device
 * (phones only)" and **no bubble is drawn**.
 *
 * ## 桌面怎么看到"有传感器"那一半：宿主自带的输入脚本注入，**不用改一行源码**
 * ## Seeing the sensor branch on the desktop: the host's input-script injection, with **no source change**
 *
 *     printf '0 accel 0.35 -0.20 1\n0 rotation 0 0 45\n' > in.txt
 *     vmlcli level.c --vml-home third_party/vml --input in.txt --timeout 40 --frame level.png
 *
 * `accel` 的单位是 **g**、`rotation` 是**度**（脚本层换算成 SI，见 `CliVmlHost.InjectSensor`）；
 * `accel` is in **g** and `rotation` in **degrees** (the script layer converts to SI, see
 * 换一组数就得到另一张气泡位置不同的图 —— 这就是"气泡真的按 x/y 走"的证据。
 * `CliVmlHost.InjectSensor`); changing the numbers gives a second picture with the bubble somewhere
 * else, which is the evidence that the bubble really tracks x and y.
 * 注入之后 `ui_sensor_is_available` 就报 1（"注入过就一直可用"，与真机上"它一直有读数"同义）。
 * Once injected, `ui_sensor_is_available` reports 1 (injection stays available, matching a real device
 * that always has a reading).
 *
 * ## 踩到的坑（都是这一轮真撞上的，写在这里省得下一个人再撞）
 * ## Pitfalls (all hit for real while writing this file; recorded so the next person does not)
 *
 * ① **`ui_sensor_fetch()` 只有一格缓存，三种传感器共用**（`vmlui.c` 里就三个 `static int`）——
 * ① **`ui_sensor_fetch()` has a single cache slot shared by all three sensors** (three `static int`s in
 *    `ui_sensor_fetch(VML_SENS_ROTATION)` 之后再 `ui_sensor_get_x()`，拿到的是**罗盘**的 x，
 *   `vmlui.c`) -- call `ui_sensor_get_x()` after fetching the rotation sensor and you get the
 *    不是加速度的。所以本文件把顺序钉死：**fetch 谁、立刻读谁的三个数**（见 `poll()`），
 *   **compass** x, not the accelerometer's. So the order here is nailed down: **fetch one, immediately
 *    中间绝不夹另一次 fetch。
 *   read its three numbers** (see `poll()`), with no other fetch in between.
 * ② **桌面注入与程序启动之间有赛跑**：`--input` 的事件由另一个线程按时刻投，程序可能在第一拍
 * ② **There is a race between desktop injection and program start**: the `--input` events are delivered
 *   就问了 `ui_sensor_is_available`，那一瞬间还没注入 ⇒ 报 0。所以"没有"这个结论**每拍都重问一次**
 *   by another thread on a timer, and the program may ask `ui_sensor_is_available` in its very first
 *   （一旦有了就不再问）—— 实测不加这一条时注入那张图仍然是降级画面。
 *   tick before the injection lands, so it reads 0. Hence "absent" is **re-asked every tick** (and not
 *   asked at all once present) -- measured: without this, the injected run still produced the degraded
 *   picture.
 * ③ **本文件不链 `libm`**：`sin`/`cos`/`sqrt` 少了 `math.h` 会退化成"把实参原样返回"的隐式声明
 * ③ **This file does not link `libm`**: without `math.h`, `sin`/`cos`/`sqrt` fall back to implicit
 *   （本仓有过实测），所以罗盘指针用一张 24 项的**整数正弦/余弦表**、倾斜量用**整数牛顿法开方**，
 *   declarations that return their argument unchanged (measured in this repo), so the compass needle
 *   全程整数运算 —— 顺带也避开了"浮点当实参传位模式"那类坑。
 *   uses a 24-entry **integer sine/cosine table** and the tilt uses an **integer Newton square root**,
 *   all in integer arithmetic -- which also sidesteps the "float argument passed as a bit pattern" trap.
 * ④ **"水平"这个判定与气泡位置必须同源**：两处各算一遍就会出现"气泡在中心、却写着倾斜 3°"。
 * ④ **The "level" verdict and the bubble position must share one source**; two copies of the rule give
 *   这里只有 `tiltMdeg` 一个量，判定与画图都读它（本仓的"同一规则两处实现"老毛病）。
 *   the contradiction of a bubble at the centre captioned "tilted 3 degrees". Here a single quantity,
 *   `tiltMdeg`, is read by both (this repo's recurring "one rule, two implementations" bug).
 * ⑤ ⚠ **那个判定必须先问"有没有传感器"**：没有传感器时 `tiltMdeg` 恒 0，只看数字会把目标圈
 * ⑤ ⚠ **That verdict must ask about the sensor first**: with no sensor `tiltMdeg` is always 0, and
 *   画成绿色的"放得很平" —— 正是本文件要避免的谎话。第一版就是这么错的，看图才发现。
 *   reading the number alone paints the target ring a green "perfectly flat" -- exactly the lie this
 *   file exists to avoid. The first version did that, and only the picture showed it.
 * ⑥ ⚠ **文件级 `static` 标量的初值会被静默丢掉**（2026-10-07 实测）：`static int x = -1;` 读到 **0**。
 * ⑥ ⚠ **A file-scope `static` scalar silently loses its initializer** (measured 2026-10-07): `static int
 *   下面那两张正弦表**是数组**、逐项初始化，实测正常（否则半张表是 0、指针只会往右上角指）。
 *   x = -1;` reads **0**. The sine tables below are **arrays** with per-element initializers, which
 *   完整边界与三个探针见 `battery.c` 文件头坑 ⑥（那里当场撞上了这个坑）。
 *   measure fine (otherwise half the table would be 0 and the needle would only point up-right). The
 *   full boundary and the three probes are in pitfall 6 of `battery.c`, which hit this one for real.
 *
 * 版面：尺寸一律从宿主问（`ui_scr_get_w()/_h()`），颜色是真彩 `0xAARRGGBB`，画完必须 `ui_2d_present()`。
 * Layout: every size is asked of the host (`ui_scr_get_w()/_h()`), colours are true colour
 * 循环**必须有界**（例程要能被自动验收）：空闲 10 秒自己退出，`Esc`/`q` 也退出。
 * `0xAARRGGBB`, and the frame must end with `ui_2d_present()`. The loop **must be bounded** (samples have
 * 写真正的程序时把 `IDLE_EXIT_MS` 去掉即可。
 * to be automatically verifiable): it exits after 10 s of idling, and `Esc`/`q` quits too. Drop
 * `IDLE_EXIT_MS` when you write a real program.
 */

#include <vml_ui.h>
#include <stdlib.h>   /* itoa --- digits to text via the library */

#define IDLE_EXIT_MS 10000        /*  空闲 10 秒自己退出（验收用） */
                                  /* idle exit after 10 s (for automated checks) */

/* 配色（真彩 AA RRGGBB）*/
/* Colours (true colour AA RRGGBB) */
#define C_BG      0xFF0E1218
#define C_DIAL    0xFF161B24
#define C_RING    0xFF3C4658
#define C_GRID    0xFF2A3346
#define C_TITLE   0xFFE8E8F0
#define C_DIM     0xFF8A94A8
#define C_OK      0xFF4ADE80     /*  水平 */
                                 /* level */
#define C_TILT    0xFFFFD166     /*  倾斜 */
                                 /* tilted */
#define C_ERR     0xFFFF8A80     /*  没有这项能力 */
                                 /* capability absent */
#define C_BUB     0xFF6FC3FF     /*  气泡 */
                                 /* the bubble */
#define C_NEEDLE  0xFFE05A4A     /*  指北针 */
                                 /* the compass needle */
#define C_BTN     0xFF233046
#define C_BTNOFF  0xFF1B2029

/* 中心目标圈 / 气泡的半径下限（太小的屏幕上也要看得见）*/
/* Floor for the centre target ring and the bubble radius (must stay visible on small screens) */
#define BUB_MIN   6

/* ── 全局状态 ───────────────────────────────────────────────────────── */
/* -- Global state -- */
static int SW;                    /*  画布宽 */
                                 /* canvas width */
static int SH;                    /*  画布高 */
                                 /* canvas height */
static int g_pad;                 /*  页边距 */
                                 /* page margin */
static int lang;                  /*  界面语言：0 = 中文，1 = English */
                                 /* UI language: 0 = Chinese, 1 = English */

static int hasAcc;                /*  这台机器有加速度计吗 */
                                 /* does this machine have an accelerometer */
static int hasRot;                /*  有融合姿态（罗盘要靠它）吗 */
                                 /* does it have a fused attitude (what the compass needs) */
static int accX;                  /*  加速度三轴，毫克（1000 = 1g）*/
                                 /* the three accelerometer axes, in milli-g (1000 = 1g) */
static int accY;
static int accZ;
static int heading;               /*  方位角，千分之一度（0 = 北）*/
                                 /* heading, thousandths of a degree (0 = north) */
static int tiltMdeg;              /*  倾斜量，千分之一度（**判定与画图共用这一个量**）*/
                                 /* tilt, thousandths of a degree (**the single source for verdict and drawing**) */

/* 几何（画与命中判定**必须用同一份**，见 `hitButton`）*/
/* Geometry (drawing and hit testing **must share this one copy**, see `hitButton`) */
static int dCx;
static int dCy;
static int dR;                    /*  圆盘中心与半径 */
                                 /* dial centre and radius */
static int cmpCx;
static int cmpCy;
static int cmpR;                  /*  罗盘中心与半径 */
                                 /* compass centre and radius */
static int tX;
static int tY;                    /*  右侧读数文本的左上角 */
                                 /* top-left of the readout text column */
static int rowH;                  /*  读数行高 */
                                 /* readout row height */
static int headY;                 /*  罗盘下面那行度数 */
                                 /* the heading line under the compass */
static int btnX;
static int btnY;
static int btnW;
static int btnH;                  /*  校准按钮 */
                                 /* the calibrate button */

/* 24 项的整数正弦/余弦表（千分之一）：15 度一格，够画一根指针了。
 * A 24-entry integer sine/cosine table (thousandths): 15 degrees per step, plenty for a needle.
 * **不用 libm** —— 见文件头坑 ③。
 * **No libm** -- see pitfall 3 in the header.
 */
static int sin1000[24] = { 0, 259, 500, 707, 866, 966, 1000, 966, 866, 707, 500, 259, 0, -259, -500, -707, -866, -966, -1000, -966, -866, -707, -500, -259 };
static int cos1000[24] = { 1000, 966, 866, 707, 500, 259, 0, -259, -500, -707, -866, -966, -1000, -966, -866, -707, -500, -259, 0, 259, 500, 707, 866, 966 };

/* ── 小工具 ─────────────────────────────────────────────────────────── */
/* -- Small helpers -- */

/* 整数平方根（牛顿迭代）。浮点那套在这里没必要，还省掉一次 libm 依赖。 */
/* Integer square root (Newton iteration). Floating point is unnecessary here and saves a libm dependency. */
static int isqrt(int v) {
    int x;
    int y;
    if (v <= 0) return 0;
    x = v;
    y = (x + 1) / 2;
    while (y < x) { x = y; y = (x + v / x) / 2; }
    return x;
}

/* 整数 → 十进制串（`ui_2d_text` 只收字符串；第 4 层没有 `print_int` 那种东西）。
 * Integer to a decimal string (`ui_2d_text` takes strings only; layer 4 has no `print_int`).
 * 同样是静态缓冲，别在同一个表达式里连用两次。
 * Also a static buffer; never use it twice in one expression.
 */
static char g_i10[16];
static char* numstr(int v) {
    itoa(v, g_i10);
    return g_i10;
}

/* 千分数 → "-0.250" 形式（符号 + 整数部分 + 三位小数）。本仓惯例：不走 sprintf 那条路。
 * Thousandths to "-0.250" (sign, integer part, three decimals). House convention: do not use sprintf.
 * ⚠ 返回的是**同一个静态缓冲** —— 别在一个表达式里连用两次（`strcat` 两次会在中间被覆盖）。
 * ⚠ It returns **one static buffer** -- never use it twice in one expression (the second call overwrites it).
 */
static char g_f3[24];
static char* fixed3(int v) {
    int neg;
    int ip;
    int fp;
    int i;
    int n;
    char tmp[16];
    neg = 0;
    if (v < 0) { neg = 1; v = 0 - v; }
    ip = v / 1000;
    fp = v - ip * 1000;
    i = 0;
    if (neg != 0) { g_f3[i] = '-'; i = i + 1; }
    n = 0;
    if (ip == 0) { tmp[n] = '0'; n = n + 1; }
    while (ip > 0) { tmp[n] = (char)('0' + ip % 10); ip = ip / 10; n = n + 1; }
    while (n > 0) { n = n - 1; g_f3[i] = tmp[n]; i = i + 1; }
    g_f3[i] = '.'; i = i + 1;
    g_f3[i] = (char)('0' + (fp / 100) % 10); i = i + 1;
    g_f3[i] = (char)('0' + (fp / 10) % 10); i = i + 1;
    g_f3[i] = (char)('0' + fp % 10); i = i + 1;
    g_f3[i] = 0;
    return g_f3;
}

/* 十分之一单位 → "20.1"（倾斜角用它，一位小数就够）。同样是静态缓冲。
 * Tenths of a unit to "20.1" (used for the tilt angle; one decimal is plenty). Also a static buffer. */
static char g_f1[16];
static char* fixed1(int v) {
    int neg;
    int ip;
    int fp;
    int i;
    int n;
    char tmp[16];
    neg = 0;
    if (v < 0) { neg = 1; v = 0 - v; }
    ip = v / 10;
    fp = v - ip * 10;
    i = 0;
    if (neg != 0) { g_f1[i] = '-'; i = i + 1; }
    n = 0;
    if (ip == 0) { tmp[n] = '0'; n = n + 1; }
    while (ip > 0) { tmp[n] = (char)('0' + ip % 10); ip = ip / 10; n = n + 1; }
    while (n > 0) { n = n - 1; g_f1[i] = tmp[n]; i = i + 1; }
    g_f1[i] = '.'; i = i + 1;
    g_f1[i] = (char)('0' + fp % 10); i = i + 1;
    g_f1[i] = 0;
    return g_f1;
}

static int clampInt(int v, int lo, int hi) {
    if (v < lo) return lo;
    if (v > hi) return hi;
    return v;
}

/* ── 读传感器 ───────────────────────────────────────────────────────── */
/* -- Reading the sensors -- */

/* 采一次。返回 1 = 画面要重画（读数或"有没有"变了）。
 * Samples once. Returns 1 when the screen needs a redraw (a reading or an availability changed).
 * ⚠ 顺序不能乱：**fetch 谁就立刻读谁的三个数**（只有一格缓存，见文件头坑 ①）。
 * ⚠ The order is not free: **fetch one and read its three numbers at once** (single cache slot, pitfall 1).
 * ⚠ "没有"每拍重问（桌面注入与启动之间有赛跑，见坑 ②）；"有"了就不再问。
 * ⚠ "Absent" is re-asked every tick (the injection race, pitfall 2); once present it is not asked again.
 */
static int poll(void) {
    int changed;
    int a;
    int b;
    int c;
    changed = 0;

    if (hasAcc == 0) { hasAcc = ui_sensor_is_available(VML_SENS_ACCEL); if (hasAcc != 0) changed = 1; }
    if (hasRot == 0) { hasRot = ui_sensor_is_available(VML_SENS_ROTATION); if (hasRot != 0) changed = 1; }

    if (hasAcc == 1) {
        a = ui_sensor_fetch(VML_SENS_ACCEL);
        if (a == 1) {
            b = ui_sensor_get_x();
            c = ui_sensor_get_y();
            a = ui_sensor_get_z();
            if (b != accX || c != accY || a != accZ) changed = 1;
            accX = b;
            accY = c;
            accZ = a;
        }
    }

    if (hasRot == 1) {
        a = ui_sensor_fetch(VML_SENS_ROTATION);
        if (a == 1) {
            b = ui_sensor_get_z();          /*  z = 方位角（x/y 是俯仰与翻滚）*/
                                           /* z = heading (x/y are pitch and roll) */
            if (b != heading) changed = 1;
            heading = b;
        }
    }

    /* 倾斜量：水平方向的重力分量 → 千分之一度。
     * The tilt: the horizontal component of gravity, in thousandths of a degree.
     * ⚠ 这是**小角近似**（`sin θ ≈ θ`）：约 45° 以内误差 <5%，再大就明显偏小，
     * ⚠ This is the **small-angle approximation** (`sin t ~ t`): under ~45 degrees the error is below 5%,
     *   到 90° 附近只能报出 57° 左右。要精确就得链 `math.h` 走 `asin` ——
     *   beyond that it reads low, and near 90 degrees it can only report about 57. Exact values need
     *   本文件刻意不链（见坑 ③），水平仪要的恰恰是"接近水平"那一段，所以够用。
     *   `asin` from `math.h`, which this file deliberately avoids (pitfall 3) -- and a spirit level cares
     *   about the near-level range anyway, so this is enough.
     */
    a = isqrt(accX * accX + accY * accY);
    b = (a * 573 + 5) / 10;                 /*  毫克 → 千分之一度（1 毫克 ≈ 0.0573°）*/
                                           /* milli-g to thousandths of a degree (1 milli-g ~ 0.0573 deg) */
    if (hasAcc == 0) b = 0;
    if (b != tiltMdeg) changed = 1;
    tiltMdeg = b;
    return changed;
}

/* ── 版面 ───────────────────────────────────────────────────────────── */
/* -- Layout -- */

static void layout(void) {
    int blockTop;
    int blockH;
    int colH;
    int rowsH;

    SW = ui_scr_get_w();
    SH = ui_scr_get_h();
    if (SW <= 0) SW = 360;
    if (SH <= 0) SH = 620;

    g_pad = SW / 18;
    if (g_pad < 10) g_pad = 10;

    /* 圆盘半径：**宽度与高度各让一步**取小的那个 ——
     * The dial radius: **bounded by both width and height**, whichever is smaller --
     * 只看宽度的话，矮屏（横屏）上圆盘会把下面整块挤出去。
     * using width alone would push the whole lower block off a short (landscape) screen.
     */
    dR = SW / 2 - g_pad * 2;
    if (dR > (SH - 300) / 2) dR = (SH - 300) / 2;
    if (dR > 130) dR = 130;
    if (dR < 44) dR = 44;
    dCx = SW / 2;
    dCy = g_pad + 54 + dR;                 /*  标题两行之下 */
                                          /* below the two heading lines */

    /* 底下一条带子分两栏：**左罗盘、右读数柱**（省下来的高度留给圆盘）。
     * The band below splits into two columns: **compass on the left, readouts on the right** (the height
     * 带子的高度取两栏里高的那一栏，两栏都从带子顶部开始排 —— 这样"谁高"都不会把对方挤出去。
     * saved goes to the dial). The band is as tall as the taller column and both start at its top, so
     * neither can push the other off.
     */
    blockTop = dCy + dR + 18;
    cmpR = SW / 12;
    if (cmpR > 40) cmpR = 40;
    if (cmpR < 22) cmpR = 22;
    rowH = cmpR;                           /*  一行读数的高度跟着罗盘走，两栏才对得齐 */
                                          /* the readout row height follows the compass so the two columns line up */
    if (rowH > 21) rowH = 21;
    colH = cmpR * 2 + 18;                  /*  圆 + 下面那行度数 */
                                          /* the circle plus the heading line below it */
    rowsH = rowH * 4;                      /*  三行加速度 + 一行判定 */
                                          /* three accelerometer rows plus the verdict */
    blockH = (colH > rowsH) ? colH : rowsH;
    cmpCx = g_pad + cmpR;
    cmpCy = blockTop + cmpR;
    headY = blockTop + cmpR * 2 + 8;
    tX = cmpCx + cmpR + 14;
    tY = blockTop + 1;

    btnW = SW - g_pad * 2;
    if (btnW > 260) btnW = 260;
    btnH = 42;
    btnX = (SW - btnW) / 2;
    btnY = blockTop + blockH + 14;
    /* 按钮与底部的提示行**不许打架**：矮屏上把按钮顶回提示行之上。 */
    /* The button must not fight the footer: on a short screen push it back above the footer line. */
    if (btnY + btnH > SH - 34) btnY = SH - 34 - btnH;
    if (btnY < blockTop + blockH - btnH / 2) btnY = blockTop + blockH - btnH / 2;
}

/* ── 绘制 ───────────────────────────────────────────────────────────── */
/* -- Drawing -- */

static void drawDial(void) {
    int bub;
    int lim;
    int bx;
    int by;
    int isLevel;

    bub = dR / 7;
    if (bub < BUB_MIN) bub = BUB_MIN;
    /* ⚠ **"水平"必须先问"有没有传感器"**：没有传感器时 `tiltMdeg` 恒 0，
     * ⚠ **"Level" must first ask whether there is a sensor**: with none, `tiltMdeg` is always 0,
     *   只看数字的话目标圈会亮成**绿色**（"放得很平"）—— 而那正是本文件要避免的谎话。
     *   so reading the number alone lights the target ring **green** ("perfectly flat") -- exactly the
     *   实测：第一版就是这么画的，看图才发现圆盘里写着"没有传感器"、中间却是一圈绿。
     *   lie this file exists to avoid. Measured: the first version did that, and only the picture showed
     *   the dial reading "no sensor" with a green ring in the middle.
     */
    isLevel = (hasAcc == 1 && tiltMdeg < 1000) ? 1 : 0;   /*  1000 千分之一度 = 1 度以内算"水平" */
                                                         /* within 1000 thousandths of a degree = 1 degree */

    ui_2d_circle(dCx, dCy, dR, C_DIAL, 1, 0);
    ui_2d_circle(dCx, dCy, dR, C_RING, 0, 2);
    /* 参照圈：给"偏了多少"一个尺度 */
    /* Reference rings: a scale for "how far off" you are */
    ui_2d_circle(dCx, dCy, dR / 3, C_GRID, 0, 1);
    ui_2d_circle(dCx, dCy, dR * 2 / 3, C_GRID, 0, 1);
    /* 十字准线 */
    /* Crosshair */
    ui_2d_line(dCx - dR, dCy, dCx + dR, dCy, C_GRID, 1);
    ui_2d_line(dCx, dCy - dR, dCx, dCy + dR, C_GRID, 1);
    /* 中心的目标圈：气泡停进来 = 水平（颜色跟着判定走，与读数同源）*/
    /* The centre target ring: a bubble inside it means level (its colour follows the verdict, one source) */
    ui_2d_circle(dCx, dCy, bub + 5, isLevel != 0 ? C_OK : C_GRID, 0, 2);

    if (hasAcc == 0) {
        /* ⚠ **降级分支**：不画气泡，明说"这台机器没有"。
         * ⚠ **The degraded branch**: draw no bubble and say plainly that this machine has none.
         *   画个居中的全 0 气泡等于告诉用户"你的手机放得很平" —— 那是撒谎。
         *   An all-zero bubble at the centre would tell the user the phone is perfectly flat -- a lie.
         */
        ui_2d_text(dCx, dCy - 14, lang == 0 ? "本机没有传感器" : "No sensor on this device", C_ERR, 16, VML_ANCHOR_CENTER);
        ui_2d_text(dCx, dCy + 12, lang == 0 ? "（手机上才有）" : "(phones only)", C_ERR, 13, VML_ANCHOR_CENTER);
        return;
    }

    /* 气泡：加速度计静止时读到的是**世界"上"方向在设备坐标里的表示**（哪条轴朝上）⇒
     * The bubble: at rest the accelerometer reads **world "up" expressed in device coordinates** (which
     * 气泡往**低**处滚，所以取**负号**（与 tilt.c 的实测结论一致：抬高右边，球往左走）。
     * axis points up), so the bubble rolls to the **low** side and the sign is **negated** (matching the
     * 写反的症状是"歪这边、气泡往那边"，看着像传感器的轴装反了，其实错在这两行。
     * measurement in tilt.c: lift the right edge and the ball goes left). Getting it backwards looks like
     * mis-mounted sensor axes, but the bug is right here.
     */
    lim = dR - bub - 3;
    bx = dCx - accX * lim / 1000;
    by = dCy - accY * lim / 1000;
    /* 只有超过 1g 的剧烈动作才需要夹 —— 真机上很少见，但夹住比画到画布外面好。 */
    /* Clamping only matters past 1g, which is rare on a phone, but it beats drawing off-canvas. */
    bx = clampInt(bx, dCx - lim, dCx + lim);
    by = clampInt(by, dCy - lim, dCy + lim);
    ui_2d_circle(bx, by, bub + 3, 0x606FC3FF, 1, 0);   /*  半透明的外圈 */
                                                      /* translucent outer ring */
    ui_2d_circle(bx, by, bub, C_BUB, 1, 0);
}

static void drawCompass(void) {
    int deg;
    int idx;
    int dx;
    int dy;
    char buf[32];

    ui_2d_circle(cmpCx, cmpCy, cmpR, C_DIAL, 1, 0);
    ui_2d_circle(cmpCx, cmpCy, cmpR, C_RING, 0, 2);
    /* 四个方位的刻度与 N 字 */
    /* Ticks for the four cardinal points, plus the N label */
    ui_2d_line(cmpCx, cmpCy - cmpR + 2, cmpCx, cmpCy - cmpR + 9, C_RING, 2);
    ui_2d_line(cmpCx, cmpCy + cmpR - 2, cmpCx, cmpCy + cmpR - 9, C_RING, 2);
    ui_2d_line(cmpCx - cmpR + 2, cmpCy, cmpCx - cmpR + 9, cmpCy, C_RING, 2);
    ui_2d_line(cmpCx + cmpR - 2, cmpCy, cmpCx + cmpR - 9, cmpCy, C_RING, 2);
    ui_2d_text(cmpCx, cmpCy - cmpR - 13, "N", C_TILT, 12, VML_ANCHOR_CENTER);

    if (hasRot == 0) {
        /* 罗盘没得画就**明说**，别画一根永远指向北的假指针。 */
        /* With no compass, **say so** instead of drawing a needle that always points north. */
        ui_2d_text(cmpCx, cmpCy - 7, lang == 0 ? "无" : "n/a", C_ERR, 13, VML_ANCHOR_CENTER);
        ui_2d_text(cmpCx, headY, lang == 0 ? "无方位传感器" : "no heading sensor", C_ERR, 11, VML_ANCHOR_CENTER);
        return;
    }

    /* 方位角归一化到 0-359 度（读数可以是负的，C 的取余会留负号，得自己补回来）*/
    /* Normalise the heading to 0-359 degrees (the reading may be negative; C's remainder keeps the sign) */
    deg = heading / 1000;
    deg = deg % 360;
    if (deg < 0) deg = deg + 360;
    idx = (deg * 24) / 360;
    if (idx > 23) idx = 23;
    dx = sin1000[idx] * (cmpR - 6) / 1000;
    dy = cos1000[idx] * (cmpR - 6) / 1000;
    /* 屏幕 y 向下，所以"北朝上"是 `-dy` */
    /* Screen y grows downward, so "north is up" means `-dy` */
    ui_2d_line(cmpCx, cmpCy, cmpCx - dx / 3, cmpCy + dy / 3, C_DIM, 2);   /*  尾巴（南）*/
                                                                        /* the tail (south) */
    ui_2d_line(cmpCx, cmpCy, cmpCx + dx, cmpCy - dy, C_NEEDLE, 3);        /*  指向（北）*/
                                                                        /* the pointing half (north) */
    ui_2d_circle(cmpCx, cmpCy, 3, C_TITLE, 1, 0);

    /* 度数写在**罗盘正下方固定的一行**（不跟指针走：跟指针走会在指针朝右/朝左时
     * Degrees go on a **fixed line right under the compass** (not at the needle tip: a tip-following
     * 撞进右边的读数柱里 —— 第一版就是这么画的，两张验收图里一张压住 `x … g`、一张压住圆盘。
     * label ran into the readout column when the needle pointed sideways -- the first version did that,
     * 固定一行才有"读数"该有的样子，位置也与指针无关。
     * and one acceptance picture had it over `x ... g` while the other had it over the dial. A fixed line
     * looks like a readout should, and its position does not depend on the needle.
     */
    buf[0] = 0;
    strcat(buf, numstr(deg));
    strcat(buf, lang == 0 ? " 度" : " deg");
    ui_2d_text(cmpCx, headY, buf, C_NEEDLE, 12, VML_ANCHOR_CENTER);
}

/* 右侧读数柱：三行加速度 + 一行判定。**判定用的就是 `tiltMdeg`**（与气泡同源）。
 * The readout column on the right: three accelerometer rows plus the verdict. **The verdict reads
 * `tiltMdeg`** (the same source the bubble uses).
 */
static void drawReadout(void) {
    int y;
    char buf[64];
    int col;

    y = tY;
    buf[0] = 0;

    if (hasAcc == 0) {
        ui_2d_text(tX, y, lang == 0 ? "加速度计：无" : "accel: none", C_ERR, 14, VML_ANCHOR_LEFT);
        ui_2d_text(tX, y + rowH, lang == 0 ? "读数不可用" : "no reading", C_ERR, 13, VML_ANCHOR_LEFT);
        ui_2d_text(tX, y + rowH * 3,
                   (hasRot == 1) ? (lang == 0 ? "罗盘：有" : "compass: yes")
                                 : (lang == 0 ? "罗盘：无" : "compass: none"),
                   (hasRot == 1) ? C_DIM : C_ERR, 13, VML_ANCHOR_LEFT);
        return;
    }

    buf[0] = 0; strcat(buf, "x "); strcat(buf, fixed3(accX)); strcat(buf, " g");
    ui_2d_text(tX, y, buf, C_TITLE, 14, VML_ANCHOR_LEFT);
    buf[0] = 0; strcat(buf, "y "); strcat(buf, fixed3(accY)); strcat(buf, " g");
    ui_2d_text(tX, y + rowH, buf, C_TITLE, 14, VML_ANCHOR_LEFT);
    buf[0] = 0; strcat(buf, "z "); strcat(buf, fixed3(accZ)); strcat(buf, " g");
    ui_2d_text(tX, y + rowH * 2, buf, C_TITLE, 14, VML_ANCHOR_LEFT);

    col = (tiltMdeg < 1000) ? C_OK : C_TILT;
    buf[0] = 0;
    if (tiltMdeg < 1000) {
        strcat(buf, lang == 0 ? "水平" : "level");
    } else {
        strcat(buf, lang == 0 ? "倾斜 " : "tilted ");
        strcat(buf, fixed1((tiltMdeg + 50) / 100));    /*  千分之一度 → 十分之一度 */
                                                      /* thousandths of a degree to tenths */
        strcat(buf, "°");
    }
    ui_2d_text(tX, y + rowH * 3, buf, col, 15, VML_ANCHOR_LEFT);
}

static void drawButton(void) {
    int on;

    on = (hasAcc == 1) ? 1 : 0;
    ui_2d_rect(btnX, btnY, btnW, btnH, on != 0 ? C_BTN : C_BTNOFF, 1, 0, btnH / 2);
    ui_2d_text(SW / 2, btnY + (btnH - 16) / 2,
               on != 0 ? (lang == 0 ? "校准（把当前姿态当零点）" : "Calibrate (take this pose as zero)")
                       : (lang == 0 ? "校准不可用（没有传感器）" : "Calibrate unavailable (no sensor)"),
               on != 0 ? C_TITLE : C_DIM, 14, VML_ANCHOR_CENTER);
}

static void draw(void) {
    ui_2d_clear(C_BG);

    ui_2d_text_styled(SW / 2, g_pad, lang == 0 ? "水平仪 / 指南针" : "Level / Compass",
                      C_TITLE, 18, VML_ANCHOR_CENTER, VML_FONT_BOLD);
    if (hasAcc == 1 && hasRot == 1) {
        ui_2d_text(SW / 2, g_pad + 26, lang == 0 ? "加速度计 + 融合姿态" : "accelerometer + fused attitude",
                   C_DIM, 11, VML_ANCHOR_CENTER);
    } else if (hasAcc == 1 || hasRot == 1) {
        ui_2d_text(SW / 2, g_pad + 26, lang == 0 ? "只有一部分传感器可用" : "only part of the sensors are available",
                   C_TILT, 11, VML_ANCHOR_CENTER);
    } else {
        ui_2d_text(SW / 2, g_pad + 26, lang == 0 ? "本机没有传感器（手机才有）" : "no sensors on this device (phones only)",
                   C_ERR, 11, VML_ANCHOR_CENTER);
    }

    drawDial();
    drawCompass();
    drawReadout();
    drawButton();

    ui_2d_text(SW / 2, SH - 22,
               (hasAcc == 0)
                   ? (lang == 0 ? "点不动也没关系：这台设备本来就没有传感器   Esc / q 退出"
                                : "nothing to tap: this device has no sensors   Esc / q quits")
                   : (lang == 0 ? "点「校准」或按回车 = 校零   Esc / q 退出"
                                : "tap Calibrate or press Enter to zero it   Esc / q quits"),
               C_DIM, 11, VML_ANCHOR_CENTER);

    ui_2d_present();      /* ⚠ 不调这句 = 黑屏 */
                          /* without this call you get a black screen */
}

/* 点在按钮里吗（与 `drawButton` 用的是**同一组** btnX/Y/W/H）*/
/* Did the tap land on the button (the **same** btnX/Y/W/H that `drawButton` uses) */
static int hitButton(int px, int py) {
    if (px >= btnX && px < btnX + btnW && py >= btnY && py < btnY + btnH) return 1;
    return 0;
}

/* ── 主流程 ─────────────────────────────────────────────────────────── */
/* -- Main flow -- */
int main(void) {
    int msg[4];
    int t;
    int t0;
    int done;

    lang = ui_scr_get_language();          /*  开局查一次，别每帧调 */
                                          /* queried once, not every frame */

    /* "有没有"**开窗之前就能问**，先问再决定画什么。 */
    /* Availability is **askable before opening a window**; ask first, then decide what to draw. */
    hasAcc = ui_sensor_is_available(VML_SENS_ACCEL);
    hasRot = ui_sensor_is_available(VML_SENS_ROTATION);
    /* 采样率：这个程序的逻辑是 5 拍/秒（200ms 的等待），60~100ms 采一次足够，
     * Sample rate: this program ticks 5 times a second (a 200 ms wait), so 60-100 ms is plenty;
     * 还能省电（传感器一开着就在耗电，间隔越大越省）。
     * it also saves power (a running sensor draws current, and a longer interval costs less).
     */
    if (hasAcc == 1) ui_sensor_set_rate(VML_SENS_ACCEL, 60);
    if (hasRot == 1) ui_sensor_set_rate(VML_SENS_ROTATION, 100);

    poll();                                /*  首帧之前先采一次，否则第一帧画的是"没有传感器" */
                                          /* sample before the first frame, or it renders as "no sensor" */

    layout();
    ui_win_open_ex(lang == 0 ? "水平仪 / 指南针" : "Level / Compass",
                   0, 0, VML_WIN_ROTATABLE, VML_WIN_NO_GAMEPAD);
    layout();
    draw();

    t0 = ui_time_get_ms();
    done = 0;
    while (!done && ui_win_is_closed() == 0) {
        t = ui_msg_wait(msg, 200);
        if (t == VML_MSG_NONE) {
            /* 没有消息**不代表没有变化** —— 传感器是**轮询**的（姿态是连续量，投消息只会淹掉队列），
             * No message does **not** mean nothing changed -- sensors are **polled** (attitude is a
             * 所以每拍自己采一次，变了才重画（刷新由"变化"驱动，不由节拍驱动）。
             * continuous quantity and posting messages would flood the queue), so sample every tick and
             * redraw only on change (refresh driven by change, not by a beat).
             */
            if (ui_time_get_ms() - t0 > IDLE_EXIT_MS) done = 1;
            else if (poll() != 0) draw();
            continue;
        }
        t0 = ui_time_get_ms();
        if (t == VML_MSG_KEYDOWN) {
            int k = msg[1];
            if (k == VML_KEY_ESCAPE || k == 'q' || k == 'Q') done = 1;
            else if (k == VML_KEY_ENTER || k == VML_KEY_SPACE) {
                if (hasAcc == 1) {
                    ui_sensor_calibrate(VML_SENS_ACCEL);
                    poll();          /*  ⚠ 校准后**立刻**重读：不重读的话气泡要"慢慢地"才回中，看着像没生效 */
                                     /* ⚠ Re-read **immediately** after calibrating: without it the bubble
                                      * only drifts back to the centre, which looks like nothing happened. */
                    draw();
                }
            }
            continue;
        }
        if (t == VML_MSG_TOUCHDOWN || t == VML_MSG_MOUSEDOWN) {
            if (hitButton(msg[1], msg[2]) != 0 && hasAcc == 1) {
                ui_sensor_calibrate(VML_SENS_ACCEL);
                poll();
                draw();
            }
            continue;
        }
        if (t == VML_MSG_WINDOWORIENT || t == VML_MSG_WINDOWRESIZE) {
            layout();
            draw();
            continue;
        }
        if (t == VML_MSG_WINDOWCLOSE) done = 1;
    }
    return 0;
}
