# -*- coding: utf-8 -*-
"""Pass 9: 형강(ㄷ형강 채널)을 '규격 단면 + 도면 옆면도/평면도 실제 윤곽' 으로 만든다.

왜 별도 패스인가(형상 종류의 문제):
  판재는 도면의 2D 외곽선 = 부재 단면이므로 그대로 압출하면 맞다.
  형강은 도면에 '옆에서 본 모습'만 그려져 있어 그걸 그대로 압출하면 통짜 판이 나온다.
  그래서 단면은 규격 표기(CHANNEL SS275 250*90*T9)에서 만들고,
  길이·끝단 사선컷·노치는 옆면도(높이 250)의 실제 폐곡선에서, 평면 윤곽은 평면도(높이 90)에서 가져온다.

[2026-08-15 재제작 — CEO 결함지적 반영]
  이전 판은 '직선 프리즘'이었다(끝단 사선컷·장공 전부 누락).
  원인: 옆면도를 bbox(가로x세로) 로만 읽어 '길이 숫자' 하나만 쓰고 윤곽을 버렸기 때문.
  수정: 옆면도/평면도의 선분을 폴리곤화(shapely polygonize)해 실제 윤곽을 복원하고,
        세 방향 프리즘의 불리언 교집합으로 몸체를 만든다.
          옆면 윤곽(x-z) 압출  ∩  ㄷ자 단면(y-z) 압출  ∩  평면 윤곽(x-y) 압출
        교집합 방식을 쓰는 이유: 스윕/컷 순서에 의존하지 않아 오목한 노치·양단 사선에 견고하다.
  구멍: 옆면도 안의 내부루프·원 -> 웨브(250면) 관통(Y방향)
        평면도 안의 내부루프·원 -> 플랜지(90면) 관통(Z방향)
  코인시던트 면(겹치는 평면)은 OCC 불리언이 깨지는 주원인이라 모든 프리즘을 부재 밖으로
  ±5mm 여유를 두고 만든다(숫자 맞추기가 아니라 불리언 안정화 목적).
"""
import json, os, re, sys, math
import numpy as np
import cadquery as cq
import trimesh
from shapely.geometry import LineString, Polygon, Point
from shapely.ops import unary_union, polygonize

import product          # 제품 고유값 모음 — 기본값 500pro
from shape_common import (SCR, FAB, load_index, build_segments, components, dist_to_box,
                          callout_positions)

DEPTH_TOL = 3.0      # 옆면도 높이가 단면 춤과 이만큼 안에서 맞아야 그 부재의 옆면도로 본다
CAND_MM = 1500       # 콜아웃에서 이 거리 안의 덩어리만 후보
PAD = 5.0            # 불리언용 프리즘 여유(부재 밖으로 내미는 길이)
BUBBLE_R = 60.0      # 이보다 큰 원은 '상세도 지시원(detail bubble)' 으로 보고 형상에서 제외
MIN_HOLE_A = 25.0    # 이보다 작은 폐곡선은 중심마크·해칭 조각으로 보고 구멍으로 세지 않는다

# 도번 -> (규격원문, 단면종류, 춤 D, 폭 B, 두께 t)   ← 제품별로 완전히 다르므로 product.py 로 옮겼다
SPEC = product.SPEC

# 도번 -> 옆면도 콜아웃 좌표 강제 지정(같은 도번 텍스트가 여러 곳이라 첫 후보가 표제란일 때)
SPEC_AT = product.SPEC_AT


def channel_profile(D, B, t):
    """ㄷ자 단면 (웨브는 y=0~t, 열린 쪽은 +y). 폭 B(웨브->플랜지끝), 춤 D."""
    return [(0, 0), (B, 0), (B, t), (t, t), (t, D-t), (B, D-t), (B, D), (0, D)]


# ---------------------------------------------------------------- 윤곽 추출
def seg_to_ls(s):
    if s['kind'] == 'L':
        return LineString([s['p0'], s['p1']])
    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))
    return LineString(list(zip(cx + r*np.cos(t), cy + r*np.sin(t))))


