/* ── 数字孪生（twin）：业态页的「实时态」建筑剖面 ─────────────────
   用途：让「系统正在跑」这件事被看见——楼层平面 + 后勤人员定位 + 当前任务。

   为什么是「剖面模型」而不是屏幕上一叠悬浮板：
   悬浮板只能表达「有几层」，表达不了「这层是什么」。真实场景里院方关心的是
   门诊大厅、手术部、洁净走廊、电梯厅这些**功能位置**，所以这里画的是建筑
   剖面（dollhouse）——楼板有厚度、有外墙与隔墙、有贯穿全楼的交通核、有院区
   地坪，平面按功能铺地面材质。

   分工：房间平面写在每页 HTML 里（.twin__room，带 --x/--y/--w/--h 与
   data-kind），所以关掉 JS 也看得到完整平面；隔墙、交通核立面、楼板厚度、
   柱网由 twin.js 按房间边线推导，六个业态页共用同一套引擎，换业态只改 HTML。

   为什么用 CSS 3D 而不是引入 3D 库：
   站点至今零第三方依赖（78 页纯 HTML/CSS/JS），上一轮刚做完字体自托管与图片
   全量 WebP 的性能优化。为一块宣传视觉引入 three.js 级别的库与这个方向相反；
   而等距剖面只需要「把平面绕两轴转一下」，一条 transform 就够，体积以 KB 计。

   ── 六个踩过的坑，别再把它们踩回来 ──

   1) 不写 perspective（正交投影）。
      带透视时投影是非线性的，下面 .twin__pin 用 counter-rotation 反解出来的
      标签会随高度逐层错位（越靠上的楼层偏得越多）。正交下「反解 = 取反角度」
      是精确的，所以楼层可以自由升降而文字始终端正。

   2) 遮挡方向是「后右角」，不是「后左角」。
      变换后 z 分量 = -0.522x + 0.668y + 0.530z，值越大离观察者越近。
      于是近端是 x 小（左）与 y 大（前），远端是 x 大（右）与 y 小（后）。
      上层楼板把下层压住的那块，实测是：
          x ≥ 0.985×gap  且  y ≤ 100 − 1.260×gap      （gap 与 x/y 同为百分比）
      gap=0.62P 时即 x ≥ 61 且 y ≤ 22。注意这种遮挡不改版面：元素照样有尺寸、
      文字照样有布局框，浏览器也不报错，只是没被画出来。所以断言脚本才是判据，
      别靠看。别为了「多留空」把 --gap 调小：0.30P 时遮挡区会吃掉 44%。

   3) 墙只立「远边」两堵。
      沿 X 走的墙盖住 y 更小的区域，沿 Y 走的墙盖住 x 更大的区域。
      所以每间房只给它立「后边（y 最小）」与「右边（x 最大）」；
      近端那两堵一旦立起来，会把紧挨着它的房间内容整片盖掉（同坑 2，不报错）。

   4) --rig-shift 必须是长度，不能写百分比。
      .twin__rig 宽高都是 0，translateY 的百分比按元素自身尺寸解析，在零尺寸
      元素上会静默归零（这里踩过一次），整栋楼往上溢出场景。

   5) 房间地面要抬离楼板。
      房间是贴在楼板上的平面，与楼板 z 相同会共面闪烁，故 translateZ(1px)。
      同理，交通核顶面只画最顶层的那块——中间层的顶面正好与上层楼板共面。

   6) 交通核侧面的宽度要按「核的高」算，不能用百分比。
      .twin__core-s 若写 width:100%，会按父元素宽度（核的宽）解析，
      得到的是一个歪掉的平行四边形。所以写成 calc(var(--u) * var(--h))。

   ── 坐标系约定 ──
     · 观察者在 -X / +Y / +Z 一侧：近边是 x 小（左）与 y 大（前）
     · 每层楼板是默认平面上的一个正方形，沿本地 +Z 向上堆叠（+Z 投影后朝屏幕上方）
     · 整栋楼由 .twin__rig 统一 rotateX(--tilt) rotateZ(--yaw) 得到等距视角
     · 平面上的任意点用百分比定位（0→100 就是整块板），会被等距变换自然带进
       斜视图，不必手算投影
     · 只有「必须保持正立」的元素（徽标、层签）才反解旋转回去
   ──────────────────────────────────────────────────────────── */

