#!/usr/bin/env python3
"""Fin-Ray 抓夹刀片 · 参数化自动生成

用途
----
输入【想要的长度】和【打印材料】（可选：杯子直径、线宽），自动完成：

    参数映射 -> 生成 STL -> 网格检查 -> 尺寸复核 -> 渲染 -> 工程图 -> HTML 报告

设计意图是「把这几天踩过的坑固化下来」：材料硬度到结构参数的换算、长度到 S_LEN
的换算、可打印性下限、以及每一项必须复核的尺寸，全部写进代码，不再靠人记。

用法
----
    python blade_designer.py --length 120 --material 98
    python blade_designer.py --cup 80 --material 98A          # 由杯径反推长度
    python blade_designer.py --length 120 --material 82 --no-render
    python blade_designer.py --length 120 --material 98 --stiffness-ratio 5.2
    python blade_designer.py --list-materials

输出目录（默认 builds/<tag>/）：
    Blade_<tag>.stl      成品（已钻孔）
    drawing.svg          带尺寸的侧视工程图，轮廓取自成品网格
    renders/*.png        三视图 + 等轴测 + 尖端 + 凸起平台 + 根部 + 安装孔
    report.html          自包含报告（图片内嵌），手机可直接看
    params.json          全部参数与复核结果，机器可读

依赖：openscad（渲染需要 DISPLAY）、numpy、本目录的 check_mesh.py
"""

from __future__ import annotations

import argparse
import base64
import json
import math
import os
import re
import shutil
import subprocess
import sys
import time
from dataclasses import dataclass, asdict, field
from pathlib import Path

import numpy as np

HERE = Path(__file__).resolve().parent
GEN_SCAD = HERE / "generate_blade_arcsweep.scad"
FIN_SCAD = HERE / "generate_v4_finish.scad"
CHECK_MESH = HERE / "check_mesh.py"
MEASURE = HERE.parent.parent / "03-software" / "scripts"


# ══════════════════════════════════════════════════════════════════════════
#  1. 材料库
# ══════════════════════════════════════════════════════════════════════════
# 弹性模量 MPa。低于 95A 用 Gent 经验式的量级，与厂商典型值对齐；
# 95A 以上【不用 Gent】—— 它的分母含 (254 - 2.54*S)，S 越接近 100 分母越趋近 0，
# 估值被急剧放大（98A 会给出 113 MPa，明显偏高）。这里取偏保守的厂商典型值。
#
# ★ 这张表是【估算】。真值请用挠度实测覆盖：--stiffness-ratio
#   测法：两片装配脚压桌沿下、刀身水平伸出，尖端同一位置挂同一重物，
#         侧面量下沉量。ratio = 参考件下沉 / 新件下沉。
MATERIALS: dict[int, dict] = {
    60: dict(E=2.5, note="很软，接近橡皮筋。夹持力会很低"),
    70: dict(E=4.5, note="软"),
    82: dict(E=11.0, note="★ 参考基准：旧红棕件推定为此，手感被操作者认可"),
    85: dict(E=14.0, note=""),
    90: dict(E=22.0, note=""),
    95: dict(E=40.0, note="原设计基准。BLADE-README 的切片参数按它写的"),
    98: dict(E=70.0, note="2026-08-22 实际到手的料。硬，压不塌，摩擦偏低（估值，待实测）"),
}
REF_SHORE = 82  # 手感被认可的参考件硬度
REF_SLEN = 83.0  # 参考件的 S_LEN（基线几何 WALL_T=2.0 / RIB_T=1.6）

# 基线几何（generate_blade_arcsweep.scad 里的默认值）
BASE_WALL_T = 2.0
BASE_RIB_T = 1.6
BASE_RIB_PITCH = 4.0

# 长度换算：由 S_LEN=83/93.8/114 三个实测点拟合，残差 < 0.02mm
LEN_A, LEN_B = 1.00729, 5.563  # 总长 = LEN_A * S_LEN + LEN_B


def shore_to_E(shore: float) -> float:
    """Shore A -> 弹性模量 MPa。表内点直接取，表外线性插值（对数域）。"""
    keys = sorted(MATERIALS)
    if shore in MATERIALS:
        return MATERIALS[shore]["E"]
    if shore <= keys[0]:
        return MATERIALS[keys[0]]["E"]
    if shore >= keys[-1]:
        return MATERIALS[keys[-1]]["E"]
    lo = max(k for k in keys if k < shore)
    hi = min(k for k in keys if k > shore)
    t = (shore - lo) / (hi - lo)
    # 模量随硬度近似指数增长，在对数域插值更合理
    return math.exp(math.log(MATERIALS[lo]["E"]) * (1 - t) + math.log(MATERIALS[hi]["E"]) * t)


def total_to_slen(total_mm: float) -> float:
    return (total_mm - LEN_B) / LEN_A


def slen_to_total(slen: float) -> float:
    return LEN_A * slen + LEN_B


def cup_to_total(cup_dia_mm: float, wrap: float = 1 / 3) -> float:
    """由杯径反推刀身总长。

    标定依据（本项目实测）：89.4mm 的刀身对 Ø80 杯子约包覆 1/4 周长，
    120mm 约包覆 1/3。两点给出 接触弧长 ≈ 0.70 x 总长。
        arc = wrap * pi * D  =>  total = wrap * pi * D / 0.70
    """
    return wrap * math.pi * cup_dia_mm / 0.70


# ══════════════════════════════════════════════════════════════════════════
#  2. 参数映射
# ══════════════════════════════════════════════════════════════════════════
@dataclass
class Design:
    total_length: float
    shore: float
    slen: float
    wall_t: float
    rib_t: float
    rib_pitch: float
    k: float                 # 几何缩放
    softening_geom: float    # 结构提供的软化倍数
    softening_len: float     # 长度提供的软化倍数
    softening_total: float
    stiffness_ratio: float   # 材料相对参考件硬多少倍
    line_width: float
    lines_in_beam: float
    cup_dia: float | None = None
    wrap: float | None = None
    warnings: list[str] = field(default_factory=list)
    notes: list[str] = field(default_factory=list)

    @property
    def tag(self) -> str:
        return f"L{self.total_length:.0f}_{self.shore:.0f}A"


