<template>
  <div class="wh3d-wrapper" :class="'wh3d-' + themeMode" :style="cssVars">
    <div class="wh3d-header">
      <h1>钢卷库 3D 可视化系统</h1>
      <span class="tag">地面存放 · 最大叠放 2 层 · 无货架</span>
      <span class="fps">FPS: <b>{{ fps }}</b></span>
    </div>
    <div class="wh3d-main">
      <div class="wh3d-view" ref="viewEl">
        <div class="wh3d-vbt">
          <button v-for="v in viewBtns" :key="v.k" :class="{ act: viewMode === v.k }" @click="setView(v.k)">{{ v.l }}</button>
          <button class="theme-toggle" @click="toggleTheme" :title="'切换为' + (themeMode === 'dark' ? '浅色' : '深色') + '主题'">
            {{ themeMode === 'dark' ? '浅色' : '深色' }}
          </button>
        </div>

        <div class="wh3d-help">
          <div class="hh">操作说明</div>
          <div class="hr"><kbd>左键</kbd> 旋转视角</div>
          <div class="hr"><kbd>右键</kbd> 平移视角</div>
          <div class="hr"><kbd>滚轮</kbd> 缩放</div>
          <div class="hr"><kbd>点击</kbd> 查看钢卷详情</div>
          <div class="hr"><kbd>R</kbd> 复位视角 · <kbd>Esc</kbd> 关闭</div>
        </div>

        <div class="wh3d-axis">
          <span>X 轴 · 列 (C)</span>
          <span class="vt">Z 轴 · 排 (R)</span>
        </div>

        <div class="wh3d-dlg" v-show="detail">
          <button class="x" @click="closeDetail">×</button>
          <h3>钢卷 {{ detail && detail.id }}</h3>
          <div class="rw"><span class="k">库位</span><span class="v">{{ detail && detail.posKey }} · 第 {{ detail && detail.layer }} 层</span></div>
          <div class="rw"><span class="k">钢卷号</span><span class="v">{{ detail && detail.id }}</span></div>
          <div class="rw"><span class="k">钢种</span><span class="v">{{ detail && detail.grade }}</span></div>
          <div class="rw"><span class="k">宽度×外径</span><span class="v">{{ detail && (detail.width + ' × Ø' + detail.od) }}</span></div>
          <div class="rw"><span class="k">重量(t)</span><span class="v">{{ detail && detail.weight }}</span></div>
          <div class="rw"><span class="k">扫码状态</span><span class="v">{{ detail && scanText(detail.scan) }}</span></div>
        </div>
      </div>

      <div class="wh3d-info">
        <h2>库区统计</h2>
        <div class="ss">
          <div class="sg">
            <div class="sc"><div class="l">总库位</div><div class="v">{{ stats.total }}</div></div>
            <div class="sc"><div class="l">已占用</div><div class="v">{{ stats.used }}</div></div>
            <div class="sc"><div class="l">空闲位</div><div class="v">{{ stats.free }}</div></div>
            <div class="sc"><div class="l">占用率</div><div class="v primary">{{ stats.pct }}</div></div>
            <div class="sc"><div class="l">钢卷总数</div><div class="v">{{ stats.coils }}</div></div>
            <div class="sc"><div class="l">双层叠放</div><div class="v">{{ stats.doubled }}</div></div>
          </div>
        </div>
        <h2>钢卷明细</h2>
        <div class="ctb">
          <table>
            <thead><tr><th>库位</th><th>钢卷号</th><th>规格</th><th>状态</th></tr></thead>
            <tbody>
              <tr v-for="c in tableRows" :key="c.id"
                  :class="{ sel: detail && detail.id === c.id }"
                  @click="showCoilDetail(c.id)">
                <td>{{ c.posKey }} L{{ c.layer }}</td>
                <td>{{ c.id }}</td>
                <td>{{ c.width }}×Ø{{ c.od }}</td>
                <td :class="scanClass(c.scan)">{{ scanShort(c.scan) }}</td>
              </tr>
            </tbody>
          </table>
        </div>
      </div>
    </div>
  </div>
</template>

<script>
import * as THREE from 'three';

const GRADE_LIST = ['SPHC', 'SPHD', 'Q195', 'Q235', 'DC01', 'DC03', 'SUS304'];
const CELL_W = 3.2, CELL_D = 3.2, GAP = 0.5;
const COIL_OD = 1.6, COIL_W = 1.1;

function pad2(n) { return n < 10 ? '0' + n : '' + n; }