.twin {
  /* 视角与几何。改 --plate 即可整体缩放，其余按比例自动跟随。
     注意：--plate 之外的一切（板厚/隔墙/核/柱、以及注记与徽标的字号、层签宽度、
     徽标抬升）都必须写成 var(--plate) 的比例式，不能写死 rem——否则放大剖面时
     这些绝对长度不跟着长，模型与文字的比例就散了，遮挡关系也会变。
     --plate 用 vw 流体而不是固定值：两列布局下剖面只有约 60% 的内容宽度可用，
     写死 16rem 在 1440 下只用掉可用宽度的七成，看着就小；流体式让它始终贴着
     可用宽度长到上限，窄屏时自动缩回。上限 20rem 是「放大但不至于让整块视觉
     高过一屏」的折中（场景高 = 2.78P + 2.6rem）。 */
  --tilt: 58deg;
  --yaw: -38deg;
  /* --plate 是「进深」（平面的 y 向尺寸），--plate-w 是「面宽」（x 向）。
     两者分开是必须的：医院的楼层平面是宽扁的长方形，做成正方形会读成办公楼
     或塔楼。所有平面坐标的 x 用 --ux、y 用 --uy，不要再混用同一个 --u——
     方形时两者相等看不出来，一旦长方形，沿 y 走的墙与核侧面就会歪。
     --plate-w 写成比例而不是独立 clamp：这样各业态只调一个 --plate 就能整体
     缩放；需要不同长宽比（如厂房更扁）时，只改这个比例。 */
  --plate-w: calc(var(--plate) * 1.4);
  --plate: clamp(15rem, 23vw + 0.2rem, 20rem);
  /* 层间距。别小于 0.6：间距越小，上层楼板的投影带越往近端压，吃掉的下层面积
     越多（0.30P 时吃掉 44%）。放大到 0.75 还有第二个原因——面宽变宽后，同样的
     绝对遮挡量折算成「百分比」会变小（x% = 0.985·gap/plate-w），0.62 时阈值
     落到 43.6%，正好压住后区中间那几间房的注记（x≈50%）；0.75 把阈值推到
     52.8%，后区注记才落在安全侧。改这个值必须同时重算 --rig-shift 与场景高。 */
  --gap: calc(var(--plate) * 0.75);
  --ux: calc(var(--plate-w) / 100);        /* 平面 x 方向 1% 的长度 */
  --uy: calc(var(--plate) / 100);          /* 平面 y 方向 1% 的长度 */
  --slab: calc(var(--plate) * 0.022);      /* 楼板厚度 */
  --wall: calc(var(--plate) * 0.026);      /* 内隔墙（剖切高度，矮） */
  --wall-ext: calc(var(--plate) * 0.064);  /* 外墙 */
  --parapet: calc(var(--plate) * 0.07);    /* 屋面女儿墙 */
  --pin-lift: calc(var(--plate) * 0.028);
  /* 提示卡往「镜头方向」推的深度。见 .twin__tip 里那段说明：卡片与屏幕平行，
     推它等于零屏幕位移、只改深度，所以给得宽裕一些没代价。 */
  --tip-lift: calc(var(--plate) * 1.2);
  --drift-dur: 15s;                        /* 点位的缓步巡走一圈时长，见 .twin__marker */
  /* 把整栋楼连同院区在场景里竖直居中，必须是长度（见坑 4）。
     最高点是顶层交通核顶面（z = 4·gap），最低点是院区前角；
     gap=0.75P、面宽=1.4P 时实测上下各余 ~0.63P，故下移 0.63P。
     改 --plate / --plate-w / --gap / --parapet / 核顶面高度时必须重算，
     check_twin.py 会断言楼层不溢出场景，scripts/_scene_fit.py 用来对账。 */
  --rig-shift: calc(var(--plate) * 0.85);

  position: relative;
  padding: var(--sec-pad) var(--gutter);
  background: var(--ink);
  color: var(--mist);
  overflow: hidden;
}

/* 两团极淡的光，让深色面板有纵深，不至于是一块死黑 */
.twin::before {
  content: '';
  position: absolute;
  inset: 0;
  background:
    radial-gradient(120% 80% at 50% 6%, rgba(31, 107, 92, 0.5) 0%, rgba(11, 47, 43, 0) 62%),
    radial-gradient(70% 50% at 84% 92%, rgba(201, 120, 60, 0.16) 0%, rgba(11, 47, 43, 0) 70%);
  pointer-events: none;
}

.twin__head {
  position: relative;
  max-width: 52rem;
}

.twin__eyebrow {
  margin: 0;
  font-family: var(--font-en);
  font-size: 0.78rem;
  font-weight: 700;
  letter-spacing: 0.2em;
  text-transform: uppercase;
  color: rgba(157, 206, 184, 0.95);
}

.twin__title {
  margin: 0.5rem 0 0;
  font-family: var(--font-display);
  font-size: clamp(1.6rem, 3.1vw, 2.3rem);
  font-weight: 600;
  line-height: 1.3;
}

.twin__lead {
  margin: 0.9rem 0 0;
  font-size: 1rem;
  line-height: 1.7;
  color: rgba(231, 238, 240, 0.82);
}

/* ── 主体：左边剖面，右边驾驶舱 ──
   剖面占约 62%。它是这块视觉的主角（也是用户唯一抱怨「太小」的东西），
   驾驶舱只是陪读的清单，字号收一档后并不需要那么多宽度。 */
.twin__body {
  position: relative;
  display: grid;
  grid-template-columns: minmax(0, 1.62fr) minmax(0, 1fr);
  gap: clamp(1.5rem, 3.5vw, 2.6rem);
  align-items: center;
  margin-top: clamp(1.5rem, 4vh, 2.5rem);
}

.twin__stage {
  position: relative;
  display: grid;
  place-items: center;
}

.twin__scene {
  position: relative;
  width: 100%;
  /* 场景高 = 楼栋竖向总投影（上 ~1.7P + 下 ~1.4P）+ 余量。
     注意竖向投影被 tilt 压过：平面的竖向跨度 =（面宽·sin(yaw) + 进深·cos(yaw)）·cos(tilt)，
     面宽变宽会同时把平面"压"得更扁，这个数必须实测（scripts/_scene_fit.py）而不是推。
     改 --plate / --plate-w / --gap / --parapet / 核顶面高度时这个系数要跟着重算，
     check_twin.py 会断言不溢出。 */
  height: calc(var(--plate) * 3.0 + 2.6rem);
}

/* 楼栋枢轴：0×0 的点落在场景中心；所有楼层相对它定位，
   这样旋转与缩放都绕楼栋中心发生。 */
.twin__rig {
  position: absolute;
  left: 50%;
  top: 50%;
  width: 0;
  height: 0;
  transform-style: preserve-3d;
  /* translateY 作用于屏幕空间，把整栋楼在场景里摆正 */
  transform: translateY(var(--rig-shift)) rotateX(var(--tilt)) rotateZ(var(--yaw));
  transition: transform 0.6s ease;
}

/* ── 背立面与女儿墙 ──
   背立面是本块视觉里唯一「把四层连起来」的东西。没有它，四层板之间的空档
   是纯背景色，读起来就是四块飘着的板；补上它，那几条缝变成楼的立面，四层
   立刻读成一栋四层建筑。几何上它站在背边（y=0）上、绕上边线立起来——
   只立背边，另三条边都会盖住自家平面（坑 3 同理）。
   顶层上方没有楼板让位，整片立满会变成一堵高墙，所以顶层只立女儿墙。 */