def design(total_length: float, shore: float, *, line_width: float = 0.40,
           cup_dia: float | None = None, wrap: float = 1 / 3,
           stiffness_ratio: float | None = None,
           snap_to_line: bool = True) -> Design:
    """核心映射：长度 + 材料 -> 全套几何参数。

    推导
    ----
    目标：让新件的【柔顺度】等于参考件（82A、S_LEN=83、基线几何）。

      柔顺度 ∝ L³ / (E · 结构刚度)
      结构刚度按单一缩放 k 同时作用于 WALL_T 与 RIB_T：梁与肋的弯曲刚度都 ∝ k³，
      两者串联，合成软化 = 1/k³（已用四个实测档 A/B/C/D 验证，误差 <2%）。

      令新件柔顺度 = 参考件：
        (L/L_ref)³ · (1/k³) · (E_ref/E) = 1
        =>  k = (L/L_ref) · (E_ref/E)^(1/3)

    k > 1 表示材料比参考件还软或刀身很长，此时不需要减薄（k 会被夹到 1.0，
    因为加厚梁不是这套设计的意图，且会顶到 outer_solid 的包络）。
    """
    warns: list[str] = []
    notes: list[str] = []

    E = shore_to_E(shore)
    E_ref = MATERIALS[REF_SHORE]["E"]
    ratio = stiffness_ratio if stiffness_ratio else E / E_ref
    if stiffness_ratio:
        notes.append(f"刚度比 {ratio:.2f}x 来自实测（--stiffness-ratio），未用材料表估值")
    else:
        notes.append(f"刚度比 {ratio:.2f}x 来自材料表估值（{shore:.0f}A E≈{E:.0f} MPa "
                     f"vs 参考 {REF_SHORE}A E≈{E_ref:.0f} MPa）。建议用挠度实测覆盖")
        if shore > 95:
            warns.append(f"{shore:.0f}A 超出可靠估算范围（>95A 时公式失真严重）。"
                         f"表内取的是偏保守的厂商典型值，务必做挠度实测复核")

    slen = total_to_slen(total_length)
    soft_len = (slen / REF_SLEN) ** 3
    k = (slen / REF_SLEN) * (1.0 / ratio) ** (1 / 3)

    if k >= 1.0:
        k = 1.0
        notes.append("材料够软或刀身够长，无需减薄结构，采用基线几何")

    wall_t = BASE_WALL_T * k
    rib_t = BASE_RIB_T * k
    # 肋距随减薄同步放开，免得薄梁上挂太多肋（由四个实测档线性标定）
    rib_pitch = min(max(BASE_RIB_PITCH + (0.78 - k) * 6.0, BASE_RIB_PITCH), 6.5)

    # ── 可打印性 ────────────────────────────────────────────────────
    # 梁厚必须 >= 2 条挤出线，否则切片器可能整段漏打薄壁
    min_wall = 2.0 * line_width
    if wall_t < min_wall:
        warns.append(f"算出的 WALL_T {wall_t:.2f} 低于 2 条线宽 {min_wall:.2f}，已夹到下限。"
                     f"实际柔性会不足 —— 考虑加长刀身，或换更软的料")
        wall_t = min_wall
    if rib_t < 2.0 * line_width:
        rib_t = 2.0 * line_width

    if snap_to_line:
        # 吸附到线宽整数倍：非整数倍会让切片器处理一条排不下的余量，可能欠挤或留缝
        n = max(2, round(wall_t / line_width))
        snapped = n * line_width
        if abs(snapped - wall_t) > 1e-6:
            notes.append(f"WALL_T {wall_t:.3f} 吸附到 {snapped:.2f}（{n} 条 {line_width} 线）")
            wall_t = snapped
        nr = max(2, round(rib_t / line_width))
        rib_t = nr * line_width

    fw = (wall_t / BASE_WALL_T) ** 3
    fr = (rib_t / BASE_RIB_T) ** 3
    soft_geom = 1.0 / math.sqrt(fw * fr)

    d = Design(
        total_length=total_length, shore=shore, slen=slen,
        wall_t=round(wall_t, 3), rib_t=round(rib_t, 3), rib_pitch=round(rib_pitch, 2),
        k=round(k, 4), softening_geom=round(soft_geom, 2),
        softening_len=round(soft_len, 2),
        softening_total=round(soft_geom * soft_len, 2),
        stiffness_ratio=round(ratio, 2), line_width=line_width,
        lines_in_beam=round(wall_t / line_width, 2),
        cup_dia=cup_dia, wrap=wrap if cup_dia else None,
        warnings=warns, notes=notes,
    )

    got, need = d.softening_total, d.stiffness_ratio
    if need > 1.05:
        err = got / need
        if err < 0.75:
            d.warnings.append(f"合成软化 {got:.1f}x 只有需要的 {need:.1f}x 的 {err*100:.0f}%，"
                              f"成品仍会偏硬。加长刀身是代价最小的补法")
        elif err > 1.4:
            d.notes.append(f"合成软化 {got:.1f}x 超出需要的 {need:.1f}x，成品会偏软 —— "
                           f"夹持力上限会跟着降")
        else:
            d.notes.append(f"合成软化 {got:.1f}x 对上需要的 {need:.1f}x ✓")
    return d


# ══════════════════════════════════════════════════════════════════════════
#  3. 生成
# ══════════════════════════════════════════════════════════════════════════
def _run(cmd: list[str], timeout: int = 2400) -> subprocess.CompletedProcess:
    return subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)


