# -*- coding: utf-8 -*-
"""Pass 6: 여러 부재를 한 번에 압출.
build_part.py 의 성공 로직(degree-1 가지치기 → 루프 탐색 → 압출)을 그대로 쓰되,
'손으로 고른 사각 창' 대신 '끝점으로 연결된 덩어리(connected component)' 하나를 부재로 본다.
두께는 pass5_thk.py 가 콜아웃 블록에서 도번1:두께1 로 확정한 값만 사용한다.

--dry 로 실행하면 후보 매칭 결과만 출력하고 압출은 하지 않는다.
"""
import json, math, sys, re, collections, numpy as np
import product          # 제품 고유값 모음 — 기본값 500pro

# 표제란/도면틀임을 알려주는 글자(도번·시트번호·표제란 상용어)
_FRAME_PAT = product.FRAME_PAT


def _clean_txt(s):
    """MTEXT 서식코드를 걷어낸 '눈에 보이는 글자'만 남긴다."""
    s = re.sub(r'\{\\f[^;]*;', '', s)
    s = re.sub(r'\\[A-Za-z][^;]*;', '', s)
    return s.replace('{', '').replace('}', '').replace('\\P', ' ').strip()

SCR = product.SCR
FAB = product.FAB
TOL = 0.5
DRY = '--dry' in sys.argv
ONLY = [a for a in sys.argv[1:] if not a.startswith('--')]

d1 = json.load(open(SCR + r'\index.json', encoding='utf-8'))
d2 = json.load(open(SCR + r'\index2.json', encoding='utf-8'))
# 두께 근거는 콜아웃(pass5)과 BOM 표(pass5b) 두 곳에서 온다. --bom 을 주면 둘을 합쳐서 쓴다.
# (소유권 게이트는 합친 목록 전체를 보고 판정해야 이웃 부재 오인식이 막힌다.)
blocks = json.load(open(SCR + r'\thk_blocks.json', encoding='utf-8'))['ok']
# 사본 시트 배제: 400Q 도면에는 같은 도번·같은 규격 묶음이 y 아래쪽에 통째로 다시 그려져 있다
# (같은 시트의 사본/구버전 배치). 걸러내지 않으면 같은 부재를 여러 번 압출하고, 소유권 게이트가
# 다른 시트의 덩어리를 붙잡아 엉뚱한 형상이 나온다. 주영역이 따로 없는 제품(500PRO)은 그대로 통과.
if product.Y_MAIN is not None:
    _n0 = len(blocks)
    blocks = [b for b in blocks if b['at'][1] > product.Y_MAIN]
    print('주영역(y>%.0f) 밖 사본시트 콜아웃 %d개 제외 -> %d개'
          % (product.Y_MAIN, _n0 - len(blocks), len(blocks)))
for b in blocks:
    b.setdefault('src', 'CALLOUT')
if '--bom' in sys.argv:
    bb = json.load(open(SCR + r'\bom_blocks.json', encoding='utf-8'))['ok']
    have = {b['pn'][0] for b in blocks}
    blocks += [b for b in bb if b['pn'][0] not in have]
    print('BOM 두께 %d개 합침 -> 총 콜아웃/BOM 블록 %d개' % (len(bb), len(blocks)))
# --vlm: 단품도를 그림으로 직접 읽어(VLM) 확정한 두께도 합친다.
# 텍스트 파싱이 도번줄과 두께줄을 묶지 못했을 뿐, 도면에는 원래 적혀 있던 값이다.
# 형강(채널/각파이프)은 pass5c 단계에서 이미 빠져 있다 — 평판 압출 가정이 맞지 않기 때문.
if '--vlm' in sys.argv:
    vb = json.load(open(SCR + r'\vlm_blocks.json', encoding='utf-8'))['ok']
    have = {b['pn'][0] for b in blocks}
    blocks += [b for b in vb if b['pn'][0] not in have]
    print('VLM 판독 두께 %d개 합침 -> 총 블록 %d개' % (len(vb), len(blocks)))

# ---------- CEO 확정 두께 (product.THK_CEO) — '압출 두께'에만 적용한다 ----------
# 도면에 취소선 정정이 있는 부재는 글자만으로 최종 두께를 가릴 수 없다. CEO 가 원도면을 보고
# 확정한 값은 어떤 판독(콜아웃/BOM/VLM)보다 우선한다. 형상(외곽/홀)은 전혀 손대지 않고
# 압출 높이만 바뀐다. 근거 문구는 그대로 manifest note 에 남는다.
#
# 왜 두 값으로 나누는가(순서 결함 교정): 예전에는 여기서 b['thk'] 를 통째로 덮어썼다.
# 그런데 그 값은 압출뿐 아니라 '형상 후보 고르기'(측면도 배제)에도 쓰인다. 도면에 그려진
# 측면도 띠의 폭은 어디까지나 '도면에 적힌 옛 두께'이므로, CEO 정정폭이 크면 띠를 못 알아본다 —
# RJ102-0591 은 확정 45T 인데 도면 띠는 40 폭이라 |40-45|=5 > 허용오차 1.0 이 되어
# 측면도 배제 규칙이 아예 발동하지 않았다(실측).
# 그래서 '도면이 그렇게 그려져 있다'는 사실(thk, 매칭용)과 '최종 제작 두께'(thk_out, 압출용)를
# 분리한다. THK_CEO 항목이 없는 도번은 thk_out == thk 라 어느 제품에서도 동작이 바뀌지 않고,
# 항목이 있는 도번도 압출 결과·manifest 두께는 예전과 완전히 같다(달라지는 건 후보 고르기뿐).
for b in blocks:
    ov = product.THK_CEO.get(b['pn'][0])
    if not ov:
        continue
    old = b['thk'][0] if b['thk'] else None
    if old == ov[0]:
        continue
    b['thk_out'] = ov[0]              # 압출·manifest 에 쓰는 최종 두께(매칭에는 쓰지 않는다)
    b.setdefault('lines', []).append('[CEO확정] %s' % ov[1])
    print('CEO 확정 두께 적용(압출 두께만): %s 도면 %s -> 제작 %.1fT (%s)'
          % (b['pn'][0], old, ov[0], ov[1]))

# ---------- 전체 세그먼트 ----------
segs = []
for l in d2['lines']:
    segs.append({'p0': (l[0], l[1]), 'p1': (l[2], l[3]), 'kind': 'L'})
