import re
s = open('v16_src.html').read()
def R(a, b, cnt=1):
    global s
    n = s.count(a)
    assert n == cnt, (n, a[:90])
    s = s.replace(a, b)
def RS(start, end, new):
    global s
    i = s.index(start); j = s.index(end, i)
    s = s[:i] + new + s[j:]
def RL(start, new):
    """replace whole line starting with start"""
    global s
    i = s.index(start); j = s.index('\n', i)
    s = s[:i] + new + s[j:]

# ---------- 头部 ----------
R('<title>NobadyBox v16 抽屉拆解图</title>', '<title>NobadyBox v17 前口拆解图</title>')
R('NOBADY BOX · 硬件方案 v16 · 底部抽屉版 · 拆解效果图', 'NOBADY BOX · 硬件方案 v17 · 前口直插版 · 拆解效果图')
R('<h1>AI 手机仓 v16 底部抽屉版逐级拆解</h1>', '<h1>AI 手机仓 v17 前口直插版逐级拆解</h1>')
R('<span>打印件 <b>4 + 1</b></span><span>拆解 <b>14</b> 级</span>', '<span>打印件 <b>3 + 1</b></span><span>拆解 <b>12</b> 级</span>')
R('max="14"', 'max="12"')
R('<span>13</span><span>14</span></div>', '</div>')
R('<span class="readout" id="readout">0 / 14</span>', '<span class="readout" id="readout">0 / 12</span>')
R('壁厚、合页、风道、走线、抽屉托和余量线是我按这组参数定的估算值。', '壁厚、合页、风道、前口尺寸和 TPU 垫是我按这组参数定的估算值。')

RS('<section>\n  <h3>v16 改了什么', '<section>\n  <h3>5 个 3D 打印零件</h3>', '''<section>
  <h3>v17 改了什么：前面开口，手机直接推进去</h3>
  <p class="lead">按你的意思做第一版最简单的结构：底仓前面底部开一个口，手机从口里平着推进去，充电口朝外，用手机自己的充电线从外面插上。没有抽屉，盒子里也没有手机充电线。顶盖只在维修时打开。</p>
  <div class="tbl"><table class="cmp">
    <thead><tr><th></th><th>v15 顶部翻盖版</th><th>v16 底部抽屉版</th><th>v17 前口直插版</th></tr></thead>
    <tbody>
      <tr><td>放手机</td><td>掀顶盖往深仓里放，手伸不进去</td><td>拉出抽屉，放好往后推</td><td>充电口朝外，从前口推进去</td></tr>
      <tr><td>拿手机</td><td>手指抓不到</td><td>拉出抽屉往前拔</td><td>拔线时连手机一起带出来，或用手指勾手机端面</td></tr>
      <tr><td>手机充电</td><td>盒内线，主板供电</td><td>盒内线，主板供电，下面留余量线</td><td>手机自己的线从外面插，盒子里没有手机线</td></tr>
      <tr><td>打印件</td><td>3 + 1</td><td>4 + 1</td><td>3 + 1</td></tr>
      <tr><td>外面的线</td><td>1 根</td><td>1 根</td><td>2 根（盒子 1 根、手机 1 根），用双口充电器就行</td></tr>
      <tr><td>要注意</td><td>线过合页</td><td>要扁平线</td><td>前口会进一点光，见下面「需要你确认」</td></tr>
    </tbody>
  </table></div>
</section>

''')
R('''<h3>5 个 3D 打印零件</h3>
  <p class="lead">4 个 PETG 打印件，加 1 套按手机大小选的 TPU 垫。''', '''<h3>4 个 3D 打印零件</h3>
  <p class="lead">3 个 PETG 打印件，加 1 套按手机大小选的 TPU 垫。''')