def build_stl(d: Design, outdir: Path, verbose=True) -> Path:
    outdir.mkdir(parents=True, exist_ok=True)
    tmp = outdir / "_work"
    tmp.mkdir(exist_ok=True)
    teeth = tmp / "teeth.stl"
    final = outdir / f"Blade_{d.tag}.stl"

    defs = ["-D", f"S_LEN={d.slen}", "-D", "TEETH=true",
            "-D", f"WALL_T={d.wall_t}", "-D", f"RIB_T={d.rib_t}",
            "-D", f"RIB_PITCH={d.rib_pitch}"]
    if verbose:
        print(f"  [1/2] 生成本体 S_LEN={d.slen:.2f} WALL_T={d.wall_t} "
              f"RIB_T={d.rib_t} PITCH={d.rib_pitch} …", flush=True)
    r = _run(["openscad", *defs, "-o", str(teeth), str(GEN_SCAD)])
    if not teeth.exists() or teeth.stat().st_size < 10_000:
        raise RuntimeError(f"本体生成失败\n{r.stderr[-2000:]}")

    # 钻孔那一步要 import 本体，路径写死在 scad 里，这里替换成本次的临时文件
    fin = tmp / "finish.scad"
    fin.write_text(FIN_SCAD.read_text().replace('"Blade_v4_teeth.stl"', f'"{teeth}"'))
    if verbose:
        print("  [2/2] 钻安装孔 …", flush=True)
    r = _run(["openscad", "-o", str(final), str(fin)])
    if not final.exists() or final.stat().st_size < 10_000:
        raise RuntimeError(f"钻孔失败（常见原因：花纹台地骑在 90° 钩棱上）\n{r.stderr[-2000:]}")
    return final


# ══════════════════════════════════════════════════════════════════════════
#  4. 验证
# ══════════════════════════════════════════════════════════════════════════
def load_stl(path: Path) -> np.ndarray:
    raw = path.read_bytes()
    if raw[:5].lower() != b"solid" or b"facet normal" not in raw[:2048]:
        import struct
        n = struct.unpack("<I", raw[80:84])[0]
        body = raw[84:84 + n * 50]
        if len(body) == n * 50:
            rec = np.frombuffer(body, dtype=np.uint8).reshape(n, 50)
            return rec[:, 12:48].copy().view("<f4").reshape(n, 3, 3).astype(np.float64)
    vals = np.array(re.findall(r"vertex\s+(\S+)\s+(\S+)\s+(\S+)",
                               raw.decode("utf-8", "replace")), dtype=np.float64)
    return vals.reshape(-1, 3, 3)


def _xs_at_z(v, z):
    out = []
    for tri in v:
        dz = tri[:, 2] - z
        for a, b in ((0, 1), (1, 2), (2, 0)):
            if (dz[a] <= 0 < dz[b]) or (dz[b] <= 0 < dz[a]):
                t = dz[a] / (dz[a] - dz[b])
                out.append((tri[a] + t * (tri[b] - tri[a]))[0])
    return np.array(out)


def verify(stl: Path, d: Design) -> dict:
    """尺寸复核。★ check_mesh 通过 ≠ 件是对的 —— 这些是它查不出来的。"""
    v = load_stl(stl)
    P = v.reshape(-1, 3)
    zb, zt = P[:, 2].min(), P[:, 2].max()
    width = lambda z: (lambda x: x.max() - x.min() if len(x) else float("nan"))(_xs_at_z(v, z))

    # 钩面：细扫找内梁最内 x 的最大跳变
    prev, best = None, (0.0, 0.0)
    for dd in np.arange(8, 26, 0.25):
        x = _xs_at_z(v, zt - dd)
        if len(x) == 0:
            continue
        if prev is not None and x.min() - prev > best[0]:
            best = (x.min() - prev, dd)
        prev = x.min()

    res = dict(
        total_length=round(zt - zb, 3),
        width_y=round(P[:, 1].max() - P[:, 1].min(), 3),
        tip_thickness_d1=round(width(zt - 1), 3),
        tip_thickness_d2=round(width(zt - 2), 3),
        solid_width_zm26=round(width(-26), 3),
        hook_step=round(best[0], 3),
        hook_at_d=round(best[1], 2),
        triangles=len(v),
    )

    mesh = _run([sys.executable, str(CHECK_MESH), str(stl)], timeout=600)
    res["mesh_pass"] = "RESULT: PASS" in mesh.stdout
    res["shells"] = int(m.group(1)) if (m := re.search(r"connected shells\s+(\d+)", mesh.stdout)) else -1

    checks = []
    def chk(name, ok, got, want):
        checks.append(dict(name=name, ok=bool(ok), got=got, want=want))

    chk("网格水密单壳", res["mesh_pass"] and res["shells"] == 1,
        f"{'PASS' if res['mesh_pass'] else 'FAIL'} / {res['shells']} shell", "PASS / 1")
    chk("总长", abs(res["total_length"] - d.total_length) < 1.5,
        f"{res['total_length']:.2f} mm", f"{d.total_length:.1f} ± 1.5")
    chk("宽度（夹套/安装座按此）", abs(res["width_y"] - 23.2) < 0.6,
        f"{res['width_y']:.2f} mm", "23.2 ± 0.6")
    chk("尖端 d=1 厚度（>0 才没塌尖）", res["tip_thickness_d1"] > 1.0,
        f"{res['tip_thickness_d1']:.3f} mm", "> 1.0")
    chk("z=−26 实心宽（受力最大处的安全线）", res["solid_width_zm26"] > 20.0,
        f"{res['solid_width_zm26']:.2f} mm", "> 20")
    chk("凸起钩面台阶（官方实测 1.5~2）", 1.5 <= res["hook_step"] <= 2.4,
        f"{res['hook_step']:.3f} mm @ d={res['hook_at_d']}", "1.5 ~ 2.4")
    chk("梁厚 ≥ 2 条挤出线", d.lines_in_beam >= 1.99,
        f"{d.lines_in_beam:.2f} 条", "≥ 2.00")

    res["checks"] = checks
    res["all_pass"] = all(c["ok"] for c in checks)
    return res