def prune(seglist):
    """끝이 어디에도 안 붙는 가지(치수 화살표·지시선 꼬리)를 반복해서 걷어낸다.
       build_part/pass6 의 검증된 degree-1 가지치기와 같은 처리다."""
    import collections
    sub = list(seglist)
    while True:
        deg = collections.Counter()
        for s in sub:
            deg[s['a']] += 1; deg[s['b']] += 1
        keep = [s for s in sub if deg[s['a']] >= 2 and deg[s['b']] >= 2]
        if len(keep) == len(sub):
            return sub
        sub = keep
        if not sub:
            return sub


def dissolve(seglist, want=None, eps=7.2):
    """선분 뭉치 -> (외곽 링, 내부 개구부 링들). 면으로 녹여서 뽑는다.

    왜 '가장 큰 닫힌 루프' 방식을 안 쓰나:
      옆면도에는 플랜지 끝선이 부재 전 길이에 걸쳐 안쪽으로 9mm 들어와 그어져 있다.
      그래서 루프 탐색을 하면 '가운데 띠' 가 가장 큰 루프로 잡히고 진짜 외곽을 놓친다.
      선분을 전부 면으로 만들어 합치면(union) 그 플랜지선은 내부 경계라 저절로 사라진다.

    노치 모서리에서는 웨브선과 플랜지선이 서로 만나지 않아 플랜지 두께(9mm) 정도의 틈이
    남아 있다(0004-020). 그래서 폴리곤화가 아예 안 될 때만, 그 틈 크기를 기준으로
    선을 eps(=플랜지 두께 수준) 만큼 부풀려 다리를 놓고 안쪽 면을 되살린다.
    (임계값 튜닝이 아니라, 도면이 그렇게 그려져 있다는 물리적 원인에 대한 처리다.)
    """
    sub = prune(seglist)
    if len(sub) < 3:
        sub = list(seglist)
    lss = [seg_to_ls(s) for s in sub]
    polys = list(polygonize(unary_union(lss)))
    if polys:
        u = unary_union(polys)
        if u.geom_type == 'MultiPolygon':
            u = max(u.geoms, key=lambda p: p.area)
        ok = True
        if want:
            x0, y0, x1, y1 = u.bounds
            ok = (x1-x0) > want[0]*0.97 and (y1-y0) > want[1]*0.97
        if ok:
            return list(u.exterior.coords), [list(r.coords) for r in u.interiors]
    # --- 되살리기: 선을 eps 만큼 부풀려 합친 '리본' 의 안쪽 구멍이 곧 부재 면적이다.
    # 노치처럼 오목하게 파인 부분도 이 방식이면 그대로 살아난다(외곽만 따면 메워져 버린다).
    rib = unary_union([l.buffer(eps) for l in lss])
    if rib.is_empty:
        return None, []
    cands = list(rib.geoms) if rib.geom_type == 'MultiPolygon' else [rib]
    rings = [Polygon(r) for g in cands for r in g.interiors]
    if rings:
        reg = max(rings, key=lambda p: p.area).buffer(eps).simplify(0.6)
        if reg.geom_type == 'MultiPolygon':
            reg = max(reg.geoms, key=lambda p: p.area)
        return list(reg.exterior.coords), [list(r.coords) for r in reg.interiors]
    g = max(cands, key=lambda p: p.area)
    shell = Polygon(g.exterior).buffer(-eps).simplify(0.6)
    if shell.is_empty:
        return None, []
    if shell.geom_type == 'MultiPolygon':
        shell = max(shell.geoms, key=lambda p: p.area)
    return list(shell.exterior.coords), [list(r.coords) for r in shell.interiors]


def ring_wh(ring):
    a = np.array(ring)
    return float(np.ptp(a[:, 0])), float(np.ptp(a[:, 1])), float(a[:, 0].min()), float(a[:, 1].min())


def poly_area(ring):
    try:
        return Polygon(ring).area
    except Exception:
        return 0.0


# ---------------------------------------------------------------- cadquery 도구
def wire3(pts2, to3):
    v = [cq.Vector(*to3(p[0], p[1])) for p in pts2]
    if (v[0]-v[-1]).Length > 1e-7:
        v.append(v[0])
    return cq.Wire.makePolygon(v, forConstruction=False)


def prism(ring, to3, direction, inner=()):
    w = wire3(ring, to3)
    iw = [wire3(r, to3) for r in inner]
    return cq.Workplane('XY').newObject(
        [cq.Solid.extrudeLinear(w, iw, cq.Vector(*direction))])