RS('<section>\n  <h3>单口供电、充电线和合页</h3>', '    <div class="req">\n      <h4>合页、磁铁和校准', '''<section>
  <h3>供电、手机充电和合页</h3>
  <p class="lead">盒子和手机各用一根线。点 3D 视图右上角的「走线」可以看到。</p>
  <div class="reqs">
    <div class="req">
      <h4>盒子供电<small>主板只管自己</small></h4>
      <ul>
        <li>顶盖 +X 侧一个 USB-C 口，只给主板、摄像头、风扇供电，峰值约 0.5 A，普通 5 V 1 A 充电头就够。</li>
        <li>主板上不再有手机供电那一路：没有 XH 插座、保险丝和电子开关，电路更简单，也不用担心手机充电的发热算在盒子里。</li>
        <li>合页里没有线穿过，顶盖拆装不用先拔线。</li>
      </ul>
    </div>
    <div class="req">
      <h4>手机怎么放、怎么充电<small>推进去，插上线</small></h4>
      <ul>
        <li>前口 88 × 22 mm，在前壁最底部。手机屏幕朝上、充电口朝外，平着推进去，顶到后面的 TPU 垫停住。</li>
        <li>停住时手机端面离外壳前面约 10 mm，充电口正对前口中间，用手机原装线从外面插上就开始充电。</li>
        <li>拿手机：把插头往外拔，手机会跟着一起滑出来；也可以手指伸进前口勾住手机端面往外拉。</li>
        <li>手机两侧有 TPU 条夹住，后面有 TPU 垫顶住，换不同长度的手机只换后垫。</li>
      </ul>
    </div>
''')
R('<li>合页带约 100° 限位，掀开后顶盖自己立住，不会往后倒。平时放手机走抽屉，顶盖只在维修时打开。</li>', '<li>合页带约 100° 限位，掀开后顶盖自己立住，不会往后倒。平时放手机走前口，顶盖只在维修时打开。</li>')
R('<li>2 个定位标记做在抽屉托上。每次推回抽屉，摄像头先拍一下标记，自动修正抽屉和顶盖的位置误差，用户感觉不到。</li>', '<li>仓底后端有 2 个定位标记。每次放进手机，摄像头先拍一下标记和屏幕，自动修正位置，用户感觉不到。</li>')
R('<li>底仓开口朝上竖着打；顶盖外壳顶面朝下打；安装板平放打；抽屉托底面贴板平放打；TPU 垫平放打。</li>', '<li>底仓开口朝上竖着打；顶盖外壳顶面朝下打；安装板平放打；TPU 垫平放打。</li>')
R('前面扁口顶边 88 mm 宽，靠桥接打出来。', '前口顶边 88 mm 宽，靠桥接打出来。')
R('<li>底仓两侧下部 4 扇进风百叶。手机放在抽屉托里，抽屉托两侧是开着的，风从手机上面流过去。</li>', '<li>底仓两侧下部 4 扇进风百叶，前口也能进风。手机底下垫 4 mm 高的支撑筋，手机背面也有风流过。</li>')
R('<li>2 颗 M3×30 穿合页把顶盖装到底仓上，压 4 颗磁铁。把插头卡进抽屉托后端，放 TPU 垫，推进底仓，做一次标定。</li>', '<li>2 颗 M3×30 穿合页把顶盖装到底仓上，压 4 颗磁铁，放进 TPU 垫，做一次标定。</li>')
RS('    <div><b>手机最长约 165 mm</b>', '  </div>\n</section>', '''    <div><b>前口会进一点光</b>前口在仓底最低处，光是平着进来的，主要照到手机端面和仓底。第一版先不挡，实测如果影响摄像头识别屏幕，再加一片带线缺口的 TPU 软帘。</div>
    <div><b>手机长度</b>手机框内长 175 mm。最长 175 mm 的手机不用后垫；短手机配厚一点的后垫，让充电口始终停在前口附近。</div>
''')

# ---------- 尺寸 / INFO ----------
R("const TRAVEL = 100, SLOT_W = 44, SLOT_Y = 25, RIB = 5.8;   // 抽屉行程 / 扁口半宽 / 扁口顶 / 抽屉托底面", "const SLOT_W = 44, SLOT_Y = 25, RIB = 7;   // 前口半宽 / 前口顶 / 支撑筋顶")
R("wall:'侧壁 / 后壁 / 前壁 2.4 mm，底 3.0 mm，手机框 2.0 mm，导轨筋 2.8 mm 高'", "wall:'侧壁 / 后壁 / 前壁 2.4 mm，底 3.0 mm，手机框 2.0 mm，支撑筋 4 mm 高'")
R("support:'不需要（百叶 45°，合页耳和磁铁座下 45° 斜撑，抽屉口顶边桥接 88 mm）'", "support:'不需要（百叶 45°，合页耳和磁铁座下 45° 斜撑，前口顶边桥接 88 mm）'")
R("role:'放手机的仓。前面开 88 × 22 mm 抽屉口，带导轨筋、手机框侧壁、后壁线槽、进风百叶、合页耳和磁铁座。'}", "role:'放手机的仓。前面开 88 × 22 mm 前口，带支撑筋、手机框侧壁、进风百叶、合页耳、磁铁座和定位标记。'}")
RS("  tray:{name:'抽屉托'", "  tpu:{", "")
R("tpu:{name:'TPU 垫', code:'P5',", "tpu:{name:'TPU 垫', code:'P4',")
R("wall:'侧条 2.8 mm，前垫 8 mm（波浪形可压缩），高 6 mm'", "wall:'侧条 4.5 mm，后垫按手机长度 0–13 mm，高 6 mm'")
R("role:'两侧把手机卡在中间对准镜头；前垫把手机往后顶，插头不会松。'}", "role:'两侧把手机卡在中间对准镜头；后垫让手机推进去时停在对的位置。'}")
RL("  cable:{name:'手机充电线'", "  cable:{name:'手机充电线（外接）', mat:'手机原装 C 口线，用户自备', role:'从前口插进手机充电口。盒子里没有手机充电线。'},")
R("role:'屏幕朝上，充电口朝后，往后一推插上充电线。屏幕离盒底 16 mm。'}", "role:'屏幕朝上，充电口朝外，从前口推进去。屏幕离盒底 16 mm。'}")