# ══════════════════════════════════════════════════════════════════════════
#  5. 渲染 + 工程图
# ══════════════════════════════════════════════════════════════════════════
def render(stl: Path, outdir: Path, verbose=True) -> dict[str, Path]:
    v = load_stl(stl)
    P = v.reshape(-1, 3)
    zb, zt = P[:, 2].min(), P[:, 2].max()
    zc, L = (zb + zt) / 2, zt - zb
    rd = outdir / "renders"
    rd.mkdir(exist_ok=True)
    view = outdir / "_work" / "view.scad"
    view.write_text(f'import("{stl}");\n')

    dist = L * 3.0
    shots = {
        "side":  (f"0.5,0,{zc:.1f},90,0,0,{dist:.0f}", "700,1250"),
        "front": (f"0.5,0,{zc:.1f},90,0,90,{dist:.0f}", "700,1250"),
        "top":   (f"0.5,0,{zc:.1f},0,0,0,{L*1.2:.0f}", "900,700"),
        "iso":   (f"0.5,0,{zc:.1f},58,0,28,{dist*1.12:.0f}", "950,1180"),
        "tip":   (f"1,0,{zt-10:.1f},90,0,0,105", "950,900"),
        "pad":   (f"-7,0,{zt-6:.1f},90,0,200,26", "950,900"),
        "root":  (f"-4,0,{zb+14:.1f},90,0,0,88", "950,780"),
        "holes": (f"4.14,0,{zb+2.5:.1f},72,0,26,58", "950,780"),
    }
    out = {}
    for name, (cam, size) in shots.items():
        png = rd / f"{name}.png"
        r = _run(["openscad", "--render", "--colorscheme=Tomorrow",
                  f"--imgsize={size}", f"--camera={cam}", "-o", str(png), str(view)], timeout=1200)
        if png.exists():
            out[name] = png
            if verbose:
                print(f"    渲染 {name}", flush=True)
    return out