.twin__facade {
  position: absolute;
  left: 0;
  top: 0;
  width: 100%;
  height: var(--gap);
  transform-origin: 0 0;
  transform: rotateX(90deg);
  /* 竖明横隐的幕墙格：竖挺密、横梁稀，远看是一片带分格的立面。
     别压得太暗：它要顶住「四层板飘在半空」的观感，太暗就等于没画。 */
  background:
    repeating-linear-gradient(90deg,
      rgba(157, 206, 184, 0.22) 0 1px, rgba(157, 206, 184, 0) 1px 0.9rem),
    linear-gradient(to bottom,
      rgba(26, 80, 72, 0.8) 0%,
      rgba(12, 48, 43, 0.92) 62%,
      rgba(8, 34, 31, 0.95) 100%);
  outline: 1px solid rgba(157, 206, 184, 0.18);
}

.twin__facade--roof {
  height: var(--parapet);
  background: linear-gradient(to bottom,
              rgba(31, 107, 92, 0.6), rgba(9, 38, 35, 0.9));
  border-top: 1px solid rgba(157, 206, 184, 0.5);
}

/* ── 院区地坪：表达院区占地与车行流线，不参与楼层计数 ──
   两个关键点，都不是随手写的：
   1) 高度：贴在建筑基座下（-0.06P ≈ 两个板厚），不能像早先那样吊在
      0.75×gap 下面。吊在半空会让整栋楼读成「四块飘着的板」；贴在基座下，
      「楼站在院区里」这件事才成立，院子里的车道/广场也才有落脚点。
   2) 范围：面宽 +0.32P、进深 1.32P（即四边各留 0.16P 的院子），且与建筑中心对齐
      （rig 原点就是楼板中心，所以 margin 必须取各自的一半）。留 0.16P 而不是更宽，
      是因为院子一大就变成一张「地界框」：楼孤零零浮在正中，车道甩到画面边上，
      反而更像效果图而不是院区。这个余量刚好够一圈环路加门前广场。 */
.twin__ground {
  position: absolute;
  left: 0;
  top: 0;
  width: calc(var(--plate-w) + var(--plate) * 0.32);
  height: calc(var(--plate) * 1.32);
  margin: calc(var(--plate) * -0.66) 0 0 calc((var(--plate-w) + var(--plate) * 0.32) / -2);
  transform: translateZ(calc(var(--plate) * -0.06));
  /* 院区本身只留「落地阴影 + 地界 + 车道 + 绿化」，不铺一块亮底：1.5P 的实心
     板在等距投影下面积是建筑的 2.25 倍，一铺亮就会盖过楼栋，视线先落在空地上。
     中间那圈压暗是楼栋投在院区上的落地影——有它，楼才像站在地上而不是贴着。 */
  background:
    radial-gradient(46% 56% at 50% 50%,
      rgba(0, 0, 0, 0.42) 0 60%, rgba(0, 0, 0, 0) 78%),
    radial-gradient(58% 58% at 50% 50%,
      rgba(157, 206, 184, 0.02) 0%, rgba(157, 206, 184, 0) 72%);
  outline: 1px solid rgba(157, 206, 184, 0.22);
}

/* 院区构件：环形车道 / 入口广场 / 绿化。全部排在建筑投影之外（地面比建筑
   每边多 25%），所以既不压到任何房间注记，也不参与楼层的遮挡账。 */
.twin__site {
  position: absolute;
  background: rgba(157, 206, 184, 0.07);
  outline: 1px solid rgba(157, 206, 184, 0.12);
}

/* 车道中线：用一个 1px 宽的取景框去裁重复渐变，得到沿路方向的虚线 */
.twin__site--road-v {
  background:
    repeating-linear-gradient(0deg,
      rgba(231, 238, 240, 0.5) 0 6px, rgba(231, 238, 240, 0) 6px 14px)
      center / 1px 100% no-repeat,
    rgba(157, 206, 184, 0.07);
}

.twin__site--road-h {
  background:
    repeating-linear-gradient(90deg,
      rgba(231, 238, 240, 0.5) 0 6px, rgba(231, 238, 240, 0) 6px 14px)
      center / 100% 1px no-repeat,
    rgba(157, 206, 184, 0.07);
}

.twin__site--plaza {
  background: rgba(214, 196, 158, 0.13);
  outline-color: rgba(214, 196, 158, 0.26);
}

.twin__site--green {
  background:
    repeating-linear-gradient(45deg,
      rgba(126, 206, 196, 0.16) 0 2px, rgba(126, 206, 196, 0) 2px 5px),
    rgba(31, 107, 92, 0.24);
  outline-color: rgba(126, 206, 196, 0.22);
}

/* ── 楼层楼板 ── */
.twin__floor {
  position: absolute;
  left: 0;
  top: 0;
  width: var(--plate-w);
  height: var(--plate);
  margin: calc(var(--plate) / -2) 0 0 calc(var(--plate-w) / -2);
  transform: translateZ(calc(var(--i) * var(--gap)));
  transform-style: preserve-3d;
  background-color: rgba(48, 79, 73, 0.9);
  background-image:
    linear-gradient(rgba(157, 206, 184, 0.16) 1px, transparent 1px),
    linear-gradient(90deg, rgba(157, 206, 184, 0.16) 1px, transparent 1px);
  background-size: calc(var(--plate) / 10) calc(var(--plate) / 10);
  outline: 1px solid rgba(157, 206, 184, 0.4);
}

/* 楼板高亮：当前楼层亮一点，其余压暗，视线跟着自动巡检走 */
.twin__floor::after {
  content: '';
  position: absolute;
  inset: 0;
  background: rgba(157, 206, 184, 0.08);
  opacity: 0;
  transition: opacity 0.5s ease;
  pointer-events: none;
}

.twin__floor.is-active::after {
  opacity: 1;
}

/* 楼板厚度：近端两条板边（正面 y 侧 + 左侧 x 侧）沿 -Z 压下去。
   比造真实侧壁便宜得多，也不会引入共面闪烁。 */
.twin__slab {
  position: absolute;
  inset: 0;
  transform-style: preserve-3d;
}