for a in d2['arcs']:
    cx, cy, r, s, e = a[:5]
    segs.append({'p0': (cx + r*math.cos(math.radians(s)), cy + r*math.sin(math.radians(s))),
                 'p1': (cx + r*math.cos(math.radians(e)), cy + r*math.sin(math.radians(e))),
                 'kind': 'A', 'arc': (cx, cy, r, s, e)})

# ---------- 끝점 격자 해싱 + union-find ----------
node = {}
def nid(p):
    k = (round(p[0]/TOL), round(p[1]/TOL))
    for dx in (-1, 0, 1):
        for dy in (-1, 0, 1):
            kk = (k[0]+dx, k[1]+dy)
            if kk in node: return node[kk]
    v = len(node); node[k] = v; return v

par = {}
def find(x):
    while par.get(x, x) != x: par[x] = par.get(par[x], par[x]); x = par[x]
    return x
def uni(a, b):
    ra, rb = find(a), find(b)
    if ra != rb: par[ra] = rb

for s in segs:
    a, b = nid(s['p0']), nid(s['p1'])
    par.setdefault(a, a); par.setdefault(b, b)
    s['a'], s['b'] = a, b
    uni(a, b)

comp = collections.defaultdict(list)
for i, s in enumerate(segs):
    comp[find(s['a'])].append(i)

# 덩어리별 bbox
comps = []
comps_small = []        # 2단 탐색 전용(1단에서 아무것도 못 찾은 도번만 본다)
for root, idxs in comp.items():
    if not (4 <= len(idxs) <= 800): continue
    # 덩어리 bbox 는 '실제로 그려지는 점'으로 재야 한다.
    # 예전 판은 세그먼트 끝점만 모았기 때문에 원호(ARC)의 불룩한 부분이 bbox 밖으로 빠졌다.
    # 그 결과 돔형 부재(0031-004)나 D형 부재(0031-001/002)는 outline() 이 뽑은 '올바른'
    # 외곽이 덩어리 bbox 보다 크게 나와 fit 게이트에서 20~48% 불일치로 오탈락했다.
    # 숫자(6%)를 늘리는 튜닝이 아니라, 원호를 점으로 펴서 재는 것이 원인 교정이다.
    _pp = []
    for i in idxs:
        s = segs[i]
        if s['kind'] == 'A':
            cx, cy, r, a0, a1 = s['arc']
            if a1 <= a0: a1 += 360
            t = np.radians(np.linspace(a0, a1, max(6, int(abs(a1 - a0) / 4))))
            _pp.extend(zip(cx + r * np.cos(t), cy + r * np.sin(t)))
        else:
            _pp.extend((s['p0'], s['p1']))
    pts = np.array(_pp, float)
    x0, y0 = pts.min(0); x1, y1 = pts.max(0)
    w, h = x1-x0, y1-y0
    # 크기 필터. 짧은 변 하한이 30 '초과' 였던 탓에 짧은 변이 정확히 30mm 인 실제 부재가
    # 통째로 후보에서 빠졌다(0004-017 = 130 x 30 x 9T 쐐기 브래킷). 숫자를 맞추려는 튜닝이
    # 아니라, 30mm 폭 브래킷은 이 도면에 실제로 존재하는 부재라서 하한을 20mm 로 내린다.
    # 긴 변 하한이 100mm 였던 탓에 90 x 90 짜리 실제 부재가 후보에서 통째로 빠졌다
    # (0031-012 = 90 x 90 x 6T 삼각 거셋, BOM 15번 SS275 PL6). 도면에 실재하는 부재라
    # 하한을 80mm 로 내린다 — 임계값 맞추기가 아니라 부재 크기 분포에 맞추는 것.
    # ---- 2단 탐색: 1단은 이 하한(80/20) 그대로, 2단은 더 작은 덩어리를 따로 모아 둔다 ----
    # 왜 나눠 담나: 하한을 그냥 낮췄더니(실측) 치수보조선 조각 같은 작은 덩어리가 후보에
    # 대량 유입돼 콜아웃에 더 가깝다는 이유로 소유권을 먼저 차지했고, 검증완료 94개 중
    # 30개가 통째로 사라지고 2개가 27x28 로 붕괴했다. 문제는 하한 값이 아니라
    # '작은 후보와 큰 후보를 같은 줄에 세우는 것'이었다.
    # 그래서 작은 덩어리는 comps 에 넣지 않고 comps_small 로 따로 둔다. 1단 탐색의 후보
    # 집합은 예전과 완전히 동일하고, 1단에서 아무것도 못 찾은 도번만 2단에서 이걸 추가로 본다.
    _big = (80 < max(w, h) < 4000 and min(w, h) >= 20)
    _small = (not _big and product.COMP_LONG_MIN < max(w, h) < 4000
              and min(w, h) >= product.COMP_SHORT_MIN)
    if not (_big or _small): continue
    narc = sum(1 for i in idxs if segs[i]['kind'] == 'A')
    (comps if _big else comps_small).append(
        dict(root=root, idxs=idxs, bbox=(x0, y0, x1, y1), w=w, h=h, narc=narc))
print('부재 후보 덩어리: %d개 (2단 탐색용 소형 덩어리 %d개 별도 보관)'
      % (len(comps), len(comps_small)))

def dist_to_box(px, py, bb):
    x0, y0, x1, y1 = bb
    dx = max(x0-px, 0, px-x1); dy = max(y0-py, 0, py-y1)
    return math.hypot(dx, dy)


# ---------------- 치수 게이트 (product.DIM_GATE 를 켠 제품에서만 동작) ----------------
# 왜 필요한가: 어떤 도면은 부품표·표제란 칸이 '빈 사각형'으로 대량 그려져 있다. 글자가
# 없으므로 위의 도면틀 게이트를 그냥 통과해 표 칸이 부재로 둔갑한다(D800 표본 7개 중 5개).
# 판정 근거: 단품도의 부재 뷰 곁에는 그 부재의 치수 숫자가 적혀 있다(가로 585 인 부재
# 곁에는 '585' 가 실제로 있다). 표 칸은 칸 크기를 적어두지 않는다.
# 임계값 맞추기가 아니라 도면이 그렇게 그려져 있다는 사실에 근거한 검사다.
# 기본은 꺼짐이라 500PRO·400Q 는 이 함수를 아예 타지 않는다(동작 100% 동일).
_NUMPAT = re.compile(r'\d{1,5}(?:\.\d{1,2})?')
_DIM_CELL = 1000.0          # 텍스트 격자 한 칸(근처 검색 속도용 — 판정값이 아니다)
_dim_grid = None