def drawing(stl: Path, outdir: Path) -> Path | None:
    """带尺寸的侧视工程图。轮廓与每一处标注位置都从成品网格取，不写死数字。"""
    from collections import defaultdict
    v = load_stl(stl)
    segs = []
    for tri in v:
        dy = tri[:, 1]
        pts = []
        for a, b in ((0, 1), (1, 2), (2, 0)):
            if (dy[a] <= 0 < dy[b]) or (dy[b] <= 0 < dy[a]):
                t = dy[a] / (dy[a] - dy[b])
                p = tri[a] + t * (tri[b] - tri[a])
                pts.append((p[0], p[2]))
        if len(pts) == 2:
            segs.append(pts)
    TOL = 1e-4
    key = lambda p: (round(p[0] / TOL), round(p[1] / TOL))
    adj, pos = defaultdict(list), {}
    for a, b in segs:
        ka, kb = key(a), key(b)
        pos[ka], pos[kb] = a, b
        adj[ka].append(kb); adj[kb].append(ka)
    seen, rings = set(), []
    for k in adj:
        if k in seen:
            continue
        ring, cur, prev = [], k, None
        while True:
            seen.add(cur); ring.append(pos[cur])
            nxt = next((x for x in adj[cur] if x != prev and x not in seen), None)
            if nxt is None:
                break
            prev, cur = cur, nxt
        if len(ring) >= 3:
            rings.append(np.array(ring))
    if not rings:
        return None
    area = lambda r: abs(np.dot(r[:, 0], np.roll(r[:, 1], -1)) - np.dot(r[:, 1], np.roll(r[:, 0], -1))) / 2
    rings.sort(key=area, reverse=True)
    outer, holes = rings[0], rings[1:]

    P = v.reshape(-1, 3)
    XMIN, XMAX, ZMIN, ZMAX = P[:, 0].min(), P[:, 0].max(), P[:, 2].min(), P[:, 2].max()
    S = 5.189
    X = lambda x: 150.0 + (x - XMIN) * S
    Y = lambda z: 150.0 + (ZMAX - z) * S
    d2s = lambda r: "M" + " L".join(f"{X(a):.1f},{Y(b):.1f}" for a, b in r) + " Z"

    band = np.array([p for r in holes for p in r])
    band = band[(band[:, 1] > ZMIN + 1) & (band[:, 1] < ZMIN + 5)]
    if len(band) < 8:
        return None
    mid = (band[:, 0].min() + band[:, 0].max()) / 2

    def cen(a):
        cx, cz = np.median(a[:, 0]), np.median(a[:, 1])
        rr = np.hypot(a[:, 0] - cx, a[:, 1] - cz)
        k = a[(rr > 0.8) & (rr < 2.0)]
        return k[:, 0].mean(), k[:, 1].mean()

    hx1, hz = cen(band[band[:, 0] < mid])
    hx2, _ = cen(band[band[:, 0] >= mid])
    foot = _xs_at_z(v, ZMIN + 4.0)
    FL, FR = foot.min(), foot.max()
    cav = [r for r in holes if (r[:, 1].max() - r[:, 1].min()) < 28 and r[:, 1].max() < ZMAX - 1 and area(r) > 1.0]
    cav_top = max((r[:, 1].max() for r in cav), default=ZMAX - 14)

    BLU, ORG, GRN, THIN = "#2a78d6", "#eb6834", "#1f9d55", "#9aa6b2"
    MONO = "font-family=\"'IBM Plex Mono', monospace\""

    def ext(z, xf, xt):
        return (f'<line x1="{X(xf):.0f}" y1="{Y(z):.0f}" x2="{X(xt)+7:.0f}" y2="{Y(z):.0f}" '
                f'stroke="{THIN}" stroke-width="0.7" stroke-dasharray="3 2.5"/>')

    def dimV(xd, z1, z2, label, color, ly, anchor="start", dx=7):
        y1, y2, xx = Y(z1), Y(z2), X(xd)
        return (f'<line x1="{xx:.0f}" y1="{y1:.0f}" x2="{xx:.0f}" y2="{y2:.0f}" stroke="{color}" stroke-width="1.3"/>\n  '
                f'<path d="M{xx-4:.0f},{y1+7:.0f} L{xx:.0f},{y1:.0f} L{xx+4:.0f},{y1+7:.0f} '
                f'M{xx-4:.0f},{y2-7:.0f} L{xx:.0f},{y2:.0f} L{xx+4:.0f},{y2-7:.0f}" fill="none" stroke="{color}" stroke-width="1.3"/>\n  '
                f'<text x="{xx+dx:.0f}" y="{ly:.0f}" font-size="12.5" text-anchor="{anchor}" fill="currentColor" font-weight="700" {MONO}>{label}</text>')

    def dimH(z, x1, x2, label, color, dy=0):
        y, a, b = Y(z) + dy, X(x1), X(x2)
        return (f'<line x1="{a:.0f}" y1="{y:.0f}" x2="{b:.0f}" y2="{y:.0f}" stroke="{color}" stroke-width="1.3"/>\n  '
                f'<path d="M{a+6:.0f},{y-4:.0f} L{a:.0f},{y:.0f} L{a+6:.0f},{y+4:.0f} '
                f'M{b-6:.0f},{y-4:.0f} L{b:.0f},{y:.0f} L{b-6:.0f},{y+4:.0f}" fill="none" stroke="{color}" stroke-width="1.3"/>\n  '
                f'<text x="{(a+b)/2:.0f}" y="{y-6:.0f}" font-size="13" text-anchor="middle" fill="currentColor" font-weight="700" {MONO}>{label}</text>')

    XD1, XD2, XD3 = 8.5, 15.5, 22.5
    TIPS, TOT = f"{ZMAX-cav_top:.1f}", f"{ZMAX-ZMIN:.1f}"
    parts = [f'<path d="{d2s(outer)}" fill="var(--series-soft,#cfe0f5)" stroke="var(--series,#2a78d6)" stroke-width="1.1"/>']
    parts += [f'  <path d="{d2s(h)}" fill="var(--paper,#fbfbfa)" stroke="var(--series,#2a78d6)" stroke-width="0.7" opacity="0.9"/>' for h in holes]
    LX, LY = round(X(XD3) + 42), round(Y(ZMAX) - 4)
    legend = [f'<text x="{LX}" y="{LY-16}" font-size="10" fill="currentColor" opacity="0.6" letter-spacing="1.2">尖端三段</text>']
    for i, (c, n, t) in enumerate([(BLU, "12.0", "内梁 平台 + 90° 钩"),
                                   (GRN, TIPS, "尖端实心 · 边界顺肋"),
                                   (ORG, "20.0", "外梁 抛物线内收")]):
        y = LY + i * 21
        legend += [f'<rect x="{LX}" y="{y-8}" width="10" height="3" fill="{c}"/>',
                   f'<text x="{LX+16}" y="{y}" font-size="11.5" fill="currentColor" font-weight="700" {MONO}>{n}</text>',
                   f'<text x="{LX+52}" y="{y}" font-size="11" fill="currentColor" opacity="0.78">{t}</text>']
    HOOK_Z, BLEND_Z = ZMAX - 15.0, ZMAX - 20.0
    ann = [ext(ZMAX, max(_xs_at_z(v, ZMAX - 0.05)), XD3),
           ext(HOOK_Z, max(_xs_at_z(v, HOOK_Z)), XD1),
           ext(cav_top, max(_xs_at_z(v, cav_top)), XD2),
           ext(BLEND_Z, max(_xs_at_z(v, BLEND_Z)), XD3),
           dimV(XD1, HOOK_Z, ZMAX, "15.0", BLU, Y(HOOK_Z) + 15),
           dimV(XD2, cav_top, ZMAX, TIPS, GRN, Y(cav_top) + 15),
           dimV(XD3, BLEND_Z, ZMAX, "20.0", ORG, Y(BLEND_Z) + 15),
           dimV(XMIN - 3.0, ZMIN, ZMAX, f"{TOT}  总长", BLU, (Y(ZMIN) + Y(ZMAX)) / 2, "end", -8),
           dimH(hz, hx1, hx2, f"{hx2-hx1:.2f}", BLU, -26),
           f'<text x="{X(hx1)-12:.0f}" y="{Y(hz)+5:.0f}" font-size="12" text-anchor="end" fill="currentColor" font-weight="700" {MONO}>2×Ø2.80</text>',
           dimV(XMAX + 2.6, ZMIN, hz, f"{hz-ZMIN:.2f}", BLU, (Y(ZMIN) + Y(hz)) / 2 + 4),
           dimH(ZMIN, FL, FR, f"{FR-FL:.2f}  装配脚", BLU, 52),
           f'<text x="{X(XMIN)-16:.0f}" y="{Y((ZMIN+ZMAX)/2):.0f}" font-size="12" fill="currentColor" opacity="0.75" text-anchor="end">← 抓取面</text>',
           f'<text x="18" y="26" font-size="12" fill="currentColor" opacity="0.7" {MONO}>Blade · 侧视 · mm · 轮廓与尺寸均取自成品 STL</text>'] + legend
    W, H = LX + 240, int(Y(ZMIN)) + 90
    svg = (f'<svg viewBox="0 0 {W} {H}" role="img" aria-label="Dimensioned side view, {TOT} mm tall">\n  '
           + "\n  ".join(parts) + "\n  " + "\n  ".join(ann) + "\n</svg>")
    svg = re.sub(r'((?:x|y|x1|y1|x2|y2)=")(-?\d+\.\d+)"',
                 lambda m: m.group(1) + str(round(float(m.group(2)))) + '"', svg)
    p = outdir / "drawing.svg"
    p.write_text(svg)
    return p