.twin__slab::before,
.twin__slab::after {
  content: '';
  position: absolute;
  z-index: -1;
  background: rgba(9, 38, 35, 0.94);
}

.twin__slab::before {
  left: 0;
  right: 0;
  bottom: 0;
  height: var(--slab);
  transform-origin: bottom center;
  transform: rotateX(90deg);
}

.twin__slab::after {
  left: 0;
  top: 0;
  width: var(--slab);
  height: 100%;
  transform-origin: left center;
  transform: rotateY(90deg);
}

/* ── 房间：平面上的功能分区 ──
   位置用 --x/--y/--w/--h（平面百分比，写在 HTML 里）。
   贴在楼板上，必须抬 1px 否则与楼板共面闪烁（见坑 5）。 */
.twin__room {
  position: absolute;
  left: calc(var(--ux) * var(--x));
  top: calc(var(--uy) * var(--y));
  width: calc(var(--ux) * var(--w));
  height: calc(var(--uy) * var(--h));
  transform: translateZ(1px);
  background: rgba(157, 206, 184, 0.09);
}

/* 按功能铺地面材质：一眼能分出公共区 / 诊疗区 / 医技区 / 住院区 */
.twin__room[data-kind="pub"]  { background: rgba(214, 196, 158, 0.26); }
.twin__room[data-kind="care"] { background: rgba(126, 206, 196, 0.24); }
.twin__room[data-kind="tech"] { background: rgba(150, 178, 222, 0.24); }
.twin__room[data-kind="ward"] { background: rgba(206, 190, 214, 0.21); }
.twin__room[data-kind="hall"] { background: rgba(231, 238, 240, 0.17); }
.twin__room[data-kind="util"] { background: rgba(70, 104, 100, 0.5); }
/* 中庭是「挖空」：压暗并往下沉一点，读作没有楼板 */
.twin__room[data-kind="void"] {
  background: rgba(5, 24, 22, 0.9);
  box-shadow: inset 0 0 0 1px rgba(157, 206, 184, 0.2);
  transform: translateZ(-0.4rem);
}

.twin__room b {
  position: absolute;
  /* 按文字宽度居中，不要 left:0;right:0 撑满整间房。
     撑满时这个盒子在倾斜面上的外接矩形会溢出到隔壁房间上方，命中测试
     于是在别人的地面上取样，报出「注记被后面那间房盖住」——而且看截图
     完全正常（字是画在自家地面上的），只能靠闸发现。 */
  left: 50%;
  transform: translateX(-50%);
  /* 按「房间高度的百分比」下移，不要按板宽（--u）。
     采样判据用的是注记外接矩形的「中线」，而中线左端在平面里比字面中心
     还靠后（约 4~5 个平面单位，板宽越小这个量越大）。所以注记必须离
     房间顶边那道隔墙有足够距离，否则左端会落回墙脚线、钻进墙的投影带。
     走廊只有 16% 深，是全站最紧的一处：25% 是同时喂饱「后墙 36」和
     「前墙 52」两头的取值，别再往下调。 */
  top: 25%;
  font-family: var(--font-body);
  /* 字号跟着板宽走（0.56rem @ 16rem 板 = 0.035P），剖面放大时字跟着放大，
     模型与文字的比例才不会散 */
  font-size: calc(var(--plate) * 0.035);
  font-weight: 600;
  line-height: 1.2;
  letter-spacing: 0.01em;
  text-align: center;
  white-space: nowrap;
  color: rgba(255, 255, 255, 0.9);
  text-shadow: 0 0 3px rgba(0, 0, 0, 0.8);
}

/* 中庭的楼板下沉 0.4rem 表示「挖空」。但注记不能跟着沉：沉下去后它在
   屏幕上等价于往前挪了约 4 个平面单位，正好钻进上一层楼板/层签的投影带里
   （窄屏上必现）。注记补回同样高度，仍读在板面上。 */
.twin__room[data-kind="void"] b {
  transform: translateX(-50%) translateZ(0.4rem);
}

/* 走道名比房间名弱一档，避免和功能区分抢注意力 */
.twin__room[data-kind="hall"] b {
  font-weight: 500;
  color: rgba(231, 238, 240, 0.72);
}

/* ── 隔墙与外墙：把元素原点放到墙上，再绕上边线立起来 ──
   data-d="x" 的墙沿本地 X 走（站在常数 y 上），data-d="y" 沿本地 Y 走。
   只立远边两堵（见坑 3），高度只到「剖切高度」——墙一高就会盖掉相邻房间。 */
.twin__wall {
  position: absolute;
  transform-origin: 0 0;
  border-bottom: 1px solid rgba(255, 255, 255, 0.5);
  height: var(--wall);
}

.twin__wall[data-d="x"] {
  transform: rotateX(90deg);
  background: linear-gradient(to bottom,
              rgba(140, 175, 168, 0.3),
              rgba(206, 228, 220, 0.58));
}

.twin__wall[data-d="y"] {
  transform: rotateZ(90deg) rotateX(90deg);
  background: linear-gradient(to bottom,
              rgba(120, 152, 146, 0.26),
              rgba(176, 204, 196, 0.46));
}

/* 外墙：远端两面（y=0 后墙与 x=100 右墙），比隔墙高一档，读作楼的外壳 */
.twin__wall[data-ext] {
  height: var(--wall-ext);
  border-bottom-color: rgba(255, 255, 255, 0.62);
}

/* ── 垂直交通核：贯穿全楼的实体竖井 ──
   没有它，四层板读不出「人怎么上下楼」，也读不出楼的结构。
   立面只画近端两面（正面 + 左侧面），顶面只画最顶层那块（见坑 5）。 */
.twin__room[data-kind="core"] {
  background: rgba(150, 172, 178, 0.34);
  transform-style: preserve-3d;
}

.twin__room[data-kind="core"] i {
  position: absolute;
  display: block;
}

/* 顶面 */
.twin__core-t {
  left: 0;
  top: 0;
  width: 100%;
  height: 100%;
  transform: translateZ(var(--gap));
  background: rgba(186, 204, 208, 0.62);
}