# XZ 평면(옆면도): 로컬 (x, z) -> 월드 (x, -PAD, z), +Y 로 압출
def _xz(x, z):
    return (x, -PAD, z)


# XY 평면(평면도): 로컬 (x, y) -> 월드 (x, y, -PAD), +Z 로 압출
def _xy(x, y):
    return (x, y, -PAD)


# YZ 평면(단면): 로컬 (y, z) -> 월드 (-PAD, y, z), +X 로 압출
def _yz(y, z):
    return (-PAD, y, z)


def main():
    only = [a for a in sys.argv[1:] if not a.startswith('--')]
    force = '--force' in sys.argv
    debug = '--debug' in sys.argv

    def chk(w, tag):
        if not debug:
            return
        f = SCR + r'\_dbg.stl'
        cq.exporters.export(w, f, tolerance=0.1, angularTolerance=0.1)
        mm = trimesh.load(f)
        print('     [dbg] %-28s watertight=%s faces=%d' % (tag, mm.is_watertight, len(mm.faces)))
    d1, d2 = load_index()
    segs = build_segments(d2)
    comps = components(segs, min_seg=1)
    print('덩어리 %d개' % len(comps))

    bubbles = [(c[2], c[3], c[4]) for c in d1['circles'] if c[4] > BUBBLE_R]
    small_circ = [(c[2], c[3], c[4]) for c in d1['circles'] if c[4] <= BUBBLE_R]

    PN = product.PN
    # 부품표(BOM) 열·사본시트 좌표를 뺀 '단품도 옆' 좌표만 쓴다 (shape_common 에 규칙 일원화).
    _cpos = callout_positions(d1)
    callouts = [(p, x, y) for p, ps in _cpos.items() for x, y in ps]

    results = []
    for pn, (raw, kind, D, B, t) in SPEC.items():
        if only and pn not in only:
            continue
        note_ceo = []
        at = [(x, y) for p, x, y in callouts if p == pn]
        if not at:
            results.append(dict(pn=pn, status='skip', why='도면에서 도번 텍스트를 못 찾음'))
            continue
        # 도번이 적힌 곳이 여러 군데다(부품표 열 + 단품도 옆 + 사본시트). 첫 곳만 보면
        # 400Q 채널 10개가 전부 부품표 옆 '조립도 3331mm' 를 자기 옆면도로 붙잡는다.
        # 그래서 후보 위치를 전부(가까운 순) 시도하고, 높이=단면 춤 게이트를 통과하는
        # 첫 옆면도를 채택한다. 게이트·거리 기준(CAND_MM/DEPTH_TOL)은 하나도 안 바꿨다.
        origins = [SPEC_AT[pn]] if pn in SPEC_AT else at
        pick = None
        for px, py in origins:
            near = sorted(((dist_to_box(px, py, c['bbox']), ci) for ci, c in enumerate(comps)))
            for dd, ci in near:
                if dd > CAND_MM:
                    break
                c = comps[ci]
                if abs(c['h'] - D) > DEPTH_TOL or c['w'] < D * 1.2:
                    continue
                owner = min((dist_to_box(x, y, c['bbox']), p) for p, x, y in callouts)
                if owner[1] != pn:
                    continue
                pick = (dd, ci)
                break
            if pick:
                break
        if not pick:
            results.append(dict(pn=pn, status='skip',
                                why='콜아웃 %dmm 안에서 높이 %.0fmm(단면 춤) 인 옆면도 덩어리를 못 찾음'
                                    % (CAND_MM, D)))
            print('  [SKIP] %s — 옆면도 못 찾음' % pn); continue
        dd, ci = pick
        sc = comps[ci]
        sb = sc['bbox']
        print('\n===== %s =====' % pn)
        print('  옆면도 덩어리 %.1f x %.1f mm (콜아웃 %.0fmm)' % (sc['w'], sc['h'], dd))

        # ---- 2) 옆면 윤곽(실제 폐곡선) ----
        # 외곽선과 끝점을 공유하지 않는 '노치 바닥선' 같은 직선 조각도 윤곽에 합친다.
        # (0004-020 의 690x170 노치가 이것 때문에 통째로 빠져 있었다)
        sside = [segs[i] for i in sc['idxs']]
        merged_in = []
        for cj, c2 in enumerate(comps):
            if cj == ci:
                continue
            b = c2['bbox']
            if not (sb[0] <= b[0] and sb[1] <= b[1] and b[2] <= sb[2] and b[3] <= sb[3]):
                continue
            if min(c2['w'], c2['h']) > 1.0 or max(c2['w'], c2['h']) < 100.0:
                continue          # 직선 조각(길이 100mm 이상)만 — 치수선 토막·글자 배제
            sside += [segs[i] for i in c2['idxs']]
            merged_in.append(cj)
        if merged_in:
            print('  윤곽에 합친 직선 조각 %d개' % len(merged_in))
        souter, sinner = dissolve(sside, want=(sc['w'], sc['h']))
        if souter is None:
            results.append(dict(pn=pn, status='skip', why='옆면도에서 닫힌 윤곽을 못 만듦'))
            print('  [SKIP] 옆면 윤곽 실패'); continue
        sw, sh, sx0, sy0 = ring_wh(souter)
        L = round(sw, 1)
        print('  옆면 윤곽 %d점  %.1f x %.1f mm -> 길이 %.1f, 내부 개구부 %d개'
              % (len(souter), sw, sh, L, len(sinner)))
        if abs(sh - D) > DEPTH_TOL:
            results.append(dict(pn=pn, status='skip',
                                why='옆면 윤곽 높이 %.1f 가 단면 춤 %.0f 와 다름' % (sh, D)))
            continue
        sideP = Polygon(souter)

        # ---- 3) 옆면도 안의 구멍(장공·원) : 웨브 관통 ----
        web_cuts = []   # (링, 설명)
        for r in sinner:
            if poly_area(r) > MIN_HOLE_A:
                web_cuts.append((r, 'A'))
        # 외곽선과 끝점을 공유하지 않는 별개 덩어리(장공)를 회수 — pass6/8 과 같은 이유
        for cj, c2 in enumerate(comps):
            if cj == ci:
                continue
            b = c2['bbox']
            if not (sb[0] <= b[0] and sb[1] <= b[1] and b[2] <= sb[2] and b[3] <= sb[3]):
                continue
            if cj in merged_in:
                continue
            o2, i2 = dissolve([segs[i] for i in c2['idxs']], eps=0.6)
            for r in ([o2] if o2 else []) + list(i2):
                if poly_area(r) > MIN_HOLE_A and sideP.contains(Polygon(r).representative_point()):
                    web_cuts.append((r, 'B'))
        # 원(볼트홀)
        web_circ = []
        for cx, cy, r in small_circ:
            if not (sb[0] <= cx <= sb[2] and sb[1] <= cy <= sb[3]):
                continue
            if sideP.contains(Point(cx, cy)):
                web_circ.append((cx, cy, r))

        # ---- 4) 평면도(높이 = 단면 폭 B) ----
        plan_ring, flg_cuts, flg_circ = None, [], []
        bands = {}
        for cj, c2 in enumerate(comps):
            if abs(c2['h'] - B) > DEPTH_TOL or c2['w'] < sc['w']*0.8 or c2['w'] > sc['w']*1.25:
                continue
            if not (c2['bbox'][0] < sb[2]+60 and c2['bbox'][2] > sb[0]-60):
                continue
            dy = abs(c2['bbox'][1] - sb[1])
            if dy > 2500:
                continue
            bands.setdefault(round(c2['bbox'][1]/50), []).append(cj)
        planP = None
        if bands:
            keys = sorted(bands, key=lambda k: abs(comps[bands[k][0]]['bbox'][1] - sb[1]))
            if len(keys) > 1:
                note_ceo.append('평면도 후보 띠가 %d개(y=%s) — 옆면도에 가장 가까운 띠만 채택했다'
                                % (len(keys), ', '.join('%.0f' % comps[bands[k][0]]['bbox'][1]
                                                        for k in keys)))
            use = bands[keys[0]]
            pseg = [segs[i] for cj in use for i in comps[cj]['idxs']]
            pouter, pinner = dissolve(pseg, want=(max(comps[cj]['w'] for cj in use), B))
            if not pouter:
                note_ceo.append('평면도 덩어리에서 닫힌 윤곽을 못 만듦 — 평면 절단·플랜지 구멍 미반영')
                print('  평면 윤곽 실패')
            # 좌표 등록(길이방향·폭방향 원점)은 평면도 덩어리 bbox 로 한다.
            # 윤곽 폴리곤이 조각나도 구멍 위치는 살릴 수 있어야 하기 때문이다.
            wx0 = min(comps[cj]['bbox'][0] for cj in use)
            wx1 = max(comps[cj]['bbox'][2] for cj in use)
            wy0 = min(comps[cj]['bbox'][1] for cj in use)
            wy1 = max(comps[cj]['bbox'][3] for cj in use)
            pb = (wx0, wy0, wx1, wy1)
            pxo, pyo, ph, pw = wx0, wy0, wy1-wy0, wx1-wx0
            if pouter:
                ow_, oh_, oxo, oyo = ring_wh(pouter)
                fill = poly_area(pouter) / max(ow_*oh_, 1)
                print('  평면 윤곽 %.1f x %.1f mm (충실도 %.2f), 덩어리 %d개 합침 (bbox %.1f x %.1f)'
                      % (ow_, oh_, fill, len(use), pw, ph))
                # 평면 윤곽으로 '자르려면' 그 윤곽이 부재 길이 전체를 덮고 있어야 한다.
                # 폭(길이)만 비교하면 조금 짧은 윤곽이 부재를 잘라먹는다(0004-005 가 4.3mm 짧아졌다).
                covers = (oxo <= sx0 + 0.5) and (oxo + ow_ >= sx0 + sw - 0.5)
                if not covers or fill < 0.5 or abs(oh_ - B) > DEPTH_TOL:
                    note_ceo.append('평면 윤곽(%.1fx%.1f, 충실도 %.2f)이 부재 길이구간을 다 덮지 못함 — '
                                    '평면 방향 절단은 적용하지 않고 구멍 위치만 반영' % (ow_, oh_, fill))
                    pouter = None
                else:
                    pyo = wy0
                    pw, ph = ow_, wy1-wy0
            planP = Polygon(pouter) if pouter else None
            if True:
                # 웨브가 평면도의 어느 쪽 가장자리인가: 가장자리에서 t(9mm) 안쪽에
                # 부재 전길이에 걸쳐 그어진 가로선(웨브 숨은선)이 있는 쪽이 웨브다.
                wy0 = min(comps[cj]['bbox'][1] for cj in use)
                wy1 = max(comps[cj]['bbox'][3] for cj in use)

                def long_h_line_at(yy):
                    # 웨브 숨은선은 평면도 외곽과 별개 덩어리인 경우가 있어 전체 선에서 찾는다
                    return any(q['kind'] == 'L' and abs(q['p0'][1]-yy) < 1.5
                               and abs(q['p1'][1]-yy) < 1.5
                               and pb[0]-1 <= min(q['p0'][0], q['p1'][0])
                               and max(q['p0'][0], q['p1'][0]) <= pb[2]+1
                               and abs(q['p1'][0]-q['p0'][0]) > pw*0.3 for q in segs)
                low = long_h_line_at(wy0 + t)
                high = long_h_line_at(wy1 - t)
                if low and not high:
                    flip = False           # 웨브 = 평면도 아래쪽 -> 로컬 y 그대로
                elif high and not low:
                    flip = True            # 웨브 = 평면도 위쪽 -> 뒤집는다
                else:
                    flip = False
                    note_ceo.append('평면도에서 웨브(250면)가 위/아래 어느 쪽인지 선으로 판별 안 됨'
                                    ' — 아래쪽을 웨브로 가정. 플랜지 구멍의 폭방향 위치가 좌우 반전일 수 있음')
                print('  평면도 웨브쪽 판별: 아래선=%s 위선=%s -> flip=%s' % (low, high, flip))

                def P2L(x, y):
                    """평면도 도면좌표 -> 부재 로컬 (X 길이, Y 폭).

                    길이방향 원점은 옆면도와 같은 도면 절대좌표(sx0)를 쓴다.
                    두 뷰는 도면에서 이미 같은 x 위치에 정렬돼 그려져 있고,
                    평면도가 양끝으로 t(9mm)씩 더 나가 보이는 것은 그리기용 여분일 뿐이다.
                    (각 뷰의 최소x끼리 맞추면 구멍이 그 9mm 만큼 밀린다)
                    """
                    lx = x - sx0
                    ly = (ph - (y - pyo)) if flip else (y - pyo)
                    return lx, ly

                flg_cuts = []
                for r in pinner:
                    if poly_area(r) > MIN_HOLE_A:
                        flg_cuts.append([P2L(*p) for p in r])
                for cj, c2 in enumerate(comps):
                    if cj in use:
                        continue
                    b = c2['bbox']
                    if not (pb[0] <= b[0] and pb[1] <= b[1] and b[2] <= pb[2] and b[3] <= pb[3]):
                        continue
                    o2, i2 = dissolve([segs[i] for i in comps[cj]['idxs']], eps=0.6)
                    for r in ([o2] if o2 else []) + list(i2):
                        if poly_area(r) > MIN_HOLE_A and (planP is None or planP.contains(Polygon(r).representative_point())):
                            flg_cuts.append([P2L(*p) for p in r])
                flg_circ = []
                for cx, cy, r in small_circ:
                    if not (pb[0] <= cx <= pb[2] and pb[1] <= cy <= pb[3]):
                        continue
                    if planP is not None and not planP.contains(Point(cx, cy)):
                        continue
                    lx, ly = P2L(cx, cy)
                    flg_circ.append((lx, ly, r))
                plan_ring = [P2L(*p) for p in pouter] if pouter else None
        else:
            note_ceo.append('평면도(높이 %.0fmm) 덩어리를 못 찾음 — 평면 절단·플랜지 구멍 미반영' % B)

        # 이미 잘라낸 구멍(장공) 안에 들어가는 원은 그 장공의 끝단 반지름 작도원이다.
        # 그대로 또 자르면 구멍 가장자리에 종잇장 같은 조각면이 생겨 닫힌 입체가 깨진다.
        def not_inside(circs, loops):
            polys = [Polygon(r) for r in loops if poly_area(r) > MIN_HOLE_A]
            out = []
            for c in circs:
                pt = Point(c[0], c[1])
                if any(pp.buffer(0.5).contains(pt) for pp in polys):
                    continue
                out.append(c)
            return out
        web_circ = not_inside(web_circ, [r for r, _ in web_cuts])
        if flg_circ:
            flg_circ = not_inside(flg_circ, flg_cuts)

        big = sorted({round(c[2]*2) for c in web_circ if c[2] >= 25})
        if big:
            note_ceo.append('웨브의 큰 원 %s mm 를 관통 구멍으로 반영했다 — 도면에 십자 중심선과 함께 '
                            '그려져 있어 실제 구멍으로 판단했으나, 지시용 원일 가능성 확인 필요' % big)

        print('  웨브(250면) 컷: 내부루프 %d개 %s / 원 %d개 %s'
              % (len(web_cuts), [tuple(round(v, 1) for v in ring_wh(r)[:2]) for r, _ in web_cuts],
                 len(web_circ), sorted({round(c[2]*2) for c in web_circ})))
        print('  플랜지(90면) 컷: 내부루프 %d개 %s / 원 %d개 %s'
              % (len(flg_cuts), [tuple(round(v, 1) for v in ring_wh(r)[:2]) for r in flg_cuts],
                 len(flg_circ), sorted({round(c[2]*2) for c in flg_circ})))

        # ---- 5) 입체 만들기 : 세 방향 프리즘의 교집합 ----
        side_ring = [(x - sx0, y - sy0) for x, y in souter]
        try:
            body = prism(channel_profile(D, B, t), _yz, (L + 2*PAD, 0, 0))
            chk(body, '단면 압출')
            body = body.intersect(prism(side_ring, _xz, (0, B + 2*PAD, 0)))
            chk(body, '옆면 교집합')
            if plan_ring:
                body = body.intersect(prism(plan_ring, _xy, (0, 0, D + 2*PAD)))
                chk(body, '평면 교집합')
            # 웨브 관통(Y 방향)
            for _i, (r, _src) in enumerate(web_cuts):
                body = body.cut(prism([(x - sx0, y - sy0) for x, y in r], _xz, (0, B + 2*PAD, 0)))
                chk(body, '웨브 내부루프컷 %d' % _i)
            for cx, cy, rr in web_circ:
                body = body.cut(cq.Workplane('XY').newObject([cq.Solid.extrudeLinear(
                    cq.Wire.makeCircle(rr, cq.Vector(cx - sx0, -PAD, cy - sy0), cq.Vector(0, 1, 0)),
                    [], cq.Vector(0, B + 2*PAD, 0))]))
                chk(body, '웨브 원컷 d%.0f' % (rr*2))
            # 플랜지 관통(Z 방향)
            for _i, r in enumerate(flg_cuts):
                body = body.cut(prism(r, _xy, (0, 0, D + 2*PAD)))
                chk(body, '플랜지 내부루프컷 %d' % _i)
            for lx, ly, rr in flg_circ:
                body = body.cut(cq.Workplane('XY').newObject([cq.Solid.extrudeLinear(
                    cq.Wire.makeCircle(rr, cq.Vector(lx, ly, -PAD), cq.Vector(0, 0, 1)),
                    [], cq.Vector(0, 0, D + 2*PAD))]))
                chk(body, '플랜지 원컷 d%.0f @%.0f' % (rr*2, lx))
        except Exception as ex:
            results.append(dict(pn=pn, status='skip', why='불리언 실패: %s' % str(ex)[:120]))
            print('  [SKIP] 불리언 실패: %s' % str(ex)[:120]); continue

        stl = SCR + ('\\_p_%s.stl' % pn.replace('-', '_'))
        cq.exporters.export(body, stl, tolerance=0.05, angularTolerance=0.1)
        m = trimesh.load(stl)
        m.apply_translation(-m.bounds[0])
        if not m.is_watertight:
            results.append(dict(pn=pn, status='skip', why='watertight 아님'))
            print('  -> watertight 실패'); continue

        fn = product.OBJ_PREFIX + '%s.obj' % (product.obj_tail(pn))
        dst = FAB + '\\' + fn
        if os.path.exists(dst) and not force:
            results.append(dict(pn=pn, status='skip', why='이미 존재 %s — 덮어쓰기 금지' % fn))
            print('  [SKIP] 이미 존재 %s' % fn); continue
        m.export(dst)
        ext = [round(float(v), 2) for v in m.extents]
        nweb = len(web_cuts) + len(web_circ)
        nflg = len(flg_cuts) + len(flg_circ)
        # 사선컷 판정: 옆면 윤곽에 수평/수직이 아닌 변이 있으면 사선(또는 노치)이 있는 것
        sa = np.array(side_ring)
        dv = np.diff(np.vstack([sa, sa[:1]]), axis=0)
        ndiag = int(sum(1 for dx, dy in dv
                        if abs(dx) > 1.0 and abs(dy) > 1.0))
        print('  OBJ -> %s  faces=%d watertight=%s bbox=%s  웨브컷%d 플랜지컷%d 사선변%d'
              % (fn, len(m.faces), m.is_watertight, ext, nweb, nflg, ndiag))
        for s in note_ceo:
            print('  [CEO확인] %s' % s)
        results.append(dict(
            pn=pn, status='ok', file=product.FAB_REL+'/'+fn, thk=t, extents=ext,
            faces=int(len(m.faces)), watertight=bool(m.is_watertight),
            nholes=len(web_circ)+len(flg_circ), ninner=len(web_cuts)+len(flg_cuts),
            nweb=nweb, nflange=nflg, ndiag=ndiag, nvert_side=len(souter)-1,
            kind='채널형강', spec=raw, length=L,
            length_src='도면 옆면도(높이 %.0fmm = 단면 춤) 실제 폐곡선 실측 길이 %.1fmm' % (D, L),
            profile='옆면 윤곽(x-z) ∩ ㄷ단면(y-z) ∩ 평면 윤곽(x-y) 교집합' if plan_ring
                    else '옆면 윤곽(x-z) ∩ ㄷ단면(y-z) 교집합(평면 윤곽 미적용)',
            ceo=note_ceo, evidence='VLM판독: ' + raw))
        json.dump(results, open(SCR + r'\channel_result.json', 'w', encoding='utf-8'),
                  ensure_ascii=False, indent=1)

    json.dump(results, open(SCR + r'\channel_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:
        print(('  [OK]   %-18s %s  길이 %s  웨브컷%d 플랜지컷%d 사선변%d'
               % (r['pn'], r['extents'], r['length'], r['nweb'], r['nflange'], r['ndiag']))
              if r['status'] == 'ok' else ('  [SKIP] %-18s %s' % (r['pn'], r['why'])))


if __name__ == '__main__':
    main()