# ══════════════════════════════════════════════════════════════════════════
#  6. 报告
# ══════════════════════════════════════════════════════════════════════════
CSS = """
:root{--paper:#fbfbfa;--plane:#f2f1ee;--rule:#e2e0da;--ink:#0b0b0b;--ink-2:#52514e;
--muted:#898781;--series:#2a78d6;--series-2:#eb6834;--good:#1f9d55;--bad:#c8442a;
--series-soft:#cfe0f5;--series-ghost:rgba(42,120,214,.06)}
@media(prefers-color-scheme:dark){:root:not([data-theme=light]){--paper:#17171a;--plane:#0e0e10;
--rule:#2c2c31;--ink:#fff;--ink-2:#c3c2b7;--series:#3987e5;--series-2:#d95926;--good:#3ecf8e;
--bad:#f0705a;--series-soft:#1d3busy;--series-soft:#1d3350;--series-ghost:rgba(57,135,229,.09)}}
:root[data-theme=dark]{--paper:#17171a;--plane:#0e0e10;--rule:#2c2c31;--ink:#fff;--ink-2:#c3c2b7;
--series:#3987e5;--series-2:#d95926;--good:#3ecf8e;--bad:#f0705a;--series-soft:#1d3350;
--series-ghost:rgba(57,135,229,.09)}
*{box-sizing:border-box}body{margin:0;background:var(--plane);color:var(--ink);
font-family:'IBM Plex Sans',-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
font-size:15.5px;line-height:1.62}
.wrap{max-width:1080px;margin:0 auto;padding:38px 22px 80px}
h1{font-size:30px;margin:0 0 8px;letter-spacing:-.02em}
h2{font-size:20px;margin:38px 0 12px;padding-bottom:8px;border-bottom:1px solid var(--rule)}
h3{font-size:15.5px;margin:22px 0 8px}
.eyebrow{font-size:11px;letter-spacing:.14em;text-transform:uppercase;color:var(--muted);margin-bottom:10px}
.lede{color:var(--ink-2);max-width:70ch}
code{font-family:'IBM Plex Mono',ui-monospace,monospace;font-size:.9em;
background:var(--series-ghost);padding:1px 5px;border-radius:4px}
table{width:100%;border-collapse:collapse;background:var(--paper);border:1px solid var(--rule);
border-radius:10px;overflow:hidden;margin:14px 0}
th{text-align:left;font-size:11px;letter-spacing:.1em;text-transform:uppercase;color:var(--muted);
padding:10px 13px;border-bottom:1px solid var(--rule);white-space:nowrap}
td{padding:9px 13px;border-bottom:1px solid var(--rule);color:var(--ink-2);vertical-align:top}
tr:last-child td{border-bottom:none}
td.n,th.n{text-align:right;font-family:'IBM Plex Mono',monospace}
.item{color:var(--ink);font-weight:500}
.ok{color:var(--good);font-weight:700}.bad{color:var(--bad);font-weight:700}
.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(230px,1fr));gap:16px;margin:20px 0}
figure{margin:0}figure img{width:100%;border-radius:10px;border:1px solid var(--rule);
background:var(--paper);display:block}
figcaption{margin-top:7px;font-size:12.5px;color:var(--ink-2)}
.cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:13px;margin:18px 0}
.card{border:1px solid var(--rule);border-radius:12px;padding:14px 16px;background:var(--paper)}
.card .l{font-size:10.5px;letter-spacing:.11em;text-transform:uppercase;color:var(--muted);margin-bottom:6px}
.card .v{font-size:26px;font-weight:700;font-family:'IBM Plex Mono',monospace;letter-spacing:-.02em}
.note{border-left:4px solid var(--series);background:var(--series-ghost);padding:12px 16px;
border-radius:0 10px 10px 0;margin:16px 0;color:var(--ink-2)}
.warn{border-left-color:var(--series-2)}
.drw{border:1px solid var(--rule);border-radius:12px;padding:14px;background:var(--paper);overflow-x:auto}
.scroll{overflow-x:auto}
footer{margin-top:46px;padding-top:16px;border-top:1px solid var(--rule);color:var(--muted);font-size:12.5px}
"""


def b64(p: Path) -> str:
    return "data:image/png;base64," + base64.b64encode(p.read_bytes()).decode()