/* 正面（y 侧，朝向观察者） */
.twin__core-f {
  left: 0;
  top: 100%;
  width: 100%;
  height: var(--gap);
  transform-origin: top left;
  transform: rotateX(90deg);
  background: linear-gradient(to bottom,
              rgba(52, 78, 80, 0.92),
              rgba(146, 172, 176, 0.86));
}

/* 左侧面（x 侧）。宽度取核的「y 向深度」，见坑 6：必须用 --uy，不能用百分比，
   也不能用 --ux——方形平面上两者相等，长方形一说就错成歪掉的平行四边形。 */
.twin__core-s {
  left: 0;
  top: 0;
  width: calc(var(--uy) * var(--h));
  height: var(--gap);
  transform-origin: top left;
  transform: rotateZ(90deg) rotateX(90deg);
  background: linear-gradient(to bottom,
              rgba(40, 62, 64, 0.92),
              rgba(118, 144, 150, 0.86));
}

/* 电梯门：正面上的两扇门，让它一眼读成电梯而不是一根柱子 */
.twin__core-f::before,
.twin__core-f::after {
  content: '';
  position: absolute;
  top: 54%;
  height: 32%;
  width: 15%;
  border: 1px solid rgba(255, 255, 255, 0.45);
  background: rgba(255, 255, 255, 0.12);
}

.twin__core-f::before { left: 28%; }
.twin__core-f::after  { left: 57%; }

/* ── 反解旋转的公共部分：总线性部分 = Rx(tilt)·Rz(yaw) · Rz(-yaw)·Rx(-tilt) = I，
   于是元素自身坐标轴与屏幕轴重合，文字是正的、清晰的。
   translateZ 必须写在反解之前——它是在楼板本地坐标系里「抬离地面」。
   顺带一个反直觉之处：抬得越高越容易被上层楼板盖住（屏幕上方正是遮挡方向），
   所以 --pin-lift 只留够压在板面上的高度，不要为了「浮起来」而调大。 */
.twin__pin,
.twin__tag {
  position: absolute;
  left: 0;
  top: 0;
  transform:
    translateZ(var(--pin-lift))
    rotateZ(calc(-1 * var(--yaw)))
    rotateX(calc(-1 * var(--tilt)));
}

/* 定位点：0×0 的点，用 left/top 百分比落在楼板平面上。
   缓步巡走（drift）加在这一层，不是加在 .twin__pin 上：
   .twin__pin 带着反解旋转（rotateZ/rotateX），在它自己的坐标系里写位移会被
   倾斜方向带跑——屏幕上看是斜着飘，而不是沿走廊走。这一层只有位移、没有旋转，
   坐标系就是楼板平面本身，于是 x 走「面宽」朝向、y 走「进深」朝向，与房间坐标
   同一套，点位移动的方向和平面图能对上。
   幅度取 3.4% 是有上限的：它不是「从这间房走到那间房」，只是原地缓步，
   让人看出「人在现场动」（1440 下实测一圈走开约 18px，略大于一个徽标的直径）。
   再大就会压到房间注记、或走出自己那间房——移动元素会不会在某个相位撞上去，
   肉眼和单帧截图都看不出来，check_twin.py 会把动画停在一圈里的 4 个相位上逐一验。 */
.twin__marker {
  position: absolute;
  width: 0;
  height: 0;
  transform-style: preserve-3d;
  --drift-x: calc(var(--ux) * 3.4);
  --drift-y: calc(var(--uy) * 3.4);
  animation: twin-drift var(--drift-dur) ease-in-out infinite;
  /* 必须写在 animation 简写之后：简写会把 play-state 重置回 running。
     只在剖面可见时跑（.twin.is-live 由 twin.js 用 IntersectionObserver 开），
     用户没看这块时不白烧电——与自动巡检同一条规矩。 */
  animation-play-state: paused;
}

.twin.is-live .twin__marker {
  animation-play-state: running;
}

/* 一圈四个航点，围成一个以徽标为中心的小椭圆（0% 与 100% 必须重合，
   否则每圈结束会跳一下）。象限极值处速度自然降下来，读起来像人走走停停，
   比匀速直来直去更像「巡」。 */
@keyframes twin-drift {
  0%   { transform: translate3d(0, calc(var(--drift-y) * -1), 0); }
  25%  { transform: translate3d(var(--drift-x), 0, 0); }
  50%  { transform: translate3d(0, var(--drift-y), 0); }
  75%  { transform: translate3d(calc(var(--drift-x) * -1), 0, 0); }
  100% { transform: translate3d(0, calc(var(--drift-y) * -1), 0); }
}

/* 六名后勤的位置。按 data-staff 而不是按第几层定位：换班次、换楼层时只需改
   这一处，且徽标号（1~6）与「任务行」的对应关系不依赖楼层顺序。
   坐标全部落在「自己那间房里、本间房的前角」，既不在全楼共用的楼前带上
   （那样 6 个点连成一条线，看不出谁在哪间房），也不压住居中的房间名。
   每个房间只放得下两个安全位置：房间左边 +5%（或右边 -6%）、y 82%。
   别往房间中间挪：房间名在 25% 高处、居中对齐，徽标落到 y 76~82 的中间位置
   时，斜投影会把两者压在一起（占位是屏幕矩形，不是平面矩形）。
   也别挪到 x ≥ 53% 且 y ≤ 6% 的后右角：上层楼板会盖掉。
   animation-delay 取负值把六个人错开：同一相位起走的话，六个人会像被同一根线
   牵着，一眼就看穿是装饰动画。 */
.twin__marker[data-staff="waste"]     { left: 11%; top: 82%; animation-delay: 0s; }
.twin__marker[data-staff="clean"]     { left: 26%; top: 82%; animation-delay: -4.2s; }
.twin__marker[data-staff="transport"] { left: 41%; top: 82%; animation-delay: -8.4s; }
.twin__marker[data-staff="disinfect"] { left: 56%; top: 82%; animation-delay: -2.1s; }
.twin__marker[data-staff="care"]      { left: 41%; top: 82%; animation-delay: -11.7s; }
.twin__marker[data-staff="engineer"]  { left: 84%; top: 82%; animation-delay: -6.3s; }