RS("const STEPS = [", "const NSTEP", """const STEPS = [
  {p:[], t:'放好手机的样子', d:'100 × 190 × 138.2 mm。手机从前面底部的前口推进去，充电线从外面插上。往右拖滑块，每一级拆下一个零件，当前这一级会标成橙色。'},
  {p:['cable'], t:'拔掉充电线', d:'手机自己的充电线，从前口插在手机充电口上。盒子里没有手机充电线。'},
  {p:['phone'], t:'抽出手机', d:'手机屏幕朝上、充电口朝外，平着从前口滑出来。放手机反过来：推进去顶到后垫停住，再插上线。'},
  {p:['tpu'], t:'取出 TPU 垫', d:'两侧条把手机卡在中间对准镜头，后垫让手机停在对的位置。按手机大小选 1 套。'},
  {p:['shell'], t:'掀开顶盖（维修时）', d:'平时不用开。维修时顶盖绕后边的合页往后翻，约 100° 停住自己立着。前面 2 对磁铁平时把它吸住。'},
  {p:['hinge'], t:'拔出合页销', d:'2 颗 M3×30 螺丝当合页销，拧下防松螺母就能抽出来。合页里没有线，直接拆。'},
  {p:['shell','plate'], t:'取下整个顶盖模块', d:'主板、摄像头、风扇都在顶盖里，整块拿下来。维修或者换代时直接换整个顶盖。'},
  {p:['shell'], t:'拆开顶盖外壳', d:'拧下 4 颗 M2 螺丝，顶盖外壳和安装板分开。外壳顶上嵌着风扇，+X 侧是 USB-C 开口。'},
  {p:['fan'], t:'拆风扇', d:'3007 风扇用 2 颗自攻螺丝装在外壳顶板下面，正对镜头中心，离 PCB 顶面约 2.5 mm，往上排风。'},
  {p:['pcb'], t:'拆 PCB 和摄像头', d:'摄像头焊在 PCB 中心，4 颗 M2 螺丝装在安装板的凸台上。镜头从安装板中间的 Ø15 孔穿下去，只露出镜头。'},
  {p:['mag'], t:'取出磁铁', d:'4 颗 Ø6 × 3 mm 钕磁铁压入，不用胶。压之前对一下极性，保证上下相吸。'},
  {p:['ins'], t:'热熔螺母', d:'8 颗 M2：安装板 4 颗（PCB），顶盖外壳 4 颗（安装板）。烙铁约 220 °C 烫进去。'},
  {p:['body'], t:'只剩底仓', d:'一体打印的底仓。虚线标注是它身上做出来的功能：前口、支撑筋、手机框侧壁、进风百叶、合页耳、磁铁座和定位标记。'}
];
""")

# ---------- 底仓 ----------
R("g.add(boxR(-XI,39,0,SPLIT,-L/2,-ZI,m), boxR(45,XI,0,SPLIT,-L/2,-ZI,m), boxR(39,45,0,114,-L/2,-ZI,m));   // 后壁（顶上 6 mm 过线缺口）",
  "g.add(boxR(-XI,XI,0,SPLIT,-L/2,-ZI,m));   // 后壁")