def report(d: Design, stl: Path, ver: dict, shots: dict, svg: Path | None, outdir: Path) -> Path:
    CAP = {"side": ("侧视", "肋扇形排布：最底肋 ⊥ 内梁，向尖端渐开到 47°"),
           "front": ("正视", f"{ver['width_y']:.1f} mm 宽"),
           "top": ("俯视", ""),
           "iso": ("等轴测", ""),
           "tip": ("尖端", f"凸起段 15 mm 到 90° 钩，实测跳 {ver['hook_step']:.2f} mm"),
           "pad": ("凸起平台", "平顶菱形台地 + 排水沟，陆地率 61%"),
           "root": ("根部", "补实顶与第一条肋水平，平行装配脚底面"),
           "holes": ("安装孔", "Ø2.80 一对，孔距 10.000，孔心距底面 2.500")}
    three = "".join(f'<figure><img src="{b64(shots[k])}" alt="{CAP[k][0]}"><figcaption><b>{CAP[k][0]}</b>'
                    f'{" — " + CAP[k][1] if CAP[k][1] else ""}</figcaption></figure>'
                    for k in ("side", "front", "top") if k in shots)
    rest = "".join(f'<figure><img src="{b64(shots[k])}" alt="{CAP[k][0]}"><figcaption><b>{CAP[k][0]}</b>'
                   f'{" — " + CAP[k][1] if CAP[k][1] else ""}</figcaption></figure>'
                   for k in ("iso", "tip", "pad", "root", "holes") if k in shots)
    rows = "".join(f'<tr><td class="item">{c["name"]}</td><td class="n">{c["want"]}</td>'
                   f'<td class="n">{c["got"]}</td><td class="n {"ok" if c["ok"] else "bad"}">'
                   f'{"PASS" if c["ok"] else "FAIL"}</td></tr>' for c in ver["checks"])
    notes = "".join(f'<div class="note">{n}</div>' for n in d.notes)
    warns = "".join(f'<div class="note warn"><b>注意：</b>{w}</div>' for w in d.warnings)
    cup = (f'<tr><td class="item">目标杯径</td><td class="n">Ø{d.cup_dia:.0f} mm</td>'
           f'<td>按包覆 {d.wrap*100:.0f}% 周长反推长度</td></tr>') if d.cup_dia else ""
    banner = ('<div class="note" style="border-left-color:var(--good)"><b>全部复核通过</b> —— 可以直接切片打印。</div>'
              if ver["all_pass"] else
              '<div class="note warn"><b>有复核项未通过</b>，见下表。打印前请先解决。</div>')
    mm = MATERIALS.get(int(d.shore), {})

    html = f"""<!doctype html><html lang="zh"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Blade {d.tag}</title>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;700&family=IBM+Plex+Sans:wght@400;500;600;700&display=swap">
<style>{CSS}</style></head><body><div class="wrap">
<div class="eyebrow">fin-ray blade · auto-generated · {time.strftime('%Y-%m-%d %H:%M')}</div>
<h1>Blade {d.tag}</h1>
<p class="lede">总长 {d.total_length:.0f} mm · TPU {d.shore:.0f}A · 由 <code>blade_designer.py</code> 自动生成并复核。</p>
{banner}{warns}{notes}

<div class="cards">
  <div class="card"><div class="l">总长</div><div class="v">{ver['total_length']:.1f}</div><div class="l" style="margin:6px 0 0">mm 实测</div></div>
  <div class="card"><div class="l">梁厚 WALL_T</div><div class="v">{d.wall_t:.2f}</div><div class="l" style="margin:6px 0 0">{d.lines_in_beam:.1f} 条挤出线</div></div>
  <div class="card"><div class="l">肋厚 RIB_T</div><div class="v">{d.rib_t:.2f}</div><div class="l" style="margin:6px 0 0">肋距 {d.rib_pitch:.1f}</div></div>
  <div class="card"><div class="l">合成软化</div><div class="v">{d.softening_total:.1f}×</div><div class="l" style="margin:6px 0 0">需要 {d.stiffness_ratio:.1f}×</div></div>
</div>

<h2>一、参数映射是怎么算的</h2>
<div class="scroll"><table>
<thead><tr><th>输入 / 中间量</th><th class="n">值</th><th>来源</th></tr></thead><tbody>
<tr><td class="item">要求总长</td><td class="n">{d.total_length:.1f} mm</td><td>输入</td></tr>
{cup}
<tr><td class="item">材料</td><td class="n">Shore {d.shore:.0f}A</td><td>输入。{mm.get('note','')}</td></tr>
<tr><td class="item">弹性模量 E</td><td class="n">{shore_to_E(d.shore):.0f} MPa</td><td>材料表（&gt;95A 为保守估值）</td></tr>
<tr><td class="item">相对参考件的刚度比</td><td class="n">{d.stiffness_ratio:.2f}×</td><td>参考 = {REF_SHORE}A（旧红棕件，手感被认可）</td></tr>
<tr><td class="item"><code>S_LEN</code></td><td class="n">{d.slen:.2f}</td><td>总长 = {LEN_A:.5f}·S_LEN + {LEN_B:.3f}（三点实测拟合，残差 &lt;0.02）</td></tr>
<tr><td class="item">长度提供的软化</td><td class="n">{d.softening_len:.2f}×</td><td>挠度 ∝ L³</td></tr>
<tr><td class="item">几何缩放 k</td><td class="n">{d.k:.3f}</td><td>k = (L/L_ref)·(E_ref/E)^⅓</td></tr>
<tr><td class="item">结构提供的软化</td><td class="n">{d.softening_geom:.2f}×</td><td>梁与肋同时缩放 k，合成 = 1/k³</td></tr>
<tr><td class="item"><b>合成软化</b></td><td class="n"><b>{d.softening_total:.2f}×</b></td><td>目标 = 刚度比 {d.stiffness_ratio:.2f}×</td></tr>
</tbody></table></div>
<p class="lede">推导：让新件的柔顺度等于参考件。柔顺度 ∝ L³/(E·结构刚度)，
结构刚度按单一缩放 k 同时作用于 <code>WALL_T</code> 与 <code>RIB_T</code>，
两者弯曲刚度都 ∝ k³、串联后合成软化 = 1/k³ —— 这个关系已用四个实测档验证，误差 &lt;2%。
令两边相等即得 <code>k = (L/L_ref)·(E_ref/E)^⅓</code>。</p>

<h2>二、复核（check_mesh 查不出来的那些）</h2>
<div class="scroll"><table>
<thead><tr><th>检查项</th><th class="n">要求</th><th class="n">实测</th><th class="n">判定</th></tr></thead>
<tbody>{rows}</tbody></table></div>

<h2>三、三视图</h2>
<div class="grid">{three}</div>
<h2>四、细部</h2>
<div class="grid">{rest}</div>
{f'<h2>五、工程图</h2><div class="drw">{svg.read_text()}</div>' if svg else ''}

<h2>{'六' if svg else '五'}、打印设置</h2>
<div class="scroll"><table><tbody>
<tr><td class="item">材料</td><td>TPU {d.shore:.0f}A</td></tr>
<tr><td class="item">层高</td><td>0.20 mm —— <b>不要加大</b>，花纹台地只有 0.4 mm 高（正好 2 层）</td></tr>
<tr><td class="item">挤出线宽</td><td>{d.line_width:.2f} mm（<code>WALL_T</code> 已吸附到它的整数倍）</td></tr>
<tr><td class="item">墙圈数</td><td>3 —— 梁在 2 圈就已填满（受厚度截断），3 圈是为了装配脚的螺丝孔更结实。<b>墙圈数不是柔性旋钮</b></td></tr>
<tr><td class="item">填充</td><td>25% grid（这个件几乎没有填充区域，调它省不了时间）</td></tr>
<tr><td class="item">温度</td><td>240 °C / 热床 35 °C</td></tr>
<tr><td class="item">支撑</td><td>关</td></tr>
<tr><td class="item">摆放</td><td>平放，宽面贴床（层线横切弯曲方向）</td></tr>
<tr><td class="item">数量</td><td>2 个（两指共用同一零件）</td></tr>
<tr><td class="item">薄壁检测</td><td>打开（Orca <code>Detect thin wall</code> / Cura <code>Print Thin Walls</code>）</td></tr>
</tbody></table></div>

<footer>
STL：<code>{stl.name}</code>（{stl.stat().st_size/1024/1024:.2f} MB，{ver['triangles']} 三角形）<br>
重新生成：<code>python blade_designer.py --length {d.total_length:.0f} --material {d.shore:.0f}</code><br>
参数映射的完整推导见 <code>PARAMETRIC-DESIGN.md</code>；材料诊断见 Space 的 material 一页。
</footer>
</div></body></html>"""
    p = outdir / "report.html"
    p.write_text(html)
    return p