def _dim_build():
    """텍스트를 격자에 담고 숫자값을 미리 뽑아 둔다(25만개를 후보마다 훑지 않기 위함)."""
    global _dim_grid
    _dim_grid = collections.defaultdict(list)
    for t in d1['texts']:
        vals = [float(m.group()) for m in _NUMPAT.finditer(_clean_txt(t[4]))]
        if vals:
            _dim_grid[(int(t[2] // _DIM_CELL), int(t[3] // _DIM_CELL))].append((t[2], t[3], vals))


def has_own_dims(bb, w, h):
    """부재 bbox 곁(바깥 여유 띠 포함)에 자기 가로·세로 값이 숫자로 적혀 있는가."""
    if _dim_grid is None:
        _dim_build()
    x0, y0, x1, y1 = bb
    mg = max(60.0, 0.5 * max(w, h))     # 치수선은 형상 바깥에 붙는다 — 그만큼 넓게 본다
    vals = []
    for gx in range(int((x0-mg)//_DIM_CELL), int((x1+mg)//_DIM_CELL)+1):
        for gy in range(int((y0-mg)//_DIM_CELL), int((y1+mg)//_DIM_CELL)+1):
            for tx, ty, vv in _dim_grid.get((gx, gy), ()):
                if x0-mg <= tx <= x1+mg and y0-mg <= ty <= y1+mg:
                    vals.extend(vv)

    def hit(v):
        return any(abs(u - v) <= max(1.0, v * 0.01) for u in vals)
    return (hit(w) and hit(h)), len(vals)


# ---------------- 치수 표기 글자 판별 (검증2 셈에서 제외할 대상) ----------------
# 단품도 정면도 안에 정상적으로 들어오는 글자는 그 부재의 치수다. 실측된 탈락 사유
# "R, 60, 2-" (RJ102-0595) 와 "110, R, 30" (RJ102-0356) 이 정확히 이 꼴이다.
# 도면 치수는 조각으로 잘려 색인되기도 한다("2-", "R" 단독) — 그것까지 포함한다.
# 표제란 글자(TITLE·SPEC.·SM490A·도번·날짜 등)는 여기 하나도 걸리지 않으므로
# 도면틀 배제 능력은 줄지 않는다.
_DIM_TXT = re.compile(r"""^(?:
      \d+(?:\.\d+)?                              # 585, 12.5  (순수 숫자)
    | \(\s*\d+(?:\.\d+)?\s*\)                    # (70)  참고치수
    | [RCTØø⌀]\s*\d+(?:\.\d+)?                   # R3, C5, T6, Ø10
    | (?:PHI|phi|파이)\s*\d+(?:\.\d+)?           # PHI10
    | \d+\s*-\s*(?:[RCØø⌀]\s*)?\d*(?:\.\d+)?     # 2-R3, 4-C5, 4-10, "2-"(잘린 조각)
    | [RCØø⌀]                                    # 단독 R / C / Ø (잘린 조각)
    | [+±-]\s*\d+(?:\.\d+)?                 # +0.5, -0.2, ±0.1 (공차)
    | \d+(?:\.\d+)?\s*[°˚]                       # 45°
    | [A-Z]                                      # A, B (단면 기호)
    | \d+\s*[xX*]\s*\d+(?:\.\d+)?                # 10x20 (구멍 표기)
)$""", re.X)


results_pre = []   # 후보조차 못 찾은 도번의 건너뜀 기록

# ---------- 도번별 후보 덩어리 목록 ----------
# (예전 판은 '가장 가까운 덩어리 1개'만 보고, 그게 도면틀로 판정되면 그대로 포기했다.
#  실제로는 도면틀이 부재보다 콜아웃에 가까운 경우가 많아 진짜 부재를 놓쳤다.
#  이제 가까운 순으로 후보를 여러 개 들고 가서, 게이트를 통과하는 첫 덩어리를 채택한다.
#  게이트 자체는 그대로다 — 기준을 늦춘 게 아니라 후보를 더 보는 것뿐.)
CAND_N = 8          # 콜아웃당 검사할 최대 후보 수
CAND_MM = 1200      # 콜아웃에서 이 거리 안의 덩어리만 후보

tasks = []
for b in blocks:
    pn, thk = b['pn'][0], b['thk'][0]
    if ONLY and pn not in ONLY: continue
    px, py = b['at']
    near = [(dd, ci) for dd, ci in
            sorted(((dist_to_box(px, py, c['bbox']), ci) for ci, c in enumerate(comps)))
            if dd <= CAND_MM][:CAND_N]
    # 두께 근거 원문(콜아웃 블록의 텍스트 줄) — manifest note 에 그대로 남긴다
    ev = ' / '.join(b.get('lines', []))
    if not near:
        print('  [SKIP] %s %sT — 콜아웃 근처(%dmm)에 부재 덩어리 없음' % (pn, thk, CAND_MM))
        results_pre.append(dict(pn=pn, status='skip', thk=thk, evidence=ev,
                                why='콜아웃 근처 %dmm 안에 부재 덩어리 없음' % CAND_MM))
        continue
    # 복잡한 형상 우선(CEO 지시): 아크·홀·세그먼트가 많은 부재를 먼저 압출한다
    c0 = comps[near[0][1]]
    cx0, cy0, cx1, cy1 = c0['bbox']
    ncirc0 = sum(1 for cc in d1['circles'] if cx0 <= cc[2] <= cx1 and cy0 <= cc[3] <= cy1)
    score = c0['narc'] * 3 + ncirc0 * 3 + len(c0['idxs'])
    print('  %s %sT -> 후보 %d개, 최근접 %.0f x %.0f mm (arc=%d circ=%d seg=%d, 복잡도 %d)'
          % (pn, thk, len(near), c0['w'], c0['h'], c0['narc'], ncirc0, len(c0['idxs']), score))
    # thk = 도면에 적힌 두께(측면도 배제 등 '후보 고르기'용)
    # thk_out = 최종 제작 두께(압출·manifest용). CEO 정정이 없으면 둘이 같다.
    tasks.append(dict(pn=pn, thk=thk, thk_out=b.get('thk_out', thk),
                      near=near, ev=ev, score=score))

tasks.sort(key=lambda t: -t['score'])
print('\n압출 순서(복잡한 형상 우선): %s' % ', '.join('%s(%d)' % (t['pn'], t['score']) for t in tasks))

if DRY:
    sys.exit(0)

# ---------- build_part.py 와 동일한 윤곽 추출 ----------
def seg_pts(s, frm):
    if s['kind'] == 'L':
        p = [s['p0'], s['p1']]
    else:
        cx, cy, r, a0, a1 = s['arc']
        if a1 <= a0: a1 += 360
        n = max(6, int(abs(a1-a0)/4))
        t = np.radians(np.linspace(a0, a1, n))
        p = list(zip(cx + r*np.cos(t), cy + r*np.sin(t)))
    if frm == s['b']: p = p[::-1]
    return p

def area(p):
    a = np.array(p); x, y = a[:, 0], a[:, 1]
    return 0.5*abs(np.dot(x, np.roll(y, 1)) - np.dot(y, np.roll(x, 1)))

SPLIT_STAT = {'split': 0, 'dup': 0, 'turn': 0}   # 회귀 검증용 계수기(전처리가 실제로 개입한 횟수)


def _prep(sub):
    """루프 탐색 전처리 — 두 가지만 한다(숫자 튜닝 없음, 기존 TOL 재사용).
    (1) 다른 선분 위에 완전히 겹쳐 놓인 짧은 선분 제거(같은 자리에 두 변이 생기는 것 방지)
    (2) T자 교차(선 끝점이 다른 선분의 중간에 닿음)에서 그 선분을 쪼갠다.
    왜 필요한가: 0031-003 처럼 10mm 단차(2-C10 모따기) 표기선이 외곽선 중간에 T자로 닿으면,
    끝점만 맞춰보는 기존 방식은 그 접점을 '연결'로 보지 못해 차수1 가지치기가 연쇄로 일어나
    18개 중 14개가 지워졌다. 접점을 실제 절점으로 만들어 주는 것이 원인 교정이다."""
    sub = [dict(s) for s in sub]          # 원본 segs dict 를 건드리지 않는다
    lines = [(k, s) for k, s in enumerate(sub) if s['kind'] == 'L']

    def _foot(px, py, t):
        ax, ay = t['p0']; bx, by = t['p1']
        dx, dy = bx-ax, by-ay
        L2 = dx*dx + dy*dy
        if L2 < 1e-9: return None
        u = ((px-ax)*dx + (py-ay)*dy) / L2
        if not (0.0 < u < 1.0): return None
        qx, qy = ax + u*dx, ay + u*dy
        if math.hypot(px-qx, py-qy) > TOL: return None
        L = math.sqrt(L2)
        if min(u*L, (1-u)*L) <= TOL: return None      # 끝점과 사실상 같은 자리면 쪼갤 것 없음
        return u

    # (1) 다른 선분 위에 통째로 얹힌 선분 제거
    drop = set()
    for k, s in lines:
        for j, t in lines:
            if j == k or j in drop or k in drop: continue
            if _foot(*s['p0'], t) is None and not any(
                    math.dist(s['p0'], t[e]) <= TOL for e in ('p0', 'p1')): continue
            if _foot(*s['p1'], t) is None and not any(
                    math.dist(s['p1'], t[e]) <= TOL for e in ('p0', 'p1')): continue
            if math.dist(s['p0'], s['p1']) < math.dist(t['p0'], t['p1']) - TOL:
                drop.add(k); SPLIT_STAT['dup'] += 1; break
    sub = [s for k, s in enumerate(sub) if k not in drop]

    # (2) T자 교차점에서 분할
    pts_all = [p for s in sub for p in (s['p0'], s['p1'])]
    out = []
    for s in sub:
        if s['kind'] != 'L':
            out.append(s); continue
        us = sorted({round(u, 9) for u in
                     (_foot(px, py, s) for px, py in pts_all) if u is not None})
        if not us:
            out.append(s); continue
        ax, ay = s['p0']; bx, by = s['p1']
        prev = 0.0
        for u in us + [1.0]:
            q0 = (ax + prev*(bx-ax), ay + prev*(by-ay))
            q1 = (ax + u*(bx-ax), ay + u*(by-ay))
            out.append({'p0': q0, 'p1': q1, 'kind': 'L'})
            prev = u
        SPLIT_STAT['split'] += len(us)
    return out


def _renode(sub):
    """분할 후 절점 번호를 다시 매긴다(격자 해싱은 전역과 동일, TOL 그대로)."""
    nd = {}
    def _nid(p):
        k = (round(p[0]/TOL), round(p[1]/TOL))
        for dx in (-1, 0, 1):
            for dy in (-1, 0, 1):
                kk = (k[0]+dx, k[1]+dy)
                if kk in nd: return nd[kk]
        v = len(nd); nd[k] = v; return v
    for s in sub:
        s['a'] = _nid(s['p0']); s['b'] = _nid(s['p1'])
    return sub


def _ang(s, frm):
    """절점 frm 에서 세그먼트 s 가 뻗어나가는 방향각(원호는 첫 한 걸음의 접선)."""
    p = seg_pts(s, frm)
    return math.atan2(p[1][1]-p[0][1], p[1][0]-p[0][0])


def outline(sub):
    """sub: 세그먼트 dict 리스트 → (외곽 점열, 내부컷 리스트)"""
    sub = _renode(_prep(sub))
    alive = [True]*len(sub)
    while True:
        deg = collections.Counter()
        for i, s in enumerate(sub):
            if alive[i]: deg[s['a']] += 1; deg[s['b']] += 1
        drop = [i for i, s in enumerate(sub) if alive[i] and (deg[s['a']] < 2 or deg[s['b']] < 2)]
        if not drop: break
        for i in drop: alive[i] = False
    sub = [s for i, s in enumerate(sub) if alive[i]]
    if len(sub) < 3: return None, None, 0
    adj = collections.defaultdict(list)
    for i, s in enumerate(sub):
        adj[s['a']].append((i, s['b'])); adj[s['b']].append((i, s['a']))
    # 갈림길(차수3 이상)에서 어느 쪽으로 가느냐 — 예전에는 목록의 첫 번째(nxt[0])를 그냥 골랐다.
    # 그 순서는 도면 안의 선분 저장 순서일 뿐이라, 단차 표기선 쪽으로 잘못 꺾여 외곽을 놓쳤다.
    # 대신 '들어온 방향에서 반시계로 가장 먼저 만나는 변'을 고른다(평면 그래프의 바깥면 추적).
    # 차수2 절점에서는 후보가 하나뿐이라 예전 동작과 완전히 같다.
    def pick(cur, i_in, cand):
        if i_in is None:
            a_in = -math.pi/2          # 최하단·최좌측 절점에서 아래(바깥)를 보고 출발
        else:
            a_in = _ang(sub[i_in], cur)
        best = min(cand, key=lambda io: (_ang(sub[io[0]], cur) - a_in) % (2*math.pi))
        if best != cand[0]: SPLIT_STAT['turn'] += 1
        return best

    # 바깥 윤곽부터 뽑기 위해 최하단·최좌측 절점에서 시작한다.
    npos = {}
    for s in sub:
        npos.setdefault(s['a'], s['p0']); npos.setdefault(s['b'], s['p1'])
    seed_n = min(npos, key=lambda n: (npos[n][1], npos[n][0]))
    order = [(seed_n, None)] + [(sub[i]['a'], i) for i in range(len(sub))]

    used_s = set(); loops = []
    for n0, start in order:
        if start is not None and start in used_s: continue
        if start is None:
            cand = [(i, o) for i, o in adj[n0]]
            if not cand: continue
            start, _o = pick(n0, None, cand)
        cyc = [start]; used_s.add(start)
        cur = sub[start]['b'] if sub[start]['a'] == n0 else sub[start]['a']
        ok = False
        while True:
            nxt = [(i, o) for i, o in adj[cur] if i not in used_s]
            if not nxt:
                if any(i == start for i, o in adj[cur]) or cur == n0: ok = True
                break
            i, o = pick(cur, cyc[-1], nxt); used_s.add(i); cyc.append(i); cur = o
            if cur == n0: ok = True; break
        if ok and len(cyc) >= 3:
            pts = []; nd = n0
            for i in cyc:
                pp = seg_pts(sub[i], nd); pts.extend(pp[:-1])
                nd = sub[i]['b'] if nd == sub[i]['a'] else sub[i]['a']
            if len(pts) >= 3: loops.append(pts)
    if not loops: return None, None, len(sub)
    loops.sort(key=area, reverse=True)
    return loops[0], loops[1:], len(sub)

from matplotlib.path import Path
import cadquery as cq
import trimesh

import os
results = list(results_pre)
used = set()

# 이미 확정된 부재의 가로·세로 (도번 -> (w, h)).
# '내 뷰 안에 있는 이 닫힌 선이 내 홈인가, 겹쳐 그린 옆 부재의 외곽선인가' 를 가르는 근거로 쓴다.
_built_size = {}
try:
    for _it in json.load(open(product.MANIFEST, encoding='utf-8'))['items']:
        _e = _it.get('size_mm')
        if _e:
            _built_size[_it['name'].split('/')[-1].strip()] = (_e[0], _e[1])
except (OSError, ValueError, KeyError):
    pass


def build_one(pn, thk, ci):
    """덩어리 하나를 부재로 압출 시도. 성공하면 (True, 결과dict), 실패하면 (False, 사유)."""
    global _slot_last
    _slot_last = set()   # 이번 시도에서 내부 홈으로 회수한 별개 덩어리 번호
    c = comps[ci]
    outer, rest, nsub = outline([segs[i] for i in c['idxs']])
    if outer is None:
        return False, '닫힌 외곽 루프 없음(남은 seg=%d)' % nsub
    oa = np.array(outer)
    ow_, oh_ = float(np.ptp(oa[:, 0])), float(np.ptp(oa[:, 1]))
    # 검증: 외곽 루프가 덩어리 bbox와 대체로 일치해야 한다(엉뚱한 내부 루프를 외곽으로 잡는 것 차단)
    fit = max(abs(ow_-c['w'])/c['w'], abs(oh_-c['h'])/c['h'])
    print('  외곽 %d점, %.1f x %.1f mm (덩어리 %.1f x %.1f, 오차 %.1f%%)'
          % (len(outer), ow_, oh_, c['w'], c['h'], fit*100))
    if fit > 0.06:
        return False, '외곽/덩어리 bbox %.0f%% 불일치(윤곽 오인식 의심)' % (fit*100)

    # 검증2: 부재 외곽 안에는 글자가 없어야 한다.
    # (덩어리가 4개 선분짜리 사각형이면 bbox 검사가 자동 통과하므로, 도면틀·표제란·상세도
    #  경계 사각형이 '부재'로 둔갑하는 것을 이 검사로 걸러낸다.)
    x0, y0, x1, y1 = c['bbox']
    # 판정 기준을 '글자가 하나라도 있으면 도면틀'에서 '도면틀의 증거가 있으면 도면틀'로 바꾼다.
    # 이유(형상적 근거): 도면틀·표제란 사각형은 반드시 도번(2110DN-xxxx)·시트번호(4/6)·FRAME
    # 같은 표제란 글자를 품고 있고, 글자 수도 여러 개다. 반면 진짜 부재 뷰 안에도 '배수구'
    # 같은 지시 라벨 한 개는 정상적으로 들어온다(0004-011 이 이것 때문에 통째로 탈락했다).
    # 서식코드만 들어있는 빈 글자는 애초에 글자로 세지 않는다.
    inside_txt = [_clean_txt(t[4]) for t in d1['texts'] if x0 <= t[2] <= x1 and y0 <= t[3] <= y1]
    inside_txt = [s for s in inside_txt if s]
    frame_ev = [s for s in inside_txt if _FRAME_PAT.search(s)]
    # 치수 표기는 '글자 수'로 세지 않는다(product.DIM_TEXT_NOT_FRAME 를 켠 제품에서만).
    # 표제란 증거(frame_ev)는 위에서 '거르지 않은 원본'으로 이미 뽑았으므로 보호가 그대로다.
    if product.DIM_TEXT_NOT_FRAME:
        _n0 = len(inside_txt)
        inside_txt = [s for s in inside_txt if not _DIM_TXT.match(s)]
        if _n0 != len(inside_txt):
            print('  (검증2: 외곽 안 글자 %d개 중 치수 표기 %d개는 세지 않음 -> %d개)'
                  % (_n0, _n0 - len(inside_txt), len(inside_txt)))
    if frame_ev or len(inside_txt) >= 3:
        return False, ('외곽 안 글자 %d개(표제란 증거 %d개) — 도면틀/표제란 경계로 판정(%s)'
                       % (len(inside_txt), len(frame_ev),
                          ', '.join(s[:20] for s in (frame_ev or inside_txt)[:3])))

    opath = Path(oa)
    inner = [l for l in rest if area(l) > 100 and opath.contains_points(np.array(l)).all()]
    # ---- 별개 덩어리로 떨어져 있는 내부 홈(슬롯) 회수 ----
    # 사각/장공 홈은 외곽선과 끝점을 공유하지 않으므로 끝점 union-find 에서 '다른 덩어리'가
    # 된다. 즉 outline() 은 그 홈을 애초에 본 적이 없어 홈 없는 통판이 나온다(0003-002 사례).
    # 외곽 안에 완전히 들어있는 다른 덩어리를 찾아 그 닫힌 루프를 내부컷으로 회수한다.
    for cj, c2 in enumerate(comps):
        if cj == ci:
            continue
        bx0, by0, bx1, by1 = c2['bbox']
        if not (x0 <= bx0 and by0 >= y0 and bx1 <= x1 and by1 <= y1):
            continue
        # 이미 다른 부재의 '몸통'으로 채택된 덩어리는 내 '홈'이 될 수 없다.
        # 왜 필요한가: 도면에는 두 부재를 동심으로 겹쳐 그려 놓은 자리가 있다.
        # (RB502-0038SW 200x300 안에 RD102-0073SW 188x288 이 겹쳐 그려져 있다.)
        # 이 검사가 없으면 이웃 부재의 외곽선을 자기 홈으로 회수해 폭 6mm 짜리
        # 테두리 띠만 남은 엉뚱한 부재가 나온다. 한 덩어리는 한 부재의 몸통이거나
        # 다른 부재의 홈이거나 둘 중 하나지 둘 다일 수 없다.
        if cj in used:
            print('  내부 별개덩어리 %.0fx%.0fmm 회수 안 함 — 이미 다른 부재의 몸통으로 채택된 덩어리'
                  % (bx1 - bx0, by1 - by0))
            continue
        # 소유권 게이트 — 부재 덩어리 선택에 쓰는 것과 같은 검사를 홈 회수에도 적용한다.
        # 왜 필요한가: 도면에는 두 부재를 동심으로 겹쳐 그려 놓은 자리가 있다.
        # (RB502-0038SW 200x300 안에 RD102-0073SW 188x288 이 겹쳐 그려져 있고 두 콜아웃의
        #  x 좌표 차이가 1.8mm 다.) 이 검사가 없으면 이웃 부재의 '외곽선'을 자기 '홈'으로
        # 회수해 폭 6mm 짜리 테두리 띠만 남은 엉뚱한 부재가 나온다.
        # 홈은 자기 부재의 일부이므로, 그 덩어리에 가장 가까운 콜아웃도 자기 도번이어야 한다.
        owner2 = min(((dist_to_box(b2['at'][0], b2['at'][1], c2['bbox']), b2['pn'][0])
                      for b2 in blocks))
        if owner2[1] != pn:
            print('  내부 별개덩어리 %.0fx%.0fmm 회수 안 함 — 더 가까운 콜아웃이 %s(%.0fmm)'
                  ' 라서 이웃 부재의 외곽선으로 판정'
                  % (bx1 - bx0, by1 - by0, owner2[1], owner2[0]))
            continue
        o2, rest2, _ = outline([segs[i] for i in c2['idxs']])
        for l in ([o2] if o2 else []) + (rest2 or []):
            if area(l) > 100 and opath.contains_points(np.array(l)).all():
                inner.append(l)
                _slot_last.add(cj)
    # ---- 겹쳐 그린 이웃 부재의 외곽선을 내 홈으로 오인하지 않는다 ----
    # 도면에는 두 부재를 겹쳐 그려 놓은 자리가 있다. RB502-0038SW(200x300 9T) 뷰 안에는
    # RD102-0073SW(188x288 9T)의 외곽선이 사방 6mm 안쪽에 그대로 겹쳐 그려져 있다.
    # 그 선을 홈으로 파면 폭 6mm 짜리 테두리 띠만 남은 엉뚱한 부재가 나온다.
    # 판정 근거는 임계값이 아니라 '그 치수를 가진 다른 부재가 실제로 존재한다'는 사실이다
    # (이미 만든 부재의 확정 크기와 가로·세로가 0.5mm 안에서 같으면 그것은 홈이 아니다).
    #
    # 다만 '같은 치수' 하나만으로는 부족하다. 다른 부재를 끼우려고 그 부재와 같은 크기로
    # 뚫은 창(커버판의 개구부 등)은 실제 설계에 흔하다. 실제로 이 도면에도 가로·세로가
    # 같은 부재 쌍이 7쌍 있다. 그래서 '그 부재가 바로 이 자리에 그려져 있다'는 증거 —
    # 그 도번의 콜아웃이 이 루프에서 후보 탐색 반경(CAND_MM) 안에 있다 — 까지 함께 요구한다.
    # (콜아웃 글자는 보통 형상 바깥 아래에 붙으므로 'bbox 안' 을 요구하면 실제 겹침도 놓친다.
    #  RD102-0073SW 콜아웃은 RB502-0038SW 외곽 밖 약 200mm 지점에 있다.)
    _pn_at = collections.defaultdict(list)
    for b2 in blocks:
        _pn_at[b2['pn'][0]].append(b2['at'])
    _keep = []
    for l in inner:
        _a = np.array(l)
        _w, _h = float(np.ptp(_a[:, 0])), float(np.ptp(_a[:, 1]))
        _other = next((p2 for p2, (w2, h2) in _built_size.items()
                       if p2 != pn and abs(w2 - _w) < 0.5 and abs(h2 - _h) < 0.5
                       and any(dist_to_box(ax, ay, (float(_a[:, 0].min()), float(_a[:, 1].min()),
                                                    float(_a[:, 0].max()), float(_a[:, 1].max())))
                               <= CAND_MM for ax, ay in _pn_at.get(p2, ()))), None)
        if _other:
            print('  내부컷 %.1fx%.1fmm 은 홈이 아님 — 같은 치수의 다른 부재 %s 가 실재한다'
                  '(겹쳐 그린 이웃 부재의 외곽선으로 판정)' % (_w, _h, _other))
            continue
        _keep.append(l)
    inner = _keep
    holes = []
    for _, _, cx, cy, r in d1['circles']:
        x0, y0, x1, y1 = c['bbox']
        if not (x0 <= cx <= x1 and y0 <= cy <= y1): continue
        t = np.radians(np.linspace(0, 360, 48, endpoint=False))
        ring = np.c_[cx + r*np.cos(t), cy + r*np.sin(t)]
        if opath.contains_points(ring).all(): holes.append((cx, cy, r))
    print('  내부컷 %d개, 홀 %d개 (지름 %s)'
          % (len(inner), len(holes), sorted({round(h[2]*2) for h in holes})))

    ox, oy = oa[:, 0].min(), oa[:, 1].min()
    def wire(pts):
        v = [cq.Vector(p[0]-ox, p[1]-oy, 0) for p in pts]
        if (v[0]-v[-1]).Length > 1e-6: v.append(v[0])
        return cq.Wire.makePolygon(v, forConstruction=False)
    try:
        iw = [wire(l) for l in inner]
        iw += [cq.Wire.makeCircle(r, cq.Vector(cx-ox, cy-oy, 0), cq.Vector(0, 0, 1)) for cx, cy, r in holes]
        solid = cq.Solid.extrudeLinear(wire(outer), iw, cq.Vector(0, 0, float(thk)))
        res = cq.Workplane('XY').newObject([solid])
        stl = SCR + ('\\_p_%s.stl' % pn.replace('-', '_'))
        cq.exporters.export(res, stl, tolerance=0.1, angularTolerance=0.1)
        m = trimesh.load(stl)
    except Exception as ex:
        return False, '압출 실패: %s' % str(ex)[:80]

    if not m.is_watertight:
        return False, 'watertight 아님(닫힌 입체가 아니라 OBJ 채택 불가)'

    tail = product.obj_tail(pn)
    fn = product.OBJ_PREFIX + '%s.obj' % tail
    # 기존 파일 덮어쓰기 금지(제1조: 성공한 산출물 보존)
    if os.path.exists(FAB + '\\' + fn):
        return False, '이미 존재하는 파일 %s — 덮어쓰기 금지로 건너뜀' % fn
    m.export(FAB + '\\' + fn)
    ext = [round(float(v), 2) for v in m.extents]
    print('  OBJ -> %s  verts=%d faces=%d watertight=%s bbox=%s'
          % (fn, len(m.vertices), len(m.faces), m.is_watertight, ext))
    return True, dict(pn=pn, status='ok', file=product.FAB_REL+'/'+fn, thk=thk,
                      extents=ext, faces=int(len(m.faces)),
                      watertight=bool(m.is_watertight), nholes=len(holes),
                      ninner=len(inner), narc=comps[ci]['narc'],
                      score=len(comps[ci]['idxs']) + (comps[ci]['narc']+len(holes))*3)


def try_task(pn, thk, thk_out, near):
    """후보를 순서대로 시도한다. 성공하면 (결과dict, 탈락사유목록), 실패하면 (None, 사유목록).

    1단·2단 탐색이 '똑같은 게이트를 똑같은 순서로' 쓰도록 한 곳에 모아 둔 것이다.
    안에 있는 판정 로직은 예전 그대로이며 옮겨 담기만 했다(게이트를 늦추지 않았다)."""
    tried = []
    # ---- 측면도 후순위(product.SIDE_VIEW_DEMOTE 를 켠 제품에서만 동작) ----
    # 단품도에는 같은 부재가 정면도(넓은 판)와 측면도(두께만큼 얇은 띠)로 두 번 그려진다.
    # 측면도 띠가 콜아웃에 더 가까우면 거리순 선택이 띠를 부재로 채택해, 두께 32T 부재가
    # 32x230 짜리 막대로 나온다. '두께 t 인 판의 측면도 폭은 t' 는 투상법의 정의다.
    # 후보를 버리지 않고 순서만 뒤로 민다 — 띠 말고 통과하는 후보가 없으면 예전처럼 띠를 쓴다.
    #
    # [알려진 한계] thk 는 도면 두께다(CEO 정정값 thk_out 과 다를 수 있다). 도면에 그려진
    # 띠 폭은 도면 두께이므로 여기서는 thk 를 쓰는 것이 맞다 — 예전에 CEO 값으로 비교해
    # RJ102-0591(도면 40폭 / 확정 45T)에서 규칙이 발동조차 못 했던 것을 바로잡은 부분이다.
    if product.SIDE_VIEW_DEMOTE:
        _tol = max(1.0, float(thk) * 0.02)      # 두께 판독 오차만큼만 허용

        def _is_side(ci):
            c = comps[ci]
            return abs(min(c['w'], c['h']) - float(thk)) <= _tol

        _side = [(dd, ci) for dd, ci in near if _is_side(ci)]
        if _side and len(_side) < len(near):
            near = [(dd, ci) for dd, ci in near if not _is_side(ci)] + _side
            print('  (측면도 후순위: 폭==두께(%.1fmm)인 띠 %d개를 뒤로 미룸 — 정면도 우선)'
                  % (float(thk), len(_side)))
    for dd, ci in near:
        if ci in used:
            continue
        c = comps[ci]
        print('  후보: %.0f x %.0f mm, 콜아웃거리 %.0fmm, seg=%d arc=%d'
              % (c['w'], c['h'], dd, len(c['idxs']), c['narc']))
        # 소유권 게이트: 이 덩어리에 가장 가까운 콜아웃이 지금 도번이어야 한다.
        # (자기 부재를 못 찾은 도번이 옆 부재를 집어가는 오인식을 막는 필수 검사.)
        owner = min(((dist_to_box(b2['at'][0], b2['at'][1], c['bbox']), b2['pn'][0])
                     for b2 in blocks))
        if owner[1] != pn:
            print('    -> 탈락: 소유권 — 이 덩어리에 더 가까운 콜아웃은 %s(%.0fmm)' % (owner[1], owner[0]))
            tried.append('%.0fx%.0fmm: 소유권 충돌(%s 가 %.0fmm 로 더 가까움)'
                         % (c['w'], c['h'], owner[1], owner[0]))
            continue
        # 치수 게이트: 이 덩어리 곁에 자기 가로·세로 치수가 적혀 있어야 부재로 인정한다.
        if product.DIM_GATE:
            _okdim, _nv = has_own_dims(c['bbox'], c['w'], c['h'])
            if not _okdim:
                print('    -> 탈락: 치수 게이트 — 곁 숫자 %d개 중 자기 치수(%.0f, %.0f)가 없음'
                      ' (표 칸으로 판정)' % (_nv, c['w'], c['h']))
                tried.append('%.0fx%.0fmm: 치수 게이트 탈락(곁숫자 %d개에 자기 치수 없음 — 표 칸)'
                             % (c['w'], c['h'], _nv))
                continue
        ok, out = build_one(pn, thk_out, ci)     # 압출은 최종 제작 두께로
        if ok:
            used.add(ci)
            used.update(_slot_last)   # 홈으로 쓰인 덩어리 예약
            return out, tried
        print('    -> 탈락: %s' % out)
        tried.append('%.0fx%.0fmm: %s' % (c['w'], c['h'], out))
    return None, tried


def near_of(px, py):
    """콜아웃 좌표에서 가까운 순으로 후보 덩어리를 고른다(현재 comps 기준)."""
    return [(dd, ci) for dd, ci in
            sorted(((dist_to_box(px, py, c['bbox']), ci) for ci, c in enumerate(comps)))
            if dd <= CAND_MM][:CAND_N]


# ---------- 복잡한 형상부터, 후보를 순서대로 시도 ----------
for t in tasks:
    pn, thk = t['pn'], t['thk']          # thk = 도면 두께(후보 고르기용)
    thk_out = t['thk_out']               # thk_out = 최종 제작 두께(압출·기록용)
    print('\n===== %s  %sT%s  (복잡도 %d) ====='
          % (pn, thk_out, '' if thk_out == thk else ' (도면 %sT, CEO 확정)' % thk, t['score']))
    # 이미 만들어진 부재는 손대지 않는다(제1조). 그 부재가 쓰던 덩어리도 예약해 두어
    # 다른 도번이 그 덩어리를 가져가지 못하게 한다.
    _fn = product.OBJ_PREFIX + '%s.obj' % (product.obj_tail(pn))
    if os.path.exists(FAB + '\\' + _fn):
        # 기존 OBJ의 실제 크기와 일치하는 덩어리를 찾아 예약한다(맹목적으로 최근접을
        # 예약하면 엉뚱한 덩어리를 묶어버린다).
        try:
            e0 = trimesh.load(FAB + '\\' + _fn).extents
            for dd, ci in t['near']:
                c = comps[ci]
                if abs(c['w']-e0[0])/max(e0[0], 1) < 0.02 and abs(c['h']-e0[1])/max(e0[1], 1) < 0.02:
                    used.add(ci)
                    print('  (기존 부재가 쓰던 덩어리 %.0fx%.0f 예약)' % (c['w'], c['h']))
                    break
        except Exception as ex:
            print('  (기존 OBJ 크기 확인 실패: %s)' % str(ex)[:60])
        print('  [건너뜀] 이미 완료된 부재 %s — 덮어쓰기 금지' % _fn)
        results.append(dict(pn=pn, status='skip', thk=thk_out, evidence=t['ev'],
                            why='이미 완료된 부재(%s) — 덮어쓰기 금지로 재생성 안 함' % _fn))
        continue
    got, tried = try_task(pn, thk, thk_out, t['near'])
    if got:
        got['evidence'] = t['ev']
        results.append(got)
        # 부재 하나 성공할 때마다 즉시 저장 — 중간에 뷰어를 만들 수 있게 한다
        json.dump(results, open(SCR + r'\multi_result.json', 'w', encoding='utf-8'),
                  ensure_ascii=False, indent=1)
        print('  ==> 채택')
    else:
        results.append(dict(pn=pn, status='skip', thk=thk_out, evidence=t['ev'],
                            why='후보 %d개 모두 게이트 탈락 | %s' % (len(tried), ' ;; '.join(tried))))
        print('  ==> 건너뜀(후보 %d개 전부 탈락)' % len(tried))

# ---------- 2단 탐색: 1단에서 아무것도 못 찾은 도번만, 소형 덩어리를 추가로 본다 ----------
# 왜 이 순서인가: 1단이 완전히 끝난 뒤에 시작하므로 1단의 후보 집합·선택 순서·used 예약이
# 전혀 달라지지 않는다. 즉 이미 성공한 부재의 탐색 경로는 원리적으로 보존된다.
# (하한을 그냥 낮추면 작은 덩어리가 소유권을 먼저 차지해 94개 중 30개가 사라졌다 — 실측.)
# comps 뒤에 이어 붙이므로 기존 인덱스(ci)와 used 예약은 그대로 유효하다.
if comps_small:
    _fail = [t for t in tasks
             if not os.path.exists(FAB + '\\' + product.OBJ_PREFIX
                                   + '%s.obj' % product.obj_tail(t['pn']))]
    if _fail:
        print('\n########## 2단 탐색: 1단 미해결 %d건 x 소형 덩어리 %d개 ##########'
              % (len(_fail), len(comps_small)))
        comps.extend(comps_small)
        _done2 = set()
        for t in _fail:
            pn, thk, thk_out = t['pn'], t['thk'], t['thk_out']
            if pn in _done2:
                continue
            b0 = next((b for b in blocks if b['pn'][0] == pn), None)
            if b0 is None:
                continue
            print('\n===== [2단] %s  %sT =====' % (pn, thk_out))
            got, tried = try_task(pn, thk, thk_out, near_of(b0['at'][0], b0['at'][1]))
            if got:
                _done2.add(pn)
                got['evidence'] = t['ev']
                got['tier'] = 2
                # 1단에서 남긴 '전부 탈락' 기록을 성공 기록으로 갈아끼운다.
                results[:] = [r for r in results
                              if not (r['pn'] == pn and r['status'] == 'skip'
                                      and '게이트 탈락' in r.get('why', ''))]
                results.append(got)
                json.dump(results, open(SCR + r'\multi_result.json', 'w', encoding='utf-8'),
                          ensure_ascii=False, indent=1)
                print('  ==> [2단] 채택')
            else:
                print('  ==> [2단] 건너뜀(후보 %d개 전부 탈락)' % len(tried))

json.dump(results, open(SCR + r'\multi_result.json', 'w', encoding='utf-8'), ensure_ascii=False, indent=1)
nok = sum(1 for r in results if r['status'] == 'ok')
print('\n===== 요약: 채택 %d / 건너뜀 %d =====' % (nok, len(results)-nok))
for r in results:
    if r['status'] == 'ok':
        print('  [OK]   %-18s %5sT %s  홀%d  근거: %s'
              % (r['pn'], r['thk'], r['extents'], r['nholes'], r.get('evidence', '')[:50]))
    else:
        print('  [SKIP] %-18s %s' % (r['pn'], r['why'][:110]))