R("// 前壁，底部开 88 × 22 抽屉口", "// 前壁，底部开 88 × 22 前口")
R("for(const x of [-30,-12,12,30]) g.add(boxR(x-.8,x+.8,FL,RIB,-FZ+2,ZI,m));   // 导轨筋，中间 22 mm 走余量线",
  "for(const x of [-30,-12,12,30]) g.add(boxR(x-.8,x+.8,FL,RIB,-FZ+2,FZ,m));   // 4 mm 支撑筋")
RL("  g.add(boxR(37.2,38.4,12,SPLIT-3,-ZI,-89.6,m));", "")
RL("  for(const y of [35,70,105]) g.add(boxR(38.4,40.6,y,y+3,-90.6,-89.6,m));", "")
R("""    for(const x0 of [18,30]){ const k=cylX(4,6,m,sd*(x0+3),PIV.y,PIV.z); g.add(k, boxR(sd*x0,sd*(x0+6),PIV.y-4,PIV.y,-97,-L/2,m)); }  // 合页耳
  }
  return g;
}""", """    for(const x0 of [18,30]){ const k=cylX(4,6,m,sd*(x0+3),PIV.y,PIV.z); g.add(k, boxR(sd*x0,sd*(x0+6),PIV.y-4,PIV.y,-97,-L/2,m)); }  // 合页耳
  }
  const mk = M(0xd9dde2);
  for(const x of [-36,36]){ const z=-84, a=boxR(x-3.5,x+3.5,FL,FL+.25,z-.5,z+.5,mk), b=boxR(x-.5,x+.5,FL,FL+.25,z-3.5,z+3.5,mk); a.userData.noEdge=b.userData.noEdge=true; g.add(a,b); }   // 定位标记
  return g;
}""")
# 顶盖外壳后壁去掉过线缺口
R("g.add(boxR(-XI,39,SPLIT,H-WT,-L/2,-ZI,m), boxR(45,XI,SPLIT,H-WT,-L/2,-ZI,m), boxR(39,45,126,H-WT,-L/2,-ZI,m),", "g.add(boxR(-XI,XI,SPLIT,H-WT,-L/2,-ZI,m),")

# ---------- TPU / 抽屉托 ----------
RS("function buildTray(m){", "function buildTPU(m){", "")
RS("function buildTPU(m){", "/* ---------- 组装体", """function buildTPU(m){
  const g = new T.Group();
  g.add(boxR(-FX,-38,7,13,-77.5,84.5,m), boxR(38,FX,7,13,-77.5,84.5,m));
  g.add(boxR(-30,30,7,13,-FZ,-77.5,m));
  for(let i=1;i<4;i++) g.add(boxR(-30,30,13,13.6,-FZ+i*2.5-.4,-FZ+i*2.5+.4,m));
  return g;
}
""")
R("const mTray=M(0x565e68,{roughness:.5}); shellMats.push(mTray);\nconst hTray = holder(asm,0,0,0); hTray.add(addEdges(buildTray(mTray))); reg('tray',hTray,[mTray]);\n", "")
R("  hPCB.add(boxR(-28,-22,1.6,7.6,-12,-4,mW));            // XH 2P 手机供电\n", "")

# 手机掉头：充电口朝外
R("const hPhone = holder(asm,0,0,PZ);", "const hPhone = holder(asm,0,0,PZ); hPhone.rotation.y = Math.PI;")

# ---------- 外接充电线 ----------
RS("// 充电线\nconst mCab", "// 热熔螺母", """// 外接充电线（手机原装线，从前口插进来）
const mCab = M(0x15181c,{roughness:.7}), mPlug = M(0x2b2f35);
const hCable = holder(asm,0,0,0);
hCable.add(boxR(-4.15,4.15,10.3,12.7,82,PZ+81+4,mSteel), boxR(-6,6,8.2,14.8,PZ+81+4,PZ+81+22,mPlug));
hCable.add(new T.Mesh(new T.TubeGeometry(new T.CatmullRomCurve3([[0,11.5,PZ+102],[0,11.5,115],[0,8,135],[0,1.6,160],[25,1.6,215],[70,1.6,250]].map(a=>new T.Vector3(...a))),80,1.8,10,false),mCab));
reg('cable',hCable,[mCab,mPlug]);
""")

# ---------- 每一级怎么动 ----------
RS("[hTray,hPhone,hTPU,hCable].forEach(h=>mv(h,1,0,0,TRAVEL));", "/* ---------- 打印摆放", """mv(hCable,1,0,0,70);
mv(hPhone,2,0,0,200);
mv(hTPU,3,0,110,0);
hHinge.forEach((h,i)=> mv(h,5,(i?1:-1)*45,0,0));
mv(lidPivot,6,0,190,40);
mv(hShell,7,0,85,0);
mv(hFan,8,0,45,0);
mv(hPCB,9,0,22,0);
hMag.forEach(o=> mv(o.h,10,0,o.up?18:22,0));
hIns.forEach(k=> mv(k,11,...k.userData.dir));

""")