# ══════════════════════════════════════════════════════════════════════════
#  7. CLI
# ══════════════════════════════════════════════════════════════════════════
def main():
    ap = argparse.ArgumentParser(
        description="Fin-Ray 抓夹刀片 · 参数化自动生成",
        formatter_class=argparse.RawDescriptionHelpFormatter,
        epilog="""例子:
  python blade_designer.py --length 120 --material 98
  python blade_designer.py --cup 80 --material 98         # 由杯径定长度
  python blade_designer.py --length 120 --material 82
  python blade_designer.py --length 120 --material 98 --stiffness-ratio 5.2
  python blade_designer.py --length 120 --material 98 --dry-run   # 只看参数不建模
""")
    ap.add_argument("--length", type=float, help="刀片总长 mm")
    ap.add_argument("--cup", type=float, help="目标杯子直径 mm（用来反推长度）")
    ap.add_argument("--wrap", type=float, default=1 / 3, help="希望包覆杯子周长的比例，默认 0.333")
    ap.add_argument("--material", "-m", default="98", help="Shore A 硬度，如 98 / 82 / 95A")
    ap.add_argument("--stiffness-ratio", type=float,
                    help="用挠度实测得到的刚度比覆盖材料表估值（= 参考件下沉 / 新件下沉）")
    ap.add_argument("--line-width", type=float, default=0.40, help="挤出线宽，默认 0.40")
    ap.add_argument("--out", type=Path, default=HERE / "builds", help="输出根目录")
    ap.add_argument("--no-render", action="store_true", help="跳过渲染（快很多）")
    ap.add_argument("--no-snap", action="store_true", help="不把 WALL_T 吸附到线宽整数倍")
    ap.add_argument("--dry-run", action="store_true", help="只打印参数映射，不生成模型")
    ap.add_argument("--list-materials", action="store_true", help="列出材料库")
    a = ap.parse_args()

    if a.list_materials:
        print(f"{'Shore A':>8s} {'E (MPa)':>9s} {'相对 82A':>9s}  说明")
        for s in sorted(MATERIALS):
            m = MATERIALS[s]
            print(f"{s:>8d} {m['E']:>9.1f} {m['E']/MATERIALS[REF_SHORE]['E']:>8.2f}×  {m['note']}")
        print("\n>95A 的值为保守估算；请用 --stiffness-ratio 传入挠度实测值覆盖。")
        return 0

    shore = float(str(a.material).upper().rstrip("A"))
    if a.length:
        total = a.length
    elif a.cup:
        total = cup_to_total(a.cup, a.wrap)
        print(f"由杯径 Ø{a.cup:.0f} × 包覆 {a.wrap*100:.0f}% 反推总长 = {total:.1f} mm")
    else:
        ap.error("需要 --length 或 --cup 之一")

    d = design(total, shore, line_width=a.line_width, cup_dia=a.cup, wrap=a.wrap,
               stiffness_ratio=a.stiffness_ratio, snap_to_line=not a.no_snap)

    print(f"\n{'='*66}\n参数映射  →  Blade {d.tag}\n{'='*66}")
    print(f"  总长          {d.total_length:8.1f} mm      S_LEN = {d.slen:.2f}")
    print(f"  材料          {d.shore:8.0f}A         E ≈ {shore_to_E(d.shore):.0f} MPa"
          f"   刚度比 {d.stiffness_ratio:.2f}× (参考 {REF_SHORE}A)")
    print(f"  几何缩放 k    {d.k:8.3f}")
    print(f"  WALL_T        {d.wall_t:8.2f} mm      {d.lines_in_beam:.2f} 条 {d.line_width} 线")
    print(f"  RIB_T         {d.rib_t:8.2f} mm")
    print(f"  RIB_PITCH     {d.rib_pitch:8.2f} mm")
    print(f"  软化: 长度 {d.softening_len:.2f}× × 结构 {d.softening_geom:.2f}×"
          f" = {d.softening_total:.2f}×   (需要 {d.stiffness_ratio:.2f}×)")
    for n in d.notes:
        print(f"    · {n}")
    for w in d.warnings:
        print(f"    ⚠ {w}")
    if a.dry_run:
        return 0

    if not shutil.which("openscad"):
        print("\n找不到 openscad，无法生成。", file=sys.stderr)
        return 1

    outdir = Path(a.out) / d.tag
    print(f"\n输出目录 {outdir}")
    t0 = time.time()
    stl = build_stl(d, outdir)
    print(f"  STL {stl.name}  {stl.stat().st_size/1024/1024:.2f} MB")

    print("  复核 …", flush=True)
    ver = verify(stl, d)
    for c in ver["checks"]:
        print(f"    {'✓' if c['ok'] else '✗'} {c['name']:<34s} {c['got']:>22s}  (要求 {c['want']})")

    shots = {}
    if not a.no_render:
        print("  渲染 …", flush=True)
        shots = render(stl, outdir)
    svg = drawing(stl, outdir)
    rep = report(d, stl, ver, shots, svg, outdir)
    (outdir / "params.json").write_text(json.dumps(
        dict(design=asdict(d), verify=ver, stl=stl.name), ensure_ascii=False, indent=2))
    shutil.rmtree(outdir / "_work", ignore_errors=True)

    print(f"\n{'='*66}")
    print(f"{'全部通过 ✓' if ver['all_pass'] else '有复核项未通过 ✗ —— 打印前请先看报告'}"
          f"        用时 {time.time()-t0:.0f}s")
    print(f"  报告   {rep}")
    print(f"  STL    {stl}")
    print(f"{'='*66}")
    return 0 if ver["all_pass"] else 2


if __name__ == "__main__":
    sys.exit(main())