/* 数字徽标。居中用负外边距而不是 translate(-50%,-50%)：后者会落在楼板本地
   坐标系里，被倾斜后就不再是屏幕居中。 */
.twin__pin {
  /* 尺寸一律按板宽的比例（1.6rem @ 16rem 板 = 0.1P），随剖面一起放大 */
  width: calc(var(--plate) * 0.1);
  height: calc(var(--plate) * 0.1);
  margin: calc(var(--plate) * -0.05) 0 0 calc(var(--plate) * -0.05);
  border-radius: 50%;
  display: grid;
  place-items: center;
  font-family: var(--font-en);
  font-size: calc(var(--plate) * 0.0463);
  font-weight: 800;
  line-height: 1;
  color: #eaf7f3;
  background: rgba(6, 28, 26, 0.92);
  border: 1.5px solid rgba(157, 206, 184, 0.75);
  box-shadow:
    0 0 0 0.26rem rgba(31, 107, 92, 0.22),
    0 0 1rem rgba(0, 0, 0, 0.5);
  transition: transform 0.35s ease, box-shadow 0.35s ease, border-color 0.35s ease;
  cursor: pointer;
  /* preserve-3d 不是可有可无的：这一层是整条链（rig → floor → marker → pin → tip）
     里唯一的 flat 环节，而 flat 会把子元素「压扁进自己的平面」再一起参与深度排序。
     后果是提示卡上写的 translateZ（把它推向镜头、压过楼板）根本传不到 3D 排序里，
     卡片照样被下方楼层的隔墙斜切一刀——症状和你直接看到的「弹出层被挡住」一模一样，
     而且加了 preserve-3d 就好了。改这里之前先想清楚：拆掉它，卡片就会再次被切。 */
  transform-style: preserve-3d;
}

/* ── 悬浮/点按提示框：把「这个人现在在干什么」直接浮在徽标旁边 ──
   为什么非得放在 .twin__pin 内部：pin 这一层已经把反解旋转做完了，它的坐标系
   就是屏幕坐标系，所以这里写「下 / 居中」就是屏幕的下 / 居中，所见即所得。
   放到 .twin__marker 上会跟着缓步位移一起走（位置倒是对的），但拿不到反解，
   提示框会跟着楼板倾斜，字是斜的、还越往上越歪。
   内容由 twin.js 从右侧任务行读出来填进去，这里只负责长相与显隐。
   尺寸用 rem 而不是按 --plate 折算：它是给人读的界面文字，不是模型的一部分，
   跟着剖面缩到 390px 时就没法读了。 */
.twin__tip {
  position: absolute;
  /* 锚在徽标「下方」：房间注记在房间中心（屏幕上方偏左），徽标在房间前角，
     两者在屏幕上几乎同一竖线。提示框往上放必然压住注记——那正是用户想看的
     房间名。往下放只压到上一层的板边/走廊，且提示框比楼板离镜头更近，
     会盖在上面而不是被切掉。 */
  left: 50%;
  top: calc(100% + 0.55rem);
  /* translateZ 是这张卡片的关键一笔，不是装饰：
     卡片是一块与屏幕平行的「告示板」，而它身下的楼层楼板是一块斜的平面。两个平面
     在 3D 里相交，相交线投影到屏幕上就是一条斜边——卡片于是被斜着切掉一角，
     上半张压在楼板/隔墙后面（用户报的「弹出的图层被挡住了」就是这个）。
     解法只能是把卡片往镜头方向推：.twin__pin 的总旋转是单位矩阵（见 CSS 顶部坑 1），
     所以 pin 的局部 z 轴就是屏幕法线——在这里写 translateZ 是「沿视线平移」，
     正交投影下屏幕位置一动不动，只改深度。因此给多宽裕都行，不存在「推歪了」。
     实测：不推时卡片深度小于下方楼层近端角点的深度，故被切；推 1.2P 后位于
     所有楼板、隔墙、交通核之前，闸在 7 个宽度上逐个采样验证。 */
  transform: translateZ(var(--tip-lift)) translateX(-50%);
  z-index: 3;
  width: max-content;
  /* 上限同时受视口约束：390 宽时 15rem 就是 240px，边缘那枚徽标的提示框会
     顶出 .twin 的裁剪框（给 overflow 切掉一半，不报错）。 */
  max-width: min(15rem, 58vw);
  display: block;
  padding: 0.5rem 0.65rem;
  border-radius: 0.5rem;
  border: 1px solid rgba(157, 206, 184, 0.45);
  /* 不透明，不是 0.96：原先那 4% 的透明度会让底下的房间名透出来，卡片上浮着
     「洁净走廊」「影像中心」一层鬼影，看着像没画干净（截图放大就能看见）。
     顺带也让闸的像素比对有确定的期望值——卡片盖住哪儿，那儿就该是什么颜色。 */
  background: rgb(5, 26, 24);
  box-shadow: 0 0.5rem 1.4rem rgba(0, 0, 0, 0.5);
  color: var(--mist);
    font-family: var(--font-body);
  font-size: 0.72rem;
  font-weight: 400;
  line-height: 1.5;
  text-align: left;
  white-space: normal;
  /* 必须穿透：提示框盖在别的徽标上时，命中测试仍要能点到下面那些徽标，
     否则「悬停切层」在卡片压住邻座徽标的那一小块上会失灵。 */
  pointer-events: none;
  opacity: 0;
  visibility: hidden;
  transition: opacity 0.22s ease, visibility 0.22s ease;
}

.twin__tip strong {
  display: block;
  font-size: 0.76rem;
  font-weight: 700;
  color: #f2f9f7;
}

.twin__tip span {
  display: block;
  color: rgba(231, 238, 240, 0.78);
}