# ---------- 打印摆放 ----------
R("""  [[-420,'盘 1 · PETG HF · 约 11 h'],[-140,'盘 2 · PETG HF · 约 4.5 h'],[140,'盘 3 · PETG HF · 约 1.5 h'],[420,'盘 4 · TPU 95A · 约 0.4 h']].forEach""",
  """  [[-300,'盘 1 · PETG HF · 约 11 h'],[0,'盘 2 · PETG HF · 约 4.5 h'],[300,'盘 3 · TPU 95A · 约 0.4 h']].forEach""")
R("b.position.set(-420,0,0); pv.add(b); PV_LABELS.push({pos:new T.Vector3(-420,120,-40)", "b.position.set(-300,0,0); pv.add(b); PV_LABELS.push({pos:new T.Vector3(-300,120,-40)")
R("s.position.set(-195,H,0); pv.add(s); PV_LABELS.push({pos:new T.Vector3(-195,20,-40)", "s.position.set(-55,H,0); pv.add(s); PV_LABELS.push({pos:new T.Vector3(-55,20,-40)")
R("p.position.set(-85,-SPLIT,0); pv.add(p); PV_LABELS.push({pos:new T.Vector3(-85,4,40)", "p.position.set(55,-SPLIT,0); pv.add(p); PV_LABELS.push({pos:new T.Vector3(55,4,40)")
RL("  const tr=addEdges(buildTray(mTray));", "")
R("tp.position.set(420,-7,-3.5); pv.add(tp); PV_LABELS.push({pos:new T.Vector3(420-40,6,0),text:'<em>P5</em>TPU 垫 · 平放'", "tp.position.set(300,-7,0); pv.add(tp); PV_LABELS.push({pos:new T.Vector3(300-40,6,0),text:'<em>P4</em>TPU 垫 · 平放'")
R("camera.position.set(0,1150,1450).multiplyScalar(stage.clientWidth<600?1.8:1);", "camera.position.set(0,820,1000).multiplyScalar(stage.clientWidth<600?1.7:1);")

# ---------- 标注 ----------
R("PID.tray=lab(hTray,[42.3,13,40],'<em>P4</em>抽屉托','printed',()=>true);\n", "")
R("PID.tpu=lab(hTPU,[-40.8,13,-40],'<em>P5</em>TPU 垫','printed tpu',()=>true);", "PID.tpu=lab(hTPU,[-FX,13,-40],'<em>P4</em>TPU 垫','printed tpu',()=>true);")
R("PID.phone=lab(hPhone,[38,16,40],'手机（示意）','',after(1));", "PID.phone=lab(hPhone,[38,16,-40],'手机（示意）','',()=>true);")
R("'合页销 M3×30','',after(6)", "'合页销 M3×30','',after(5)")
R("'3007 风扇','',after(9)", "'3007 风扇','',after(8)")
R("'PCB + 摄像头','',after(10)", "'PCB + 摄像头','',after(9)")
R("PID.cable=lab(hCable,[5.5,14.7,-83],'手机充电线','',after(11));", "PID.cable=lab(hCable,[6,14.8,PZ+95],'手机充电线（外接）','',()=>true);")
R("'钕磁铁 Ø6×3','',after(12)", "'钕磁铁 Ø6×3','',after(10)")
R("'热熔螺母 M2','',after(13)", "'热熔螺母 M2','',after(11)")
R("lab(lidG,[70,132,0],'唯一外露接口 USB-C 5 V 3 A','wire',WL);", "lab(lidG,[70,132,0],'盒子供电 USB-C 5 V 1 A','wire',WL);")
RL("lab(lidG,[-25,132,-12],'XH 2P", "")
RL("lab(asm,[42,PIV.y,PIV.z-2],'贴合页转轴穿过", "")
RL("lab(hBody,[42,70,-91.5],'后壁线槽", "")
RL("lab(hCable,[5.5,14,-84],'插头卡在抽屉托后端", "lab(hCable,[0,11.5,PZ+110],'手机原装线从前口插进来','wire',WL);")
RL("lab(hBody,[0,4,-45],'抽屉托下 U 形余量线", "")
R("[[0,SLOT_Y,L/2],'抽屉口 88 × 22'],[[30,RIB,0],'导轨筋'],", "[[0,SLOT_Y,L/2],'前口 88 × 22'],[[30,RIB,0],'4 mm 支撑筋'],")
R("[[42,100,-91],'后壁线槽'],", "[[-36,FL+.3,-84],'定位标记'],")
R("].forEach(([p,t])=>lab(hBody,p,t,'feat',after(14)));", "].forEach(([p,t])=>lab(hBody,p,t,'feat',after(12)));")
RL("lab(hTray,[-36,7.3,-84],'定位标记'", "")