function parseCode(code) {
  if (!code) return null;
  const reg = /^([A-Za-z0-9]{3})([^-]+)-X?(\d{2})-(\d+)$/;
  const m = code.match(reg);
  if (!m) return null;
  return { column: Number(m[2]), row: Number(m[3]), layer: Number(m[4]) };
}

function hexToInt(hex) {
  if (!hex) return 0x409eff;
  return parseInt(hex.replace('#', ''), 16);
}

export default {
  name: 'Warehouse3D',
  props: {
    warehouseList: { type: Array, default: () => [] },
  },
  data() {
    return {
      fps: '--',
      viewMode: 'iso',
      detail: null,
      stats: { total: 0, used: 0, free: 0, pct: '0%', coils: 0, doubled: 0 },
      coilList: [],
      cols: 14,
      rows: 6,
      themeMode: localStorage.getItem('wh3d-theme') || 'dark',
      viewBtns: [
        { k: 'iso', l: '等轴' },
        { k: 'top', l: '俯视' },
        { k: 'front', l: '正视' },
        { k: 'side', l: '侧视' },
      ],
    };
  },
  computed: {
    themeColor() {
      return (this.$store && this.$store.state.settings && this.$store.state.settings.theme) || '#409EFF';
    },
    palette() {
      // 整套色板根据 themeMode 切换；3D 场景与 DOM 都用同一份
      if (this.themeMode === 'light') {
        return {
          sceneBg: 0xf5f7fa,
          ground: 0xe4eaf2,
          gridMajor: 0x9aa5b1,
          gridMinor: 0xdcdfe6,
          slot: 0xeef2f7,
          slotOpacity: 0.55,
          coilBody: 0x303133,
          coilEnd: 0x9aa5b1,
          coilHole: 0x1a1a1a,
          ambient: 0.95, dirMain: 1.0, dirFill: 0.4, hemiSky: 0xffffff, hemiGround: 0xcccccc,
          highlightEmissive: 0xf39c12,
          highlightColor: hexToInt(this.themeColor),
        };
      }
      return {
        sceneBg: 0x080c14,
        ground: 0x111a26,
        gridMajor: 0x1e3a5f,
        gridMinor: 0x0f1923,
        slot: 0x1a3a1a,
        slotOpacity: 0.55,
        coilBody: 0x1a1a1a,
        coilEnd: 0x9aa5b1,
        coilHole: 0x000000,
        ambient: 0.6, dirMain: 1.1, dirFill: 0.35, hemiSky: 0x445566, hemiGround: 0x111820,
        highlightEmissive: 0xf39c12,
        highlightColor: hexToInt(this.themeColor),
      };
    },
    cssVars() {
      return { '--wh3d-primary': this.themeColor };
    },
    tableRows() {
      return this.coilList.slice().sort((a, b) => {
        if (a.posKey !== b.posKey) return a.posKey.localeCompare(b.posKey);
        return a.layer - b.layer;
      });
    },
  },
  watch: {
    warehouseList() { if (this._ready) this.rebuild(); },
    themeColor() { if (this._ready) this.rebuild(); },
    themeMode() {
      if (!this._ready) return;
      this.scene.background = new THREE.Color(this.palette.sceneBg);
      this.rebuild();
    },
  },
  mounted() {
    this.$nextTick(() => {
      this.initScene();
      this._ready = true;
    });
  },
  beforeDestroy() {
    this._destroyed = true;
    if (this._raf) cancelAnimationFrame(this._raf);
    window.removeEventListener('resize', this._onResize);
    document.removeEventListener('keydown', this._onKey);
    if (this.renderer) {
      this.renderer.dispose && this.renderer.dispose();
      if (this.renderer.domElement && this.renderer.domElement.parentNode) {
        this.renderer.domElement.parentNode.removeChild(this.renderer.domElement);
      }
    }
  },
  methods: {
    scanText(s) { return s === 'OK' ? '正常' : (s === 'PENDING' ? '待扫码' : '异常'); },
    scanShort(s) { return s === 'OK' ? '正常' : (s === 'PENDING' ? '待扫' : '异常'); },
    scanClass(s) { return s === 'OK' ? 'ok' : (s === 'PENDING' ? 'pn' : 'ng'); },

    deriveGrid() {
      let maxC = 0, maxR = 0;
      (this.warehouseList || []).forEach((w) => {
        const info = parseCode(w.actualWarehouseCode);
        if (info) {
          if (info.column > maxC) maxC = info.column;
          if (info.row > maxR) maxR = info.row;
        }
      });
      this.cols = Math.max(maxC, 8);
      this.rows = Math.max(maxR, 4);
    },

    initScene() {
      this.deriveGrid();
      this.scene = new THREE.Scene();
      this.scene.background = new THREE.Color(this.palette.sceneBg);

      const el = this.$refs.viewEl;
      const w = Math.max(el.clientWidth - 310, 100);
      const h = Math.max(el.clientHeight, 100);

      this.camera = new THREE.PerspectiveCamera(50, w / h, 0.1, 200);
      this.camTarget = { x: 0, y: 1.5, z: 0 };
      this.camR = 36; this.camTheta = 0.6; this.camPhi = 0.55;
      this.updateCamera();

      this.renderer = new THREE.WebGLRenderer({ antialias: true });
      this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
      this.renderer.shadowMap.enabled = true;
      this.renderer.shadowMap.type = THREE.PCFSoftShadowMap;
      this.renderer.setSize(w, h);
      el.appendChild(this.renderer.domElement);

      const P = this.palette;
      this.scene.add(new THREE.AmbientLight(0xffffff, P.ambient));
      const dl = new THREE.DirectionalLight(0xffffff, P.dirMain);
      dl.position.set(20, 30, 18);
      dl.castShadow = true;
      dl.shadow.mapSize.set(1024, 1024);
      this.scene.add(dl);
      const fill = new THREE.DirectionalLight(0xa0c0ff, P.dirFill);
      fill.position.set(-15, 20, -10);
      this.scene.add(fill);
      this.scene.add(new THREE.HemisphereLight(P.hemiSky, P.hemiGround, 0.5));

      this.clock = new THREE.Clock();
      this.raycaster = new THREE.Raycaster();
      this.mouseVec = new THREE.Vector2();

      this.buildGround();
      this.buildAllCoils();
      this.updateStats();

      const dom = this.renderer.domElement;
      dom.addEventListener('click', this.onCanvasClick);
      dom.addEventListener('mousedown', this.onMouseDown);
      dom.addEventListener('mousemove', this.onMouseMove);
      dom.addEventListener('mouseup', this.onMouseUp);
      dom.addEventListener('wheel', this.onMouseWheel, { passive: false });
      dom.addEventListener('contextmenu', (e) => e.preventDefault());
      this._onResize = () => this.resizeRenderer();
      this._onKey = (e) => {
        if (e.key === 'r' || e.key === 'R') this.setView('iso');
        if (e.key === 'Escape') this.closeDetail();
      };
      window.addEventListener('resize', this._onResize);
      document.addEventListener('keydown', this._onKey);

      this.fpsCount = 0; this.fpsTime = 0;
      this.animate();
    },

    clearScene() {
      const dispose = (obj) => {
        if (obj.geometry) obj.geometry.dispose();
        if (obj.material) {
          if (Array.isArray(obj.material)) obj.material.forEach((m) => m.dispose());
          else obj.material.dispose();
        }
      };
      const children = this.scene.children.slice();
      children.forEach((o) => {
        if (o.isLight) return;
        o.traverse && o.traverse(dispose);
        this.scene.remove(o);
      });
    },

    rebuild() {
      if (!this.scene) return;
      this.detail = null;
      this.clearScene();
      // 重新加灯（clearScene 保留灯，但主题变化时需要刷新强度，所以也清掉并重建）
      const lights = this.scene.children.filter((o) => o.isLight);
      lights.forEach((l) => this.scene.remove(l));
      const P = this.palette;
      this.scene.background = new THREE.Color(P.sceneBg);
      this.scene.add(new THREE.AmbientLight(0xffffff, P.ambient));
      const dl = new THREE.DirectionalLight(0xffffff, P.dirMain);
      dl.position.set(20, 30, 18);
      dl.castShadow = true;
      this.scene.add(dl);
      const fill = new THREE.DirectionalLight(0xa0c0ff, P.dirFill);
      fill.position.set(-15, 20, -10);
      this.scene.add(fill);
      this.scene.add(new THREE.HemisphereLight(P.hemiSky, P.hemiGround, 0.5));
      this.buildGround();
      this.buildAllCoils();
      this.updateStats();
    },

    toggleTheme() {
      this.themeMode = this.themeMode === 'dark' ? 'light' : 'dark';
      localStorage.setItem('wh3d-theme', this.themeMode);
    },

    resizeRenderer() {
      if (!this.renderer) return;
      const el = this.$refs.viewEl;
      const w = Math.max(el.clientWidth - 310, 100);
      const h = Math.max(el.clientHeight, 100);
      this.renderer.setSize(w, h);
      this.camera.aspect = w / h;
      this.camera.updateProjectionMatrix();
    },

    updateCamera() {
      const c = this.camera;
      c.position.x = this.camTarget.x + this.camR * Math.sin(this.camPhi) * Math.cos(this.camTheta);
      c.position.y = this.camTarget.y + this.camR * Math.cos(this.camPhi);
      c.position.z = this.camTarget.z + this.camR * Math.sin(this.camPhi) * Math.sin(this.camTheta);
      c.lookAt(this.camTarget.x, this.camTarget.y, this.camTarget.z);
    },

    createTextSprite(text, size, color) {
      const canvas = document.createElement('canvas');
      const px = Math.floor(size * 256);
      canvas.width = px * 3;
      canvas.height = px;
      const ctx = canvas.getContext('2d');
      ctx.fillStyle = color || '#5dade2';
      ctx.font = 'bold ' + Math.floor(size * 64) + 'px sans-serif';
      ctx.textAlign = 'center';
      ctx.textBaseline = 'middle';
      ctx.fillText(text, canvas.width / 2, canvas.height / 2);
      const tex = new THREE.CanvasTexture(canvas);
      tex.minFilter = THREE.LinearFilter;
      const mat = new THREE.SpriteMaterial({ map: tex, transparent: true, depthTest: false });
      const sp = new THREE.Sprite(mat);
      sp.renderOrder = 10;
      return sp;
    },

    buildGround() {
      const tw = this.cols * CELL_W + GAP * (this.cols - 1);
      const td = this.rows * CELL_D + GAP * (this.rows - 1);

      const P = this.palette;
      const gnd = new THREE.Mesh(
        new THREE.PlaneGeometry(tw + 8, td + 8),
        new THREE.MeshStandardMaterial({ color: P.ground, roughness: 0.95 })
      );
      gnd.rotation.x = -Math.PI / 2;
      gnd.receiveShadow = true;
      this.scene.add(gnd);

      const grid = new THREE.GridHelper(
        Math.max(tw, td) + 6,
        Math.max(this.cols, this.rows) * 2,
        P.gridMajor,
        P.gridMinor
      );
      grid.position.y = 0.01;
      this.scene.add(grid);

      for (let c = 0; c < this.cols; c++) {
        for (let r = 0; r < this.rows; r++) {
          const cx = -tw / 2 + c * (CELL_W + GAP) + CELL_W / 2;
          const cz = -td / 2 + r * (CELL_D + GAP) + CELL_D / 2;
          const key = pad2(c + 1) + pad2(r + 1);
          const b = new THREE.Mesh(
            new THREE.PlaneGeometry(CELL_W - 0.1, CELL_D - 0.1),
            new THREE.MeshStandardMaterial({ color: P.slot, roughness: 0.8, transparent: true, opacity: 0.4 })
          );
          b.rotation.x = -Math.PI / 2;
          b.position.set(cx, 0.02, cz);
          b.userData = { isSlot: true, key };
          this.scene.add(b);
        }
      }

      const labelColor = this.themeMode === 'light' ? '#3A71A8' : '#5dade2';
      const subColor = this.themeMode === 'light' ? '#909399' : '#7f8c8d';
      for (let c = 0; c < this.cols; c++) {
        const cx = -tw / 2 + c * (CELL_W + GAP) + CELL_W / 2;
        const sp = this.createTextSprite('C' + pad2(c + 1), 0.5, labelColor);
        sp.position.set(cx, 0.1, -td / 2 - 2.2);
        sp.scale.set(2.6, 0.9, 1);
        this.scene.add(sp);
      }
      for (let r = 0; r < this.rows; r++) {
        const cz = -td / 2 + r * (CELL_D + GAP) + CELL_D / 2;
        const sp = this.createTextSprite('R' + (r + 1), 0.5, labelColor);
        sp.position.set(-tw / 2 - 2.5, 0.1, cz);
        sp.scale.set(2.2, 0.9, 1);
        this.scene.add(sp);
      }
      const origin = this.createTextSprite('原点 (0,0)', 0.42, subColor);
      origin.position.set(-tw / 2 - 2.5, 0.1, -td / 2 - 2.2);
      origin.scale.set(3, 0.8, 1);
      this.scene.add(origin);
    },

    buildAllCoils() {
      this.coilList = [];
      const tw = this.cols * CELL_W + GAP * (this.cols - 1);
      const td = this.rows * CELL_D + GAP * (this.rows - 1);

      const occupiedMap = {};
      (this.warehouseList || []).forEach((w) => {
        const info = parseCode(w.actualWarehouseCode);
        if (!info) return;
        if (w.isEnabled !== 0) return;
        const c = info.column - 1, r = info.row - 1, l = info.layer;
        if (c < 0 || c >= this.cols || r < 0 || r >= this.rows) return;
        const key = pad2(c + 1) + pad2(r + 1);
        if (!occupiedMap[key]) occupiedMap[key] = {};
        occupiedMap[key][l] = w;
      });

      if (Object.keys(occupiedMap).length === 0) {
        const total = this.cols * this.rows;
        const nUsed = Math.floor(total * 0.65);
        const indices = [];
        for (let i = 0; i < total; i++) indices.push(i);
        for (let i = total - 1; i > 0; i--) {
          const j = Math.floor(Math.random() * (i + 1));
          [indices[i], indices[j]] = [indices[j], indices[i]];
        }
        for (let i = 0; i < nUsed; i++) {
          const idx = indices[i];
          const c = Math.floor(idx / this.rows), r = idx % this.rows;
          const key = pad2(c + 1) + pad2(r + 1);
          occupiedMap[key] = { 1: {} };
          if (Math.random() < 0.3) occupiedMap[key][2] = {};
        }
      }

      let posSeed = 0;
      for (let c = 0; c < this.cols; c++) {
        for (let r = 0; r < this.rows; r++) {
          const key = pad2(c + 1) + pad2(r + 1);
          if (!occupiedMap[key]) continue;
          const cx = -tw / 2 + c * (CELL_W + GAP) + CELL_W / 2;
          const cz = -td / 2 + r * (CELL_D + GAP) + CELL_D / 2;

          [1, 2].forEach((layer) => {
            if (!occupiedMap[key][layer]) return;
            const w = occupiedMap[key][layer];
            posSeed++;
            // 仅极少数标记为非正常状态，避免整体闪烁
            let scan = 'OK';
            const rnd = (posSeed * 9301 + 49297) % 233280 / 233280;
            if (rnd < 0.04) scan = 'NG';
            else if (rnd < 0.12) scan = 'PENDING';

            const grade = GRADE_LIST[(posSeed) % GRADE_LIST.length];
            const width = 800 + ((posSeed * 37) % 550);
            const od = 1100 + ((posSeed * 53) % 900);
            const weight = (4 + ((posSeed * 7) % 220) / 10).toFixed(1);

            const g = this.createCoilGroup();
            const y = layer === 1 ? COIL_OD / 2 : (COIL_OD / 2 + COIL_OD + 0.08);
            g.position.set(cx, y, cz);
            const cid = 'C' + key + pad2(layer);
            g.userData = { coilId: cid };
            this.scene.add(g);
            this.coilList.push({
              id: cid, posKey: key, grade, width, od, weight, scan, mesh: g, layer,
              warehouseId: w.actualWarehouseId, warehouseCode: w.actualWarehouseCode,
            });
          });
          this.updateSlotColor(key);
        }
      }
    },

    updateSlotColor(key) {
      const P = this.palette;
      this.scene.traverse((obj) => {
        if (obj.userData && obj.userData.isSlot && obj.userData.key === key) {
          obj.material.color.setHex(P.slot);
          obj.material.opacity = P.slotOpacity;
        }
      });
    },

    createCoilGroup() {
      const P = this.palette;
      const g = new THREE.Group();
      const cyl = new THREE.Mesh(
        new THREE.CylinderGeometry(COIL_OD / 2, COIL_OD / 2, COIL_W, 28),
        new THREE.MeshStandardMaterial({
          color: P.coilBody, roughness: 0.55, metalness: 0.85,
          emissive: 0x000000, emissiveIntensity: 1,
        })
      );
      cyl.rotation.x = Math.PI / 2;
      cyl.castShadow = true;
      cyl.userData.isCoilBody = true;
      g.add(cyl);
      const rGeo = new THREE.TorusGeometry(COIL_OD / 2, 0.06, 8, 24);
      const rMat = new THREE.MeshStandardMaterial({ color: P.coilEnd, roughness: 0.3, metalness: 0.95 });
      const rn1 = new THREE.Mesh(rGeo, rMat); rn1.position.z = COIL_W / 2 - 0.03; g.add(rn1);
      const rn2 = new THREE.Mesh(rGeo, rMat); rn2.position.z = -(COIL_W / 2 - 0.03); g.add(rn2);
      const hole = new THREE.Mesh(
        new THREE.CylinderGeometry(0.22, 0.22, COIL_W + 0.1, 12),
        new THREE.MeshStandardMaterial({ color: P.coilHole })
      );
      hole.rotation.x = Math.PI / 2;
      g.add(hole);
      return g;
    },

    onCanvasClick(e) {
      if (this._didDrag) { this._didDrag = false; return; }
      const rect = this.renderer.domElement.getBoundingClientRect();
      this.mouseVec.x = ((e.clientX - rect.left) / rect.width) * 2 - 1;
      this.mouseVec.y = -((e.clientY - rect.top) / rect.height) * 2 + 1;
      this.raycaster.setFromCamera(this.mouseVec, this.camera);
      const targets = [];
      this.scene.traverse((o) => { if (o.userData && o.userData.coilId) targets.push(o); });
      const hits = this.raycaster.intersectObjects(targets, true);
      if (hits.length > 0) {
        let obj = hits[0].object;
        while (obj.parent && !(obj.userData && obj.userData.coilId)) obj = obj.parent;
        if (obj.userData && obj.userData.coilId) {
          this.showCoilDetail(obj.userData.coilId);
          return;
        }
      }
      this.closeDetail();
    },

    showCoilDetail(cid) {
      const coil = this.coilList.find((c) => c.id === cid);
      if (!coil) return;
      this.detail = coil;
      this.applyHighlight();
    },

    closeDetail() {
      this.detail = null;
      this.applyHighlight();
    },

    applyHighlight() {
      const P = this.palette;
      this.coilList.forEach((c) => {
        if (!c.mesh) return;
        c.mesh.traverse((ch) => {
          if (ch.userData && ch.userData.isCoilBody) {
            ch.material.color.setHex(P.coilBody);
            ch.material.emissive.setHex(0x000000);
            ch.material.emissiveIntensity = 1;
          }
        });
      });
      if (this.detail) {
        const c = this.coilList.find((x) => x.id === this.detail.id);
        if (c && c.mesh) {
          c.mesh.traverse((ch) => {
            if (ch.userData && ch.userData.isCoilBody) {
              ch.material.color.setHex(P.highlightColor);
              ch.material.emissive.setHex(P.highlightEmissive);
              ch.material.emissiveIntensity = 0.9;
            }
          });
        }
      }
    },

    onMouseDown(e) {
      e.preventDefault();
      this._didDrag = false;
      this._dragStartX = e.clientX; this._dragStartY = e.clientY;
      if (e.button === 0) this.isRotating = true;
      if (e.button === 2) this.isPanning = true;
      this.prevMX = e.clientX; this.prevMY = e.clientY;
    },
    onMouseUp() { this.isRotating = false; this.isPanning = false; },
    onMouseMove(e) {
      if (this.isRotating || this.isPanning) {
        if (Math.abs(e.clientX - this._dragStartX) + Math.abs(e.clientY - this._dragStartY) > 4) {
          this._didDrag = true;
        }
      }
      if (this.isRotating) {
        const dx = e.clientX - this.prevMX, dy = e.clientY - this.prevMY;
        this.camTheta -= dx * 0.005;
        this.camPhi = Math.max(0.15, Math.min(1.45, this.camPhi + dy * 0.005));
        this.updateCamera();
      }
      if (this.isPanning) {
        const dx = e.clientX - this.prevMX, dy = e.clientY - this.prevMY;
        this.camTarget.x += (-dx * Math.cos(this.camTheta) + dy * Math.sin(this.camTheta)) * 0.04;
        this.camTarget.z += (-dx * Math.sin(this.camTheta) - dy * Math.cos(this.camTheta)) * 0.04;
        this.updateCamera();
      }
      this.prevMX = e.clientX; this.prevMY = e.clientY;
    },
    onMouseWheel(e) {
      this.camR = Math.max(8, Math.min(80, this.camR + e.deltaY * 0.03));
      this.updateCamera();
      e.preventDefault();
    },

    setView(type) {
      this.viewMode = type;
      if (type === 'iso') { this.camR = 36; this.camTheta = 0.6; this.camPhi = 0.55; this.camTarget = { x: 0, y: 1.5, z: 0 }; }
      if (type === 'top') { this.camR = 34; this.camTheta = 0; this.camPhi = 0.01; this.camTarget = { x: 0, y: 0, z: 0 }; }
      if (type === 'front') { this.camR = 32; this.camTheta = 0; this.camPhi = 1.5; this.camTarget = { x: 0, y: 2, z: 0 }; }
      if (type === 'side') { this.camR = 32; this.camTheta = Math.PI / 2; this.camPhi = 1.5; this.camTarget = { x: 0, y: 2, z: 0 }; }
      this.updateCamera();
    },

    updateStats() {
      const total = this.cols * this.rows;
      const usedKeys = {};
      let doubled = 0;
      this.coilList.forEach((c) => {
        usedKeys[c.posKey] = true;
        if (c.layer === 2) doubled++;
      });
      const used = Object.keys(usedKeys).length;
      this.stats = {
        total, used, free: total - used,
        pct: total > 0 ? Math.round(used / total * 100) + '%' : '0%',
        coils: this.coilList.length, doubled,
      };
    },

    animate() {
      if (this._destroyed) return;
      this._raf = requestAnimationFrame(this.animate);
      const t = Date.now() * 0.001;

      // 仅给选中钢卷一个柔和呼吸高亮，其它一律保持静态
      if (this.detail) {
        const c = this.coilList.find((x) => x.id === this.detail.id);
        if (c && c.mesh) {
          const pulse = 0.6 + 0.4 * Math.abs(Math.sin(t * 2.5));
          c.mesh.traverse((ch) => {
            if (ch.userData && ch.userData.isCoilBody) ch.material.emissiveIntensity = pulse;
          });
        }
      }

      this.fpsCount++;
      this.fpsTime += this.clock.getDelta();
      if (this.fpsTime >= 1) { this.fps = this.fpsCount; this.fpsCount = 0; this.fpsTime = 0; }
      this.renderer.render(this.scene, this.camera);
    },
  },
};
</script>