.twin__tip em {
  display: inline-block;
  margin-top: 0.3rem;
  padding: 0.05rem 0.35rem;
  border-radius: 999px;
  border: 1px solid rgba(157, 206, 184, 0.4);
  background: rgba(31, 107, 92, 0.34);
  font-style: normal;
  font-size: 0.66rem;
  color: #dff2ec;
}

.twin__marker[data-tone="warn"] .twin__tip em {
  border-color: rgba(201, 120, 60, 0.5);
  background: rgba(201, 120, 60, 0.26);
  color: #ffe6d2;
}

/* 悬停即看，点一下钉住（触屏没有 hover，只能靠点）。
   .is-open 由 twin.js 在点击徽标或右侧任务行时加上。 */
.twin__pin:hover .twin__tip,
.twin__marker.is-open .twin__tip {
  opacity: 1;
  visibility: visible;
}

/* 临期/预警人员用暖色，让「该处理的事」在深绿底上跳出来 */
.twin__marker[data-tone="warn"] .twin__pin {
  border-color: rgba(201, 120, 60, 0.95);
  color: #ffe6d2;
  box-shadow:
    0 0 0 0.26rem rgba(201, 120, 60, 0.2),
    0 0 1rem rgba(0, 0, 0, 0.5);
}

/* 激活态由 twin.js 加在 .twin__marker 上（而不是 .twin__pin），
   这里必须跟着 marker 写：两边曾经对不上，高亮一直没生效，
   而截图里那个「亮着的」徽标其实是 data-tone="warn" 的橙色默认样式，
   看图和肉眼都会把它误判成激活态——所以这条要用脚本断言，别靠看。 */
.twin__marker.is-active .twin__pin {
  border-color: #ffffff;
  box-shadow:
    0 0 0 0.36rem rgba(157, 206, 184, 0.32),
    0 0 1.4rem rgba(157, 206, 184, 0.55);
}

.twin__marker[data-tone="warn"].is-active .twin__pin {
  border-color: #ffd9b8;
  box-shadow:
    0 0 0 0.36rem rgba(201, 120, 60, 0.32),
    0 0 1.4rem rgba(201, 120, 60, 0.6);
}

/* 层签：挂在本层楼板的后左角（屏幕上最左端的那条露出带）上，写清「这层是什么」。
   位置必须是后左角，不能贴着左边缘往下挪：本层所有房间注记都在层签右侧，
   两者只差 6~20px，层签一宽、一往右，就压到「中庭/车库」的注记上。
   挂在角上时层签只盖住角外那块空地，四条在屏幕上自然排成一列。 */
.twin__tag {
  /* 宽度固定 + 文字居中，是为了让四层签在剖面上排成整齐的一列；文字长度要
     控制在这个宽度内（nowrap 会溢出）。宽度被房间注记的「可用带」卡死：
     层签挂在本层楼板的后左角上（屏幕最左端），本层再往右就是房间注记的地盘，
     5.4rem 时右端正好压到「中庭/车库」那几间后左房的注记上（它比层签低 6px，
     于是差分只差几像素，窄屏必现）。4.6rem 是能容下最长一条层签文本、又不
     越进注记带的最大值，别再加宽，也别把 left 往右挪。 */
  /* 位置：层签挂在本层楼板的后左角上（HTML 写 left:2%;top:2%），再由这里的
     margin-left 把它整条平移到角外——角内（2%~20%）正好是本层后左那几间房的
     注记，平移量不够就会压上去，平移太多则会被 .twin 的 overflow 裁掉（裁掉
     不报错、只是看不见，正是要靠闸发现的那类坏法）。0.21875P 是宽屏下两者之间
     的取值；窄屏剖面几乎贴到视口边，可用余量不足，见 ≤640 那一段。 */
  width: calc(var(--plate) * 0.2875);
  margin: calc(var(--plate) * -0.04375) 0 0 calc(var(--plate) * -0.21875);
  padding: calc(var(--plate) * 0.0125) calc(var(--plate) * 0.028);
  border-radius: 999px;
  font-family: var(--font-body);
  font-size: calc(var(--plate) * 0.03875);
  letter-spacing: 0.02em;
  line-height: 1.35;
  text-align: center;
  white-space: nowrap;
  color: rgba(231, 238, 240, 0.95);
  background: rgba(6, 28, 26, 0.84);
  border: 1px solid rgba(157, 206, 184, 0.32);
}

/* 层签里的「功能说明」前半段是楼层号（B1/L1/…），单独包一层，窄屏只留楼层号 */
.twin__tag i {
  font-style: normal;
}

/* ── 右侧驾驶舱 ── */
.twin__hud {
  position: relative;
  min-width: 0;
}

.twin__metrics {
  display: grid;
  grid-template-columns: repeat(3, minmax(0, 1fr));
  gap: 0.75rem;
  margin: 0;
  padding: 0;
  list-style: none;
}

.twin__metric {
  padding: 0.6rem 0.7rem;
  border: 1px solid rgba(157, 206, 184, 0.22);
  border-radius: 0.5rem;
  background: rgba(6, 28, 26, 0.45);
}

.twin__metric b {
  display: block;
  font-family: var(--font-en);
  font-size: 1.32rem;
  font-weight: 800;
  line-height: 1.1;
  color: #dff3ec;
}

.twin__metric[data-tone="warn"] b {
  color: #ffb87f;
}

.twin__metric span {
  display: block;
  margin-top: 0.15rem;
  font-size: 0.7rem;
  color: rgba(231, 238, 240, 0.6);
}

.twin__list {
  list-style: none;
  margin: 0.85rem 0 0;
  padding: 0;
  display: grid;
  gap: 0.34rem;
}

.twin__row {
  display: grid;
  grid-template-columns: 1.55rem minmax(0, 1fr) auto;
  align-items: center;
  gap: 0.6rem;
  padding: 0.48rem 0.62rem;
  border: 1px solid transparent;
  border-radius: 0.5rem;
  background: rgba(6, 28, 26, 0.34);
  transition: background 0.3s ease, border-color 0.3s ease;
}