# ---------- 姿态 ----------
RS("  lidPivot.rotation.x = -Math.PI*100/180 * tOf(5,v) * (1-tOf(7,v));", "const accentColor", """  lidPivot.rotation.x = -Math.PI*100/180 * tOf(4,v) * (1-tOf(6,v));
  asm.updateMatrixWorld(true);
}
""")
R("if(k===0) h+='<p class=\"note\">打印件 5 个：P1 底仓、P2 顶盖外壳、P3 摄像头安装板、P4 抽屉托、P5 TPU 垫。", "if(k===0) h+='<p class=\"note\">打印件 4 个：P1 底仓、P2 顶盖外壳、P3 摄像头安装板、P4 TPU 垫。")
R("const s0=c(x), s=c(x-4), s2=c((x-6)/3); return {ty:69+45*s+65*s2, tz:45*s0,", "const s0=c(x-1), s=c(x-3), s2=c((x-5)/3); return {ty:69+45*s+65*s2, tz:60*s0*(1-s),")
R("mCab.color.copy(on?accentColor:cabBlack);", "mCab.color.copy(on?accentColor:cabBlack);")

# ---------- 零件卡与清单 ----------
R("body:['合页耳下面有 45° 斜撑，受力不会把耳朵掰断','抽屉口上面还有 95 mm 高的前壁，和两侧壁连成一圈，开口不会变形','4 条导轨筋托住抽屉托，中间 22 mm 宽的槽给余量线滚动']",
  "body:['合页耳下面有 45° 斜撑，受力不会把耳朵掰断','前口上面还有 95 mm 高的前壁，和两侧壁连成一圈，开口不会变形','4 条 4 mm 支撑筋托住手机，手机推进推出顺滑，背面也通风']")
R("tpu:['软料吸震，不刮手机','前垫是波浪形，压缩 5 mm，顶住手机不松','坏了单独重打，0.4 h','按手机分档，屏幕中心对准镜头']",
  "tpu:['软料吸震，不刮手机','后垫上有 3 道软筋，手机推到底有一点缓冲','坏了单独重打，0.4 h','按手机分档，屏幕中心对准镜头']")
R("['body','shell','plate','tray','tpu'].map(id=>", "['body','shell','plate','tpu'].map(id=>")
RS("const BOM=[", "document.getElementById('bom')", """const BOM=[
  ['P1 底仓','打印','1','PETG HF 黑','12'],['P2 顶盖外壳','打印','1','PETG HF 黑','7'],['P3 摄像头安装板','打印','1','PETG HF 黑','6'],['P4 TPU 垫','打印','按手机选 1 套','TPU 95A','3'],
  ['PCB + 摄像头','你的设计','1','57 × 28 × 6.5 mm，摄像头 Ø13.8 × 15 mm；不再需要手机供电那一路','9'],['3007 风扇','采购','1','30 × 30 × 7 mm，5 V 滚珠','8'],
  ['合页销','采购','2','M3×30 + 防松螺母','5'],['钕磁铁','采购','4','Ø6 × 3 mm','10'],
  ['热熔螺母','采购','8','M2','11'],['其他螺丝','采购','10','M2×6 ×8（PCB、安装板）、M2 自攻 ×2（风扇）（图中省略）','—'],
  ['USB-C 短延长线 + 面板座','待确认','1','PCB 的 USB 口够不到侧壁时才需要','—'],
  ['双口充电器','随机附或用户自备','1','一口给盒子（5 V 1 A），一口给手机；手机用原装线','—']
];
""")
assert 'Tray' not in s and 'TRAVEL' not in s and 'slackPts' not in s and 'CAB_' not in s, [m.group(0) for m in re.finditer(r'.{30}(Tray|TRAVEL|slackPts|CAB_).{30}', s)]
open('v17_src.html', 'w').write(s)
print('ok')