<style scoped lang="scss">
.wh3d-wrapper {
  background: #0a0e17;
  color: #c8d6e5;
  font-family: 'Segoe UI', 'Microsoft YaHei', sans-serif;
  height: 100%;
  display: flex;
  flex-direction: column;
  border-radius: 8px;
  overflow: hidden;
  border: 1px solid #1e3a5f;
}
.wh3d-header {
  background: linear-gradient(90deg, #0d1320, #111827, #0d1320);
  border-bottom: 1px solid #1e3a5f;
  padding: 10px 20px;
  display: flex;
  align-items: center;
  gap: 16px;
  h1 { color: #5dade2; font-size: 15px; font-weight: 600; letter-spacing: 1px; margin: 0; }
  .tag { font-size: 10px; color: #7f8c8d; background: #1a2233; padding: 3px 8px; border-radius: 3px; }
  .fps { margin-left: auto; font-size: 12px; color: #2ecc71; }
}
.wh3d-main { display: flex; flex: 1; overflow: hidden; }
.wh3d-view {
  position: relative;
  flex: 1;
  background: #080c14;
  overflow: hidden;
}
.wh3d-info {
  width: 310px;
  background: #0f1923;
  border-left: 1px solid #1e3a5f;
  display: flex;
  flex-direction: column;
  overflow: hidden;
  h2 {
    font-size: 13px; color: #5dade2;
    padding: 10px 14px 8px;
    border-bottom: 1px solid #1a2a3a;
    background: #0d1320; margin: 0;
    font-weight: 600;
  }
  .ss { padding: 10px 14px; border-bottom: 1px solid #1a2a3a; }
  .sg { display: grid; grid-template-columns: 1fr 1fr; gap: 6px; }
  .sc {
    background: #151f2e; border: 1px solid #1e3a5f; border-radius: 4px; padding: 7px 10px;
    .l { font-size: 10px; color: #576574; margin-bottom: 2px; }
    .v {
      font-size: 15px; font-weight: 700; color: #2ecc71;
      &.primary { color: var(--wh3d-primary); }
    }
  }
  .ctb {
    flex: 1; overflow-y: auto;
    table { width: 100%; font-size: 11px; border-collapse: collapse; }
    th {
      position: sticky; top: 0;
      background: #0d1320; color: #5dade2;
      padding: 5px 6px; text-align: left;
      font-weight: 600; font-size: 10px;
      border-bottom: 1px solid #1e3a5f;
    }
    td { padding: 5px 6px; border-bottom: 1px solid #0f1923; color: #8395a7; }
    tr { cursor: pointer; }
    tr:hover { background: #151f2e; }
    tr.sel { background: #1a3a5f; td { color: #5dade2; } }
    .ok { color: #2ecc71; }
    .pn { color: #e67e22; }
    .ng { color: #e74c3c; }
  }
}

.wh3d-vbt {
  position: absolute; top: 10px; right: 10px; z-index: 5;
  display: flex; gap: 4px;
  button {
    background: #1a2a3a; border: 1px solid #2a4a6f; color: #8395a7;
    padding: 5px 12px; font-size: 11px; border-radius: 3px; cursor: pointer;
    transition: all 0.15s;
    &:hover { background: #1a3a5f; color: #5dade2; border-color: #5dade2; }
    &.act { background: #1a3a5f; color: #5dade2; border-color: #5dade2; font-weight: 600; }
    &.theme-toggle {
      margin-left: 8px;
      border-color: var(--wh3d-primary);
      color: var(--wh3d-primary);
    }
  }
}

/* ====== 浅色主题覆盖 ====== */
.wh3d-light {
  background: #fff !important;
  color: #303133 !important;
  border-color: #ebeef5 !important;
  .wh3d-header {
    background: #fafbfc;
    border-bottom-color: #ebeef5;
    h1 { color: var(--wh3d-primary); }
    .tag { color: #909399; background: #f4f4f5; }
    .fps { color: #67C23A; }
  }
  .wh3d-view { background: #f5f7fa; }
  .wh3d-info {
    background: #fff;
    border-left-color: #ebeef5;
    h2 { color: #303133; background: #fafbfc; border-bottom-color: #ebeef5; }
    .ss { border-bottom-color: #ebeef5; }
    .sc {
      background: #fafbfc; border-color: #ebeef5;
      .l { color: #909399; }
      .v { color: #67C23A; &.primary { color: var(--wh3d-primary); } }
    }
    .ctb {
      th { background: #fafbfc; color: #606266; border-bottom-color: #ebeef5; }
      td { color: #606266; border-bottom-color: #f2f3f5; }
      tr:hover { background: #ecf5ff; }
      tr.sel { background: var(--wh3d-primary); td { color: #fff; } }
    }
  }
  .wh3d-vbt button {
    background: #fff; border-color: #dcdfe6; color: #606266;
    &:hover { background: #ecf5ff; color: var(--wh3d-primary); border-color: var(--wh3d-primary); }
    &.act { background: var(--wh3d-primary); color: #fff; border-color: var(--wh3d-primary); }
    &.theme-toggle { border-color: var(--wh3d-primary); color: var(--wh3d-primary); background: #fff; }
  }
  .wh3d-help {
    background: rgba(255, 255, 255, 0.95);
    border-color: #ebeef5;
    color: #606266;
    .hh { color: var(--wh3d-primary); border-bottom-color: #ebeef5; }
    .hr kbd {
      background: #f4f4f5; border-color: #dcdfe6;
      color: var(--wh3d-primary);
    }
  }
  .wh3d-axis {
    background: rgba(255, 255, 255, 0.92);
    border-color: #ebeef5;
    color: var(--wh3d-primary);
    .vt { color: #E6A23C; }
  }
  .wh3d-dlg {
    background: #fff; border-color: var(--wh3d-primary);
    box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
    h3 { color: var(--wh3d-primary); border-bottom-color: #ebeef5; }
    .k { color: #909399; }
    .v { color: #303133; }
    .x { color: #909399; &:hover { color: #303133; } }
  }
}

.wh3d-help {
  position: absolute; top: 10px; left: 10px;
  background: rgba(15, 25, 35, 0.92);
  border: 1px solid #1e3a5f; border-radius: 6px;
  padding: 10px 14px; z-index: 5;
  font-size: 11px; color: #8395a7;
  min-width: 180px;
  .hh {
    color: #5dade2; font-weight: 600; font-size: 12px;
    margin-bottom: 8px; padding-bottom: 6px;
    border-bottom: 1px solid #1e3a5f;
  }
  .hr {
    line-height: 1.9;
    kbd {
      display: inline-block;
      background: #1a2a3a;
      border: 1px solid #2a4a6f;
      border-bottom-width: 2px;
      border-radius: 3px;
      padding: 1px 6px;
      margin-right: 6px;
      color: #5dade2;
      font-family: monospace;
      font-size: 10px;
      min-width: 32px;
      text-align: center;
    }
  }
}

.wh3d-axis {
  position: absolute; bottom: 10px; left: 10px;
  background: rgba(15, 25, 35, 0.85);
  border: 1px solid #1e3a5f; border-radius: 4px;
  padding: 5px 10px;
  z-index: 5;
  font-size: 11px; color: #5dade2;
  display: flex; gap: 14px;
  .vt { color: #f39c12; }
}

.wh3d-dlg {
  position: absolute; top: 56px; right: 20px;
  width: 280px; background: #0f1923;
  border: 1px solid var(--wh3d-primary); border-radius: 6px;
  padding: 14px; z-index: 20;
  box-shadow: 0 8px 32px rgba(0, 0, 0, 0.7);
  h3 {
    color: var(--wh3d-primary);
    font-size: 13px; margin: 0 0 10px;
    padding-bottom: 6px;
    border-bottom: 1px solid #1e3a5f;
  }
  .rw { display: flex; justify-content: space-between; margin-bottom: 6px; font-size: 12px; }
  .k { color: #576574; }
  .v { color: #c8d6e5; font-weight: 600; }
  .x {
    position: absolute; top: 8px; right: 10px;
    background: none; border: none;
    color: #576574; cursor: pointer; font-size: 18px;
    &:hover { color: #c8d6e5; }
  }
}
</style>