.twin__row.is-active {
  background: rgba(31, 107, 92, 0.34);
  border-color: rgba(157, 206, 184, 0.42);
}

.twin__row-no {
  display: grid;
  place-items: center;
  width: 1.55rem;
  height: 1.55rem;
  border-radius: 50%;
  font-family: var(--font-en);
  font-size: 0.7rem;
  font-weight: 800;
  color: #dff3ec;
  border: 1px solid rgba(157, 206, 184, 0.5);
}

.twin__row[data-tone="warn"] .twin__row-no {
  border-color: rgba(201, 120, 60, 0.85);
  color: #ffd9b8;
}

.twin__row-main {
  min-width: 0;
}

.twin__row-main strong {
  display: block;
  font-size: 0.84rem;
  font-weight: 700;
  color: #eaf7f3;
}

.twin__row-main span {
  display: block;
  margin-top: 0.1rem;
  font-size: 0.76rem;
  line-height: 1.45;
  color: rgba(231, 238, 240, 0.7);
}

.twin__chip {
  padding: 0.14rem 0.45rem;
  border-radius: 999px;
  font-size: 0.66rem;
  white-space: nowrap;
  border: 1px solid rgba(157, 206, 184, 0.4);
  color: #cfe9e0;
}

.twin__row[data-tone="warn"] .twin__chip {
  border-color: rgba(201, 120, 60, 0.75);
  color: #ffcb9d;
}

.twin__row[data-state="idle"] .twin__chip {
  border-color: rgba(231, 238, 240, 0.28);
  color: rgba(231, 238, 240, 0.66);
}

.twin__foot {
  margin: 1rem 0 0;
  font-size: 0.78rem;
  line-height: 1.65;
  color: rgba(231, 238, 240, 0.62);
}

.twin__actions {
  display: flex;
  flex-wrap: wrap;
  gap: 0.6rem;
  margin-top: 1.1rem;
}

/* 深色面板里的按钮要用浅色描边版本，否则 site 上的深绿描边看不见 */
.twin .btn-ghost {
  border-color: rgba(157, 206, 184, 0.5);
  color: #dff3ec;
}

.twin .btn-ghost:hover {
  border-color: rgba(157, 206, 184, 0.9);
  background: rgba(157, 206, 184, 0.12);
}

/* ── 窄屏：剖面上下排到驾驶舱上方 ── */
@media (max-width: 1000px) {
  .twin {
    /* 单列后剖面独占整行，比两列时更宽，可以再放大一档（上限仍受场景高度约束） */
    --plate: clamp(15rem, 30vw, 22rem);
  }

  .twin__body {
    grid-template-columns: minmax(0, 1fr);
  }
}

@media (max-width: 640px) {
  .twin {
    /* 手机上单列，剖面独占一行，同样按可用宽度流体放大（11rem 是 390 宽下
       不切到院区地坪边缘的下限，40vw 到 640 宽时约 16rem）。 */
    --plate: clamp(11rem, 40vw, 17rem);
    /* 手机段曾把徽标/层签/注记写死成绝对 rem（那时 --plate 固定 11rem）。
       现在 --plate 是流体，这些值必须改回「对 11rem 时的等效比例」，否则剖面
       长大而文字不长，比例一散遮挡判据就失效。 */
    --pin-lift: calc(var(--plate) * 0.0318);
  }

  .twin__pin {
    width: calc(var(--plate) * 0.1273);
    height: calc(var(--plate) * 0.1273);
    margin: calc(var(--plate) * -0.0636) 0 0 calc(var(--plate) * -0.0636);
    font-size: calc(var(--plate) * 0.06);
  }

  .twin__room b {
    font-size: calc(var(--plate) * 0.0418);
  }

  .twin__tag {
    /* 窄屏可用余量不足：390 宽时剖面左右只剩约 24px，而「B1 医废暂存」这种整条
       层签要 80px 以上。硬塞的结果就是被 .twin 的 overflow 裁掉、或者反过来
       压住本层后左那间房的注记——两种都不报错，只是图坏了。这里改成只留楼层号
       （B1/L1/…），宽度压到 0.15P，让整条落在角外的余量里。 */
    width: calc(var(--plate) * 0.15);
    padding: calc(var(--plate) * 0.014) calc(var(--plate) * 0.022);
    margin: calc(var(--plate) * -0.04) 0 0 calc(var(--plate) * -0.145);
    font-size: calc(var(--plate) * 0.05);
  }
  .twin__tag i {
    display: none;
  }

  /* 最靠右那枚徽标（设备层 x=84%）的卡片要翻边：窄屏下 58vw ≈ 226px，
     居中挂在徽标下方时右半边会顶出 .twin 的裁剪框（390 宽实测越界 16px），
     被 overflow 切掉一截且不报错。改成右边缘对齐徽标、整张往左长——
     弹出层空间不够就翻边是标准做法，比一味缩窄卡片好（缩到能塞进右边那条缝的
     宽度，字就没法读了）。左侧那枚不用管：同样的宽度压在它左边还有富余。 */
  .twin__marker[data-staff="engineer"] .twin__tip {
    left: 100%;
    transform: translateZ(var(--tip-lift)) translateX(-100%);
  }

  .twin__metric b {
    font-size: 1.15rem;
  }
}

  .twin__metrics {
    grid-template-columns: repeat(3, minmax(0, 1fr));
    gap: 0.5rem;
  }

  .twin__metric b {
    font-size: 1.2rem;
  }
}

/* 用户不想看动效时：不自动巡检、不缓步巡走，也不做过渡，但信息一个不少 */
@media (prefers-reduced-motion: reduce) {
  .twin__rig,
  .twin__pin,
  .twin__floor::after,
  .twin__row {
    transition: none;
  }

  /* 缓步巡走是一种持续运动，正是这个偏好要挡掉的东西。提示框照常可用
     （悬停/点击都能看），任务信息在右侧也一行不少。 */
  .twin__marker {
    animation: none;
  }

  .twin__tip {
    transition: none;
  }
}
