# -*- coding: utf-8 -*-
"""제관웹 오프라인 단일파일 빌더.
   fab.html + 로컬 JS + fab_models(obj/json) 을 HTML 한 장으로 합친다.
   원본은 읽기만 한다. 산출물만 새로 만든다."""
import base64, gzip, io, json, os, re, sys

SRC  = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
# 쉼 본체는 _build/shim/ 폴더 안에 나뉘어 있다(목록은 아래 SHIM_ORDER).

# --lite : 500PRO-R_v2(분해가능) 만 남긴 축소판. 에버다임 통짜 75개를 빼서 절반 이하로.
#   아이패드가 용량 때문에 멈추는 경우를 대비한 예비 산출물.
#   --web  : 웹 게시용(16MB 한도). CANADA + 분해모델만 담고 gzip 으로 압축한다.
#            압축 해제에 DecompressionStream 이 필요하다(사파리 16.4 이상).
#            현장용 두 판은 호환성을 우선해 압축하지 않는다.
LITE = '--lite' in sys.argv
WEB  = '--web' in sys.argv
# --rev0917 : [2026-09-17 바이어 미팅용] 오늘 등록한 '500PRO-R 수정안 20260917'(부재 293)을
#   담은 웹게시용 한 장. --web 과 함께 쓴다. 기존 3종 산출물은 건드리지 않는다(파일명 별도).
#   이 판이 9/16 웹게시용과 다른 점 3가지:
#     ① 오늘 모델의 부품 293개를 상위 원본에서 끌어와 '블럭'으로 등록한다
#        (블럭이라 뷰어의 부품 팔레트 12색이 부재마다 순환 적용된다 = 부재별 색 구분).
#        오늘 블럭 등록정보는 이 오프라인 폴더의 parts/index.json 에는 없고
#        상위 원본 폴더의 _parts.json 에 있다 → 거기서 읽어 붙인다.
#     ② 통짜 부재(fab_models/*.obj 75개)를 빼지 않는다 — 9/16 판은 이걸 빼서
#        부재 목록이 206 → 0 이 되어 블럭 2개만 남았다. CEO 가 부재를 눌러 봐야 한다.
#        실측: 통짜 75개 = raw 16.5MB → gzip+base64 4.9MB (16MB 한도 안).
#     ③ 조작감(ROT_SPEED 등)은 상위 원본 fab_scene.js 에만 고쳐져 있다 → 그 파일 하나만
#        상위 원본에서 인라인한다(아래 PARENT_JS). 다른 fab_*.js 는 절대 바꾸지 않는다
#        (fab_process.js 는 지금 다른 작업자가 수정 중이다 — 반쪽 파일을 실으면 흰 화면).
REV0917 = '--rev0917' in sys.argv
_sfx = '_축소판' if LITE else ('_웹게시용' if WEB else '')
OUT  = os.path.join(os.path.dirname(SRC),
                    '제관웹_오프라인_단일파일_500PRO' + _sfx +
                    ('_대조군' if '--nofix' in sys.argv else '') + '.html')
if REV0917:
    OUT = os.path.join(os.path.dirname(SRC), '제관웹_웹게시용_500PRO_20260917.html')
# 오늘 모델(상위 원본에만 있는 것들)
REV_FOLDER = '500PRO_R_20260917_REV'
REV_PARTS_PREFIX = 'fab_models/parts/' + REV_FOLDER + '/'
# 상위 원본에서만 가져올 코드 파일 — 딱 이 한 개만 허용한다(화이트리스트).
PARENT_JS = {'fab_scene.js'} if REV0917 else set()

def rd(p, b=False):
    with open(p, 'rb' if b else 'r', encoding=None if b else 'utf-8',
              errors=None if b else 'replace') as f:
        return f.read()

log = []
def say(*a):
    s = ' '.join(str(x) for x in a); log.append(s); print(s)

# ── 1. 박아넣을 목록 고르기 ────────────────────────────────────────────
#   실제로 fetch 되는 것은 .obj(텍스트 메시) 와 .json(목록/메모) 뿐이다.
#   .skp/.skb/.dwg/.dae/.pdf 는 원본 설계파일로 뷰어가 읽지 않으므로 제외.
EMBED_EXT = {'.obj', '.json', '.png'}
SKIP_RE   = re.compile(r'manifest_backup_', re.I)

# ── [예정] 불량 사진 embed — 용량 사전 실측 결과 (2026-09-14) ──────────
#   CEO 지시: 작업기록 패널에서 불량 항목을 누르면 해당 사진이 열리게 한다.
#   구현은 등록소 리팩토링 이후지만, '웹게시용 16MB 한도를 넘는가'를 먼저 쟀다.
#   대상: 104호기_프레임파손_20260829 의 14장 (timeline.DEFECT_DIR)
#
#     원본            31.6 MB   (장당 1.7~2.9MB, 카톡 원본)
#     800px q80        0.76 MB  → base64  1.02 MB   ← 채택 권고
#     640px q80        0.51 MB  → base64  0.69 MB
#
#   판별: 세 판 모두 사진을 넣어도 된다. 웹게시용은 4.6 → 약 5.6MB 로
#         16MB 한도에 한참 못 미친다. '웹게시용만 사진 제외'는 필요 없다.
#   주의: JPEG 는 이미 압축이라 --web 의 gzip 이 거의 안 듣는다(실측 99.8%,
#         즉 1000바이트가 998바이트가 된다). 웹게시용 용량 계산에서 사진은
#         '압축이 안 되는 덩어리'로 잡아야 한다 — obj/json 처럼 줄어들 거라고
#         기대하면 안 된다.
#   사진은 리사이즈 결과만 넣는다(원본 31.6MB 는 절대 넣지 않는다).
#
# ── [예정] 작업일보 원문 보기 — 용량·호환성 사전 실측 (2026-09-14) ─────
#   대상: C:\Users\user\Desktop\작업일보\02_PDF 의 14개 (126쪽)
#
#     PDF 원본        14.8 MB  → base64  19.7 MB   ← base64 는 33% 부풀어난다
#     1200px JPEG      9.3 MB  → base64  12.4 MB   ← 채택 권고
#      900px JPEG      5.9 MB  → base64   7.9 MB
#
#   판별이 뒤집혔다: 페이지 이미지 변환은 'PDF 가 안 될 때의 대비책'이 아니라
#   그냥 더 작다. PDF 를 통째로 넣으면 19.7MB, 이미지로 바꾸면 12.4MB 다.
#   (CEO 예상치는 15MB 였지만 base64 부풀림을 빼고 계산한 값이다)
#
#   호환성 실측(불투명 오리진 재현, _build/pdf_probe.html):
#     크롬에서는 data-URI PDF 가 iframe/object 로 '뜬다'(onload 발생, 레이아웃 182px,
#     blob: URL 도 불투명 오리진에서 생성됨). 다만 contentDocument 는 null 이라
#     안이 어떻게 렌더됐는지는 확인할 수 없고, 시험 중 최상위 탭이 PDF 뷰어에
#     가로채여 접근 불가가 된 일이 두 번 있었다(크롬에서도 불안정하다는 뜻).
#     ※ 이건 크롬 결과일 뿐 아이패드 사파리 증명이 아니다. 사파리는 data: PDF 를
#       인라인 렌더하지 않는 것으로 알려져 있고, 여기서 검증할 방법이 없다.
#   → 결론: 이미지 방식이 더 작고, 아이패드 실기 시험도 필요 없다. 이미지로 간다.
#     같은 조건에서 JPEG 표시는 정상 확인됨(대조군).
#
# ── [확정 2026-09-14 CEO] 혼합안 — 판별 해상도 스위치 ──────────────────
#   불량사진 : 세 판 모두 800px q80                 (+base64 1.02MB)
#   작업일보 : 전체판·축소판 1200px (현장에서 글씨를 읽는다)  (+base64 12.4MB)
#              웹게시용      900px  (브리핑에서 훑는다)      (+base64  7.9MB)
#   예상 최종: 전체판 약 53.7MB / 축소판 약 29.2MB / 웹게시용 약 13.5MB
#   → 웹게시용도 16MB 한도 안이라 세 판 모두 '원문 보기'가 들어간다.
#
#   구현할 때 이 두 상수만 두면 된다:
#     PHOTO_PX = 800
#     DOC_PX   = 900 if WEB else 1200
#   ※ 한도 점검은 반드시 base64 기준으로 한다. JPEG 는 gzip 이 안 들으므로
#     웹게시용(--web)에서도 사실상 base64 크기 그대로 더해진다.
#
# ── [실측 2026-09-15] 신형_2026 현장사진 376장 (사진↔부품 연결용) ──────
#   E:\VLA시스템\...\E500PRO-R_프레임\신형_2026\
#     가접/{최종가접79, 콘솔4, 프레임 가접162, 프레임선작업27}
#     용접/{최종용접23, 프레임용접81}          원본 합계 978MB
#
#     320px q72  전체 376장 :  4.27 MB → base64   5.69 MB   (장당  12KB)
#     800px q80  전체 376장 : 24.56 MB → base64  32.74 MB   (장당  67KB)
#    1200px q80  전체 376장 : 50.32 MB → base64  67.09 MB   (장당 137KB)
#
#   판별: CEO 선택지 중 '전체 800px embed(전체판 한정)' 는 탈락이다.
#     전체판 40.3 + 32.7 = 73MB, 작업일보까지 더하면 85MB 가 된다.
#     아이패드가 용량 때문에 멈춰서 축소판을 따로 만든 마당에 낼 수 없는 숫자다.
#   → 2단계 전략(썸네일 전체 + 연결분만 고해상)을 채택한다.
#     ① 사진 선택기 그리드용 썸네일 320px 전체 376장 = 5.69MB (훑어보기에 충분)
#     ② 부품에 '연결된' 사진만 고해상 추가 (1200px 137KB/장, 800px 67KB/장)
#        → 연결이 늘어난 만큼만 커진다 = 용량이 예측 가능해진다.
#   ※ 웹게시용 주의: 4.6 + 불량1.02 + 작업일보7.9 + 썸네일5.69 = 19.2MB 로
#     16MB 를 넘는다. 웹게시용은 사진 선택기(썸네일 전체)를 빼고 '연결된 사진'만
#     싣는다 — 사진 첨부는 현장 아이패드에서 하는 작업이라 웹에는 선택기가 필요 없다.
#
#   [장기 옵션 — 지금 구현하지 말 것] VLM 으로 사진 속 부품 자동 후보 추천.
#     파일명이 타임스탬프뿐이라 사람이 376장을 일일이 붙이는 부담이 크다.
#     다만 도면 원본을 학습에 넣지 않는다는 IP 규칙과 충돌하지 않는 범위
#     (현장사진만 온디맨드로 보고 후보만 제시)에서 검토한다.

files = []
for root, dirs, names in os.walk(os.path.join(SRC, 'fab_models')):
    for n in names:
        if os.path.splitext(n)[1].lower() not in EMBED_EXT: continue
        if SKIP_RE.search(n): continue
        full = os.path.join(root, n)
        rel  = os.path.relpath(full, SRC).replace('\\', '/')
        files.append((rel, full))

# ── 1-b. 상위 원본에서 500PRO-R_v2 만 끌어온다 ─────────────────────────
#   이 오프라인 폴더에는 분해가능 모델의 부품 obj 가 통째로 빠져 있다(1개뿐).
#   현장 목적이 500PRO 이므로 그 한 모델의 부품 251개와 앗세이 분류맵만 보충한다.
#   나머지 90개 모델은 지시대로 넣지 않는다(전부면 1.6GB → 사파리에서 못 연다).
PARENT_MODELS = os.path.join(os.path.dirname(SRC), 'fab_models')
#   [2026-09-11] 캐나다 현장용 통짜 모델도 상위 원본에서 끌어온다.
#     .mtl 은 현재 뷰어(자체 OBJ 파서)가 쓰지 않지만, 원본 한 벌을 같이 두려고 넣는다.
EXTRA_DIRS  = ['parts/500PRO-R_v2'] + (['parts/' + REV_FOLDER] if REV0917 else [])
#   [2026-09-14] 현장 용접정반(2×4m) 3D 모델도 상위 원본에서 끌어온다.
#     장비(치공구)라 부재 목록과 별개지만, 부재를 정반 위에 올려놓고 자세를 보려면
#     같은 씬에 불러올 수 있어야 한다 → 라이브러리 항목으로 등록한다(아래 2-c).
EXTRA_FILES = ['assembly_map.json',
               'sketchup/500PRO_R_CANADA.obj',
               'sketchup/500PRO_R_CANADA.mtl',
               'sketchup/welding_table_2x4m.obj']
CANADA = 'fab_models/sketchup/500PRO_R_CANADA.obj'
TABLE  = 'fab_models/sketchup/welding_table_2x4m.obj'
extra_n = 0
for d in EXTRA_DIRS:
    sd = os.path.join(PARENT_MODELS, d.replace('/', os.sep))
    if not os.path.isdir(sd):
        say('[경고] 상위 원본에 없음:', sd); continue
    for n in sorted(os.listdir(sd)):
        if os.path.splitext(n)[1].lower() not in EMBED_EXT: continue
        files.append(('fab_models/' + d + '/' + n, os.path.join(sd, n))); extra_n += 1
for n in EXTRA_FILES:
    p = os.path.join(PARENT_MODELS, n)
    if os.path.isfile(p): files.append(('fab_models/' + n, p)); extra_n += 1
    else: say('[경고] 상위 원본에 없음:', p)
say('상위 원본에서 보충:', extra_n, '개 (500PRO-R_v2 부품 + assembly_map)')

if LITE or WEB:
    # 축소판·웹게시용: 분해가능 500PRO 부품 + 캐나다 모델 + 목록 json 만 남기고,
    #   에버다임 통짜 부재 75개는 뺀다.
    before = len(files)
    #   용접정반(0.4MB)은 세 판 모두에 남긴다 — 현장에서 부재 자세를 볼 때 기준이 된다.
    #   [2026-09-17 --rev0917] 여기에 두 가지를 더 남긴다:
    #     ① 오늘 모델 부품 293개  ② 통짜 부재 75개(부재 목록을 살리는 실체)
    def _keep(r):
        if (r.startswith('fab_models/parts/500PRO-R_v2/')
                or r.startswith('fab_models/sketchup/500PRO_R_CANADA')
                or r == TABLE or r.endswith('.json')):
            return True
        if REV0917:
            if r.startswith(REV_PARTS_PREFIX):
                return True
            # 통짜 부재 = fab_models 바로 밑의 .obj (하위 폴더는 포함하지 않는다)
            if r.startswith('fab_models/') and r.endswith('.obj') \
                    and '/' not in r[len('fab_models/'):]:
                return True
        return False
    files = [(r, f) for (r, f) in files if _keep(r)]
    say('[' + ('축소판' if LITE else '웹게시용') + '] 통짜 부재 제외:', before, '→', len(files), '개')

files.sort()
disk = {rel for rel, _ in files}
say('박아넣을 파일 합계:', len(files), '개')

# ── 1-c. 캐나다 OBJ 를 부위(g 그룹)별로 쪼갠다 ─────────────────────────
#   [CEO 2026-09-12] "회색 단일색이라 부위 구분이 안 된다".
#   원인: 뷰어는 통짜(loadOBJ)에 스틸 단색 하나만 입힌다(fab_material.js:132).
#         .mtl 을 읽어도 소용없다 — 재질 7개가 전부 무채색(최대 채도 0.06)이고
#         그중 SteelGray 하나가 전체 면의 71% 라, 원본 색을 써도 같은 회색 덩어리다.
#   해법: 부위별로 나눠 '분해가능 모델(블럭)'로 등록한다. 그러면 뷰어가 이미 쓰는
#         부품 팔레트(fab_material.js 의 PALETTE 12색)가 부위마다 순환 적용되어
#         분해모델 251부품과 정확히 같은 방식이 된다. 부위 클릭·격리도 덤으로 된다.
#   쪼개는 기준은 usemtl(7개, 무의미)이 아니라 g 그룹(159개) — 스케치업 그룹이자 도번.
#   원본 OBJ 는 그대로 두고, 쪼갠 조각은 '가상 파일'로 HTML 안에만 만든다.
CANADA_FOLDER = 'CANADA_500PRO_R'
CANADA_NAME   = '500PRO_R CANADA (캐나다 현장)'
virtual = {}          # rel경로 -> OBJ 텍스트 (디스크에 쓰지 않는다)
canada_parts = []

_JUNK = re.compile(r'Group\d+|Model|Mesh\d+')
_THK  = re.compile(r'[_0-9TtMm.]+')          # '___3_14T__' 같은 두께 메모
def _part_name(tokens):
    """스케치업 그룹 이름줄에서 도번을 골라낸다.
       'g Mesh44 G_2110DN_0004_006 Group11 Group1 Model' → ('2110DN-0004-006', 조상들)
       'g Mesh7 RE102_0214_1 Group5 ... Model'           → ('RE102-0214-1', 조상들)
       접두사 G_ 는 스케치업이 붙인 것이라 떼고, '_' 는 도번 표기대로 '-' 로 바꾼다.
       도번이 없는 그룹(Group12 같은 껍데기만)은 MeshNN 을 그대로 쓴다 — 전체의 약 11%."""
    for i, t in enumerate(tokens):
        if _JUNK.fullmatch(t) or _THK.fullmatch(t):
            continue
        nm = t[2:] if t.startswith('G_') else t
        return nm.replace('_', '-').strip('-'), tokens[i + 1:]
    return (tokens[0] if tokens else 'part'), tokens[1:]

def split_canada(path):
    verts, groups, cur = [], [], None
    with open(path, 'r', encoding='utf-8', errors='replace') as fh:
        for ln in fh:
            if ln.startswith('v '):
                verts.append(ln[2:].strip())
            elif ln.startswith('g ') or ln.startswith('o '):
                nm, anc = _part_name(ln.strip().split()[1:])
                cur = (nm, anc, []); groups.append(cur)
            elif ln.startswith('f '):
                if cur is None:
                    cur = ('part', [], []); groups.append(cur)
                idx = []
                for tok in ln.split()[1:]:
                    n = int(tok.split('/')[0])
                    idx.append(n - 1 if n > 0 else len(verts) + n)
                cur[2].append(idx)
    out = []
    for nm, anc, faces in groups:
        if not faces:
            continue                                   # 면 없는 그룹은 버린다
        used, remap = [], {}
        for f in faces:
            for a in f:
                if a not in remap:
                    remap[a] = len(used) + 1            # OBJ 는 1-based
                    used.append(a)
        # parseOBJ 는 v 와 f 만 읽고 법선은 스스로 계산한다 → vn/vt 는 싣지 않는다(용량 절감)
        body = ''.join('v ' + verts[a] + '\n' for a in used)
        body += ''.join('f ' + ' '.join(str(remap[a]) for a in f) + '\n' for f in faces)
        out.append((nm, anc, len(faces), body))
    return out

if CANADA in disk:
    cpath = dict(files)[CANADA]
    pieces = split_canada(cpath)
    # 스케치업 조상은 전부 '... Group1 Model' 로 끝나서, 역순하면 맨 앞이 모두 같다.
    #   그대로 쓰면 159개가 최상위 그룹 '하나' 밑으로 다 들어가고, fab_explode 가
    #   '최상위 단위 1개 = 통짜' 로 보아 분해를 거부한다.
    #   (실측: 로그 "최상위 1 부품 159" → "통짜/단일이라 분해할 수 없습니다")
    #   → 모두가 공유하는 앞부분 경로를 잘라 실제 소조립이 최상위로 올라오게 한다.
    #   껍데기 그룹을 몇 단계 벗겨야 하는지는 실제 분포를 보고 고른다 —
    #   '최상위 단위가 가장 여럿으로 갈라지는 깊이'가 소조립 단위다.
    paths = [list(reversed(a)) for (n, a, f, b) in pieces]
    names = [n for (n, a, f, b) in pieces]
    #   더 깊이 벗길수록 단위는 잘게 쪼개지지만 소조립 이름(centerframe_211 등)을 잃는다.
    #   → '쓸 만큼 갈라지는(4개 이상) 가장 얕은 깊이'를 쓴다. 이름이 남아 격리·선택에 유리하다.
    def tops_at(d):
        return set(p[d] if len(p) > d else names[k] for k, p in enumerate(paths))
    common, best = 0, len(tops_at(0))
    for d in range(0, 5):
        n = len(tops_at(d))
        common, best = d, n
        if n >= 4:
            break
    say('캐나다 경로 %d 단계 벗김 → 최상위 분해 단위 %d개 (fab_explode 는 2개 이상이어야 분해한다)'
        % (common, best))
    for i, (nm, anc, nface, body) in enumerate(pieces):
        rel = 'fab_models/parts/%s/p%03d.obj' % (CANADA_FOLDER, i + 1)
        virtual[rel] = body
        segs = list(reversed(anc))[common:] + [nm]     # 조상 역순 → 계층 트리(격리·분해용)
        canada_parts.append({'file': rel, 'name': nm, 'faces': nface,
                             'path': '/'.join(s for s in segs if s)})
    say('캐나다 부위 분할: %d 조각 (면 %d) — 가상 파일로만 생성'
        % (len(canada_parts), sum(p['faces'] for p in canada_parts)))
    # 통짜 원본은 더 싣지 않는다 — 같은 형상을 두 벌 넣을 이유가 없다(용량 절감).
    files = [(r, f) for (r, f) in files if r != CANADA]
    disk.discard(CANADA)
    disk.update(virtual.keys())

# ── 2. 목록 JSON 손질 — 없는 모델은 지운다 ─────────────────────────────
#   이 오프라인 패키지는 500PRO 관련만 추려 만든 것이어서, 원본 manifest 가
#   가리키는 파일 다수가 실제로는 없다. 그대로 두면 목록에 뜨지만 눌러도
#   아무 일이 없는 항목이 대부분이 된다 → 빌드 시점에 걸러낸다(원본은 그대로).
patched = {}

mf = json.loads(rd(os.path.join(SRC, 'fab_models', 'manifest.json')))
items = mf.get('items', [])
keep  = [it for it in items if it.get('file', '').replace('\\', '/') in disk]

#   캐나다 모델은 manifest(통짜) 가 아니라 아래에서 '블럭'으로 등록한다 — 부위 색 구분 때문.

# ── 2-c. 용접정반 = '무대 바닥' 리소스 ────────────────────────────────
#   [CEO 정정 2026-09-14] 정반은 사용자가 골라 여는 부품이 아니다.
#   공정서에서 어셈블리 제작 애니메이션이 시작될 때 자동으로 깔리는 기본 베이스(바닥)다.
#   → 부재 목록(manifest)에 넣지 않는다. 파일은 계속 박아넣되(embed 유지),
#     '어디에 무엇이 있는지'는 이 전용 JSON 하나로만 알린다.
#     공정서 애니메이션 구현이 이 파일을 읽어 바닥을 깔면 된다.
#   zup 근거(실측): x 0~2000, y 0~4000, z 0~850.5 → Z 가 높이축(작업높이 850).
#     뷰어는 Y-up 이므로 X축 -90도 눕혀야 한다.
#   재질(CEO 지시: 난반사로 너무 밝다 → 어둡고 차분한 공업용 강재):
#     기본 스틸(0x9aa6b4 / metal 0.85 / rough 0.40 / env 1.05)은 밝고 번들거린다.
#     무대 바닥은 주인공(부재)보다 튀면 안 되므로 무광 진회색으로 낮춘다.
if TABLE in disk:
    _tp = dict(files)[TABLE]
    _mn = [float('inf')] * 3; _mx = [float('-inf')] * 3; _nf = 0
    with open(_tp, 'r', encoding='utf-8', errors='replace') as _fh:
        for _ln in _fh:
            if _ln.startswith('v '):
                _c = _ln.split()
                for _i in range(3):
                    _val = float(_c[_i + 1])
                    if _val < _mn[_i]: _mn[_i] = _val
                    if _val > _mx[_i]: _mx[_i] = _val
            elif _ln.startswith('f '):
                _nf += 1
    patched['fab_models/stage_base.json'] = json.dumps({
        'name': '3D 용접정반 2x4m',
        'file': TABLE,
        'role': 'stage_base',
        'zup': True,
        'size_mm': [round(_mx[i] - _mn[i], 1) for i in range(3)],
        'work_height_mm': round(_mx[2], 1),
        'faces': _nf,
        'material': {                      # 무광 진회색 공업용 강재
            'color': '0x23272c',
            'metalness': 0.35,
            'roughness': 0.92,
            'envMapIntensity': 0.18,
            'edgeColor': '0x7d8894',       # 어두운 상판 위에서 구멍이 보이려면 테두리가 '밝아야' 한다
            'edgeOpacity': 0.45,
            'note': ('기본 스틸(0x9aa6b4 / metal 0.85 / rough 0.40 / env 1.05)은 반사가 강해 '
                     '바닥이 부재보다 밝게 뜬다. 바닥은 배경이므로 어둡고 무광으로 깐다. '
                     '색을 이렇게까지 어둡게 잡은 이유(실측): 씬 조명 합이 약 3.25 다 '
                     '(HemisphereLight 1.05 + Directional 0.9 + fab_material key 0.55·fill 0.30·rim 0.45). '
                     '렌더러에 toneMapping 이 없어 밝기가 그대로 곱해지므로, 화면에 찍히는 값은 '
                     '재질색의 약 2.5배가 된다. 0x4a5058 로 넣었더니 화면에서는 밝은 회색이 됐다(1차 실측). '
                     '0x23272c 면 화면에서 차분한 진회색으로 앉는다. '
                     '구멍 테두리 색은 기본값(0x0b1220, 거의 검정)을 쓰면 어두운 상판에 묻혀 '
                     '구멍이 안 보인다 → 상판보다 밝은 회색으로 뒤집었다.')
        },
        'note': ('공정서 어셈블리 애니메이션의 기본 바닥. 애니메이션 시작 시 깔고 끝나면 치운다. '
                 '부재 목록에는 노출하지 않는다(CEO 정정 2026-09-14).')
    }, ensure_ascii=False)
    say('무대 바닥(용접정반) 리소스 등록: 면 %d, %.1f×%.1f×%.1f mm, zup=True — 목록 비노출'
        % (_nf, _mx[0] - _mn[0], _mx[1] - _mn[1], _mx[2] - _mn[2]))
else:
    say('[경고] 용접정반 OBJ 가 없어 무대 바닥 리소스를 만들지 못했다:', TABLE)

mf['items'] = keep
mf['_offline_pruned'] = {'before': len(items), 'after': len(keep)}
patched['fab_models/manifest.json'] = json.dumps(mf, ensure_ascii=False)
say('manifest 부재 항목: %d → %d (파일 없는 %d 개 제외)' % (len(items), len(keep), len(items) - len(keep)))

ix = json.loads(rd(os.path.join(SRC, 'fab_models', 'parts', 'index.json')))
blocks = ix.get('blocks', [])
kb, dropped = [], 0
for b in blocks:
    ps = b.get('parts', [])
    # 부품 파일이 한 개라도 없으면 조립이 깨지므로 통째로 제외한다.
    if ps and all(p.get('file', '').replace('\\', '/') in disk for p in ps):
        kb.append(b)
    else:
        dropped += 1
# 캐나다 모델을 '분해가능 모델'로 등록 → 부위마다 팔레트 색이 붙는다(251부품과 같은 방식).
#   zup:true 는 추측이 아니라 형상에서 확인했다 — Z 가 0 에서 시작하고 Z 폭 1199.3mm 가
#   '높이'다(기존 SketchUp 출처 항목과 소수점까지 같은 값). loadBlock 이 root 를 X축 -90도 돌린다.
if canada_parts:
    kb.append({
        'model': CANADA_NAME,
        'folder': CANADA_FOLDER,
        'block': True,
        'zup': True,
        'part_count': len(canada_parts),
        'parts': canada_parts,
        'note': ('캐나다 현장 모델. 원본은 통짜 OBJ 1개지만, 회색 단색으로 보여 부위 구분이 '
                 '안 된다는 지적에 따라 빌드 시점에 스케치업 g 그룹(도번) 기준으로 %d 조각으로 '
                 '나눠 블럭으로 등록했다. 색은 뷰어의 기존 부품 팔레트(fab_material PALETTE 12색)가 '
                 '부위 순서대로 순환 적용된다. .mtl 재질 7개는 전부 무채색(최대 채도 0.06)이고 '
                 'SteelGray 하나가 면의 71%%라, 원본 재질색을 써도 같은 회색 덩어리가 되어 쓰지 않았다. '
                 '원본 OBJ/MTL 파일은 수정하지 않았고 조각은 HTML 안에만 존재한다.') % len(canada_parts)
    })
    say('캐나다 블럭 등록: %s (부위 %d, zup=True)' % (CANADA_NAME, len(canada_parts)))

# [2026-09-17 --rev0917] 오늘 모델을 '분해가능 모델(블럭)'로 등록한다.
#   등록정보는 상위 원본 폴더의 _parts.json 을 그대로 쓴다 — 빌드가 새로 지어내지 않는다.
#   블럭으로 넣는 이유: 뷰어가 블럭에는 부품 팔레트(fab_material PALETTE 12색)를
#   부재 순서대로 순환 적용한다 → 부재별 색 구분이 그 경로로 자동 적용된다.
rev_block = None
if REV0917:
    _rp = os.path.join(PARENT_MODELS, 'parts', REV_FOLDER, '_parts.json')
    if not os.path.isfile(_rp):
        raise SystemExit('[빌드 중단] 오늘 모델 등록정보가 없다: %s' % _rp)
    rev_block = json.loads(rd(_rp))
    _miss = [p['file'] for p in rev_block.get('parts', [])
             if p.get('file', '').replace('\\', '/') not in disk]
    if _miss:
        raise SystemExit('[빌드 중단] 오늘 모델 부품 %d 개가 안 실렸다(첫 3개: %s). '
                         '목록에는 뜨지만 눌러도 안 열리는 판이 된다.'
                         % (len(_miss), _miss[:3]))
    kb.append(rev_block)
    say('오늘 모델 블럭 등록: %s (부재 %d, zup=%s)'
        % (rev_block.get('model'), len(rev_block.get('parts', [])), rev_block.get('zup')))

ix['blocks'] = kb
ix['_offline_pruned'] = {'before': len(blocks), 'after': len(kb)}
patched['fab_models/parts/index.json'] = json.dumps(ix, ensure_ascii=False)
say('블럭(분해가능 모델): %d → %d (부품 누락 %d 개 제외)' % (len(blocks), len(kb), dropped))

# ── 2-b. 104호기 수리 타임라인 + 조립순서 ──────────────────────────────
#   원본 fab_*.js 는 건드리지 않는다. 뷰어가 이미 읽는 assembly.json 규격에 맞춰
#   '가상 파일'로 주입하면 기존 조립순서 재생 기능이 그대로 돌아간다.
TL = None
if canada_parts:
    sys.path.insert(0, os.path.join(SRC, '_build'))
    import timeline as _tl
    TL = _tl.build([p['name'] for p in canada_parts])
    asm = {
        'model': CANADA_NAME,
        'note': ('E500PRO-R 1차가접 공정서 6-2 놓는 순서(①~⑪) 기반. 도번이 적힌 단계만 '
                 '부위와 연결했고, 도번이 없는 단계는 타임라인에만 남겼다. '
                 '검증문서: _build_timeline_추출검증.md'),
        'steps': [{'part': s['part'], 'action': s['action'], 'note': s['note']} for s in TL['steps']]
    }
    # fab_assembly.js 는 '표시 이름' 으로 경로를 만든다(loadAssembly(model)).
    patched['fab_models/parts/' + CANADA_NAME + '/assembly.json'] = json.dumps(asm, ensure_ascii=False)
    # 타임라인 패널용 데이터(쉼이 읽는다)
    patched['fab_models/timeline_104.json'] = json.dumps({
        'machine': '104호기', 'defects': TL['areas'], 'work': TL['work'],
        'order': TL['order'], 'steps': TL['steps'], 'unmatched': TL['unmatched']
    }, ensure_ascii=False)
    say('타임라인: 작업기록 %d건, 불량부위 %d곳, 공정서 %d단계, 조립스텝 %d개(미매칭 %d)'
        % (len(TL['work']), len(TL['areas']), len(TL['order']), len(TL['steps']), len(TL['unmatched'])))

    # CEO 확인용 검증문서 — 어디서 나온 값인지 출처를 밝힌다(전체판 빌드 때만 쓴다).
    if not LITE and not WEB:
        L = []
        L.append('# 104호기 타임라인 · 조립순서 추출 검증')
        L.append('')
        L.append('> 빌드가 자동 생성한 문서입니다. 원본 파일은 수정하지 않았습니다.')
        L.append('> 확신할 수 없는 것은 **[CEO확인]** 으로 표시했고, 없는 내용은 채우지 않았습니다.')
        L.append('')
        L.append('## 출처')
        L.append('1. 작업기록 폴더 — `%s` (폴더명 = 작업기록_날짜_부위_작업내용)' % _tl.CANADA_ROOT)
        L.append('2. 공정서 — `%s` 의 【6단계】 6-2 놓는 순서 ①~⑪' % _tl.SPEC_MD)
        L.append('3. 불량 사진 — `%s` (파일명에서 부위 추출)' % _tl.DEFECT_DIR)
        L.append('')
        L.append('## 1. 불량 발견 부위 (2026-08-29 공장입고 점검)')
        L.append('')
        L.append('| 부위 | 사진 파일명 근거 | 사진 수 |')
        L.append('|---|---|---|')
        for a in TL['areas']:
            L.append('| %s | %s | %d |' % (a['area'], a['label'], len(a['photos'])))
        ctx = [p for p in TL['photos'] if p['context_only']]
        L.append('')
        L.append('참고용(부위 아님) %d장: %s' % (len(ctx), ', '.join(p['file'] for p in ctx)))
        L.append('')
        L.append('## 2. 날짜별 작업기록 (%d건)' % len(TL['work']))
        L.append('')
        L.append('| 날짜 | 호기 | 작업내용 | 파일수 | 비고 |')
        L.append('|---|---|---|---|---|')
        for w in TL['work']:
            d8 = w['date']
            dd = (d8[:4] + '-' + d8[4:6] + '-' + d8[6:8] + (('~' + d8[9:]) if len(d8) > 8 else '')) if len(d8) >= 8 else '(미상)'
            L.append('| %s | %s | %s | %s | %s |' %
                     (dd, w['machine'], w['desc'], w.get('files', ''),
                      '[CEO확인] 폴더명에 호기 표기 없음' if w['confirm'] else ''))
        L.append('')
        L.append('> 폴더명에 호기가 적힌 것만 104/102 로 분류했습니다. '
                 '적히지 않은 %d건은 임의 판단하지 않고 "불명"으로 두었습니다.'
                 % sum(1 for w in TL['work'] if w['confirm']))
        L.append('')
        L.append('## 3. 조립 놓는 순서 → 모델 부위 매칭')
        L.append('')
        L.append('| 순서 | 단품 | 공정서 도번 | 매칭된 모델 부위 | 판정 |')
        L.append('|---|---|---|---|---|')
        by = {}
        for s in TL['steps']:
            by.setdefault(s['_order'], []).append(s)
        for o in TL['order']:
            ss = by.get(o['order'], [])
            if ss:
                parts = ', '.join(s['part'] for s in ss)
                if any(s['_guessed'] for s in ss):
                    verdict = '[CEO확인] 공정서에 뒤 3자리만 적혀 있어 2110DN-0004 계열로 추정'
                elif all(s['_exact'] for s in ss):
                    verdict = '확정(도번 완전일치)'
                else:
                    verdict = '[CEO확인] 접두사 일치 — 사본·유사도번 포함 가능'
            else:
                parts, verdict = '—', '매칭 없음(공정서에 도번 미표기) — 타임라인 텍스트로만 표시'
            L.append('| %d | 단품 %s번 | %s | %s | %s |' %
                     (o['order'], o['piece'] or '-', ', '.join(o['dobun']) or '—', parts, verdict))
        L.append('')
        L.append('- 조립 애니메이션에 실제로 들어간 스텝: **%d개** (부위 기준)' % len(TL['steps']))
        L.append('- 부위 매칭이 안 된 순서: **%d개** (%s) — 애니메이션에는 빠지고 타임라인에만 남습니다.'
                 % (len(TL['unmatched']), ', '.join(str(u['order']) for u in TL['unmatched'])))
        L.append('')
        L.append('## 4. 아직 확인이 필요한 것')
        L.append('')
        L.append('- 작업기록 폴더 중 호기 미표기 %d건의 실제 대상 호기' % sum(1 for w in TL['work'] if w['confirm']))
        L.append('- 회의록 날짜 불일치 1건 — 원본 확인 전까지 [CEO확인] 유지')
        L.append('- 공정서 ①③④⑥⑪ 은 도번이 없어 3D 부위와 연결하지 못했습니다. '
                 '도번을 알려주시면 애니메이션에 바로 넣을 수 있습니다.')
        io.open(os.path.join(os.path.dirname(SRC), '_build_timeline_추출검증.md'),
                'w', encoding='utf-8').write('\n'.join(L))
        say('검증문서 작성: _build_timeline_추출검증.md')

# 회사 분류: 캐나다 모델도 에버다임 장비(E500PRO-R) 라 같은 섹션에 묶는다.
#   정반은 목록에 노출하지 않으므로 회사 분류를 주지 않는다(CEO 정정 2026-09-14).
if canada_parts:
    cm_path = os.path.join(SRC, 'fab_models', 'company_map.json')
    cm = json.loads(rd(cm_path)) if os.path.isfile(cm_path) else {}
    cm[CANADA_FOLDER] = '에버다임'
    patched['fab_models/company_map.json'] = json.dumps(cm, ensure_ascii=False)

# ── 2-c. 공정서 원본 = 제품 폴더 [CEO 원칙 2026-09-15] ──────────────────
#   "서류 폴더가 원본, 프로그램은 읽기만 한다."
#   대표가 제품 폴더에 공정서를 만든다. 그 폴더를 제관웹이 읽어야지, 빌드 옆 임시 폴더나
#   build.py 안에 베껴 두면 대표가 고쳐도 화면이 안 바뀐다(그게 지금까지의 최대 문제였다).
#
#   표준 위치:  <제품폴더>\공정서\
#       chapters.json        챕터·부품분류·작업순서      (없으면 그 제품은 공정서 탭에 안 뜬다)
#       순서_<챕터>.json      아이패드에서 내보낸 취부순서  (되돌아오는 길)
#       이력_pin_notes.json   아이패드에서 내보낸 부품이력  (되돌아오는 길)
#       사진연결.json         사진↔부품 연결표            (되돌아오는 길)
#   사진 원본은 옮기지 않는다 — 제품 폴더 옆의 신형_2026\ · 불량\ · 조립\ 을 그대로 참조한다.
#
#   쓰는 법:  python build.py --product "E:\VLA시스템\...\E500PRO-R_프레임"
#   여러 제품을 얹으려면 --product 를 여러 번 준다. 생략하면 공정서 없이 예전처럼 빌드된다.
#
#   읽은 결과는 embed 의 'fab_models/process/...' 키로 들어간다 → 쉼이 기존 키찾기(lookupKey)
#   로 그대로 읽는다. 새 fetch 경로를 만들지 않는다.
PROC_DIRNAME = '공정서'
PROC_PREFIX = 'fab_models/process/'
#   공정이력.json 은 아이패드 '공정이력 내보내기'가 내는 한 벌(순서+이력+사진연결)이다.
#   되돌아오는 길을 파일 하나로 줄이려고 둔다 — 나머지 세 이름도 그대로 읽는다(옛 경로 보존).
PROC_ONE = ['chapters.json', '공정이력.json', '이력_pin_notes.json', '사진연결.json']
PROC_MANY = ['순서_']

def product_dirs():
    """--product 인자들. 없으면 빈 목록(예전 동작 그대로)."""
    out = []
    for i, a in enumerate(sys.argv):
        if a == '--product' and i + 1 < len(sys.argv):
            out.append(sys.argv[i + 1])
        elif a.startswith('--product='):
            out.append(a.split('=', 1)[1])
    return out

def load_products():
    """제품 폴더의 공정서\\ 를 읽어 embed 용 가상 파일로 만든다.
       파일이 없는 것은 '정상'이다(대표가 아직 안 만든 것) — 오류가 아니라 '없음'으로 찍는다."""
    made, index = {}, []
    for pdir in product_dirs():
        pdir = os.path.abspath(pdir)
        pname = os.path.basename(pdir.rstrip('\\/'))
        proc = os.path.join(pdir, PROC_DIRNAME)
        if not os.path.isdir(proc):
            say('[공정서] 건너뜀 — %s 에 %s 폴더가 없다' % (pname, PROC_DIRNAME))
            continue
        entry = {'제품': pname, '폴더': proc, '파일': {}}
        names = sorted(os.listdir(proc))

        def take(fname):
            body = rd(os.path.join(proc, fname))
            key = PROC_PREFIX + pname + '/' + fname
            made[key] = body
            entry['파일'][fname] = {'키': key, '바이트': len(body.encode('utf-8'))}
            say('[공정서] %s ← %s (%d바이트)' % (pname, fname, len(body.encode('utf-8'))))

        for want in PROC_ONE:
            if want in names:
                take(want)
            else:
                say('[공정서] %s — %s 없음 (정상: 아직 안 만든 것)' % (pname, want))
        for pre in PROC_MANY:
            got = [f for f in names if f.startswith(pre) and f.endswith('.json')]
            for f in got:
                take(f)
            if not got:
                say('[공정서] %s — %s*.json 없음 (정상: 순서 편집 전)' % (pname, pre))
        index.append(entry)
    # 제품 목록도 embed 에 넣는다 — 쉼은 이 목록만 보고 무엇이 있는지 안다.
    made[PROC_PREFIX + 'index.json'] = json.dumps(
        {'생성': 'build.py', '제품수': len(index), '제품': index},
        ensure_ascii=False, indent=1)
    if index:
        say('[공정서] 제품 %d개 탑재: %s' % (len(index), ', '.join(e['제품'] for e in index)))
    else:
        say('[공정서] --product 가 없어 공정서 없이 빌드한다(예전 동작)')
    return made, index

PROC_FILES, PROC_INDEX = load_products()
virtual.update(PROC_FILES)

# ── 2-d. 현장사진 싣기 [CEO 2026-09-15 — 신규기능 4] ───────────────────
#   왜 — 신형_2026 폴더의 사진 376장은 파일명이 타임스탬프뿐이라 어느 부품인지
#     알 수 없다. 부품에 붙여 쌓으면 '사진-부품-노하우'가 이어진 공정이력서가 된다.
#
#   용량 실측(설계 예약에 기록된 것을 그대로 따른다):
#     320px q72 전체 376장 = base64 약 5.7MB   ← 선택기 그리드용, 탑재 가능
#     800px    전체 376장 = base64  32.7MB    ← 전체판이 73MB 가 된다. 탈락.
#   → ① 썸네일 320px 은 전체 탑재(훑어보기)
#     ② 고해상(1200px)은 '연결된 사진만' 추가 — 연결이 는 만큼만 커진다
#     ③ 웹게시용(16MB 한도)은 선택기(320px 전체)를 빼고 연결분만 싣는다
#   ※ JPEG 는 이미 압축이라 --web 의 gzip 이 거의 안 듣는다(실측 99.8%).
#     웹게시용 용량은 '사진은 안 줄어드는 덩어리'로 잡아야 한다.
#
#   원본은 절대 건드리지 않는다 — 읽기만 하고, 줄인 결과는 _build/_thumbcache/ 에만 쓴다.
#   제품 폴더(신형_2026\)에는 단 한 바이트도 쓰지 않는다(CEO 원칙: 제품 폴더가 원본).
#   캐시 열쇠는 (경로, 수정시각, 크기, 목표폭, 화질) — 원본이 바뀌면 자동으로 다시 만든다.
#   캐시가 없으면 3판을 빌드할 때마다 376장을 세 번 줄이게 되어 검증 게이트가 못 쓰게 된다.
PHOTO_DIRS = ['신형_2026']            # 사진 원본이 있는 제품 폴더 하위 폴더
PHOTO_EXT = ('.jpg', '.jpeg', '.png')
PHOTO_PREFIX = 'fab_models/process/'
THUMB_W, THUMB_Q = 320, 72
FULL_W, FULL_Q = 1200, 80
CACHE_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), '_thumbcache')

virtual_bin = {}                      # 글자가 아닌 것(JPEG) 을 싣는 자리

def _pil():
    try:
        from PIL import Image
        return Image
    except Exception:
        return None

def photo_resize(path, width, quality):
    """원본을 width 픽셀 폭으로 줄인 JPEG 바이트. 캐시가 있으면 그대로 쓴다."""
    import hashlib
    Image = _pil()
    if Image is None:
        return None
    st = os.stat(path)
    key = hashlib.sha1(('%s|%d|%d|%d|%d' % (path, st.st_mtime_ns, st.st_size,
                                            width, quality)).encode('utf-8')).hexdigest()
    cf = os.path.join(CACHE_DIR, key + '.jpg')
    if os.path.exists(cf):
        return rd(cf, b=True)
    if not os.path.isdir(CACHE_DIR):
        os.makedirs(CACHE_DIR)
    im = Image.open(path)
    im = im.convert('RGB')
    if im.width > width:
        im = im.resize((width, max(1, int(im.height * width / im.width))), Image.LANCZOS)
    buf = io.BytesIO()
    im.save(buf, 'JPEG', quality=quality, optimize=True)
    data = buf.getvalue()
    with open(cf, 'wb') as f:
        f.write(data)
    return data

def photo_scan(pdir):
    """제품 폴더에서 사진 목록을 만든다. 상대경로는 항상 '/' 로 쓴다(웹 주소라서)."""
    out = []
    for sub in PHOTO_DIRS:
        base = os.path.join(pdir, sub)
        if not os.path.isdir(base):
            continue
        for root, _dirs, fnames in os.walk(base):
            for fn in sorted(fnames):
                if not fn.lower().endswith(PHOTO_EXT):
                    continue
                full = os.path.join(root, fn)
                rel = os.path.relpath(full, pdir).replace('\\', '/')
                folder = os.path.dirname(rel)
                m = re.search(r'(20\d{6})', fn)      # 파일명 앞 타임스탬프 = 찍은 날
                day = ('%s-%s-%s' % (m.group(1)[:4], m.group(1)[4:6], m.group(1)[6:8])) if m else '날짜미상'
                out.append({'rel': rel, '폴더': folder, '날짜': day, '파일': fn})
    return out

def load_photos():
    """썸네일(전체) + 고해상(연결된 것만) 을 embed 용으로 만든다."""
    if not product_dirs():
        return
    Image = _pil()
    if Image is None:
        say('[사진] Pillow 가 없어 사진을 싣지 못한다 — `pip install pillow` 후 다시 빌드하라')
        return
    for pdir in product_dirs():
        pdir = os.path.abspath(pdir)
        pname = os.path.basename(pdir.rstrip('\\/'))
        shots = photo_scan(pdir)
        if not shots:
            say('[사진] %s — 사진 폴더가 없다 (정상: 사진 없는 제품)' % pname)
            continue
        # 어떤 사진이 부품에 연결돼 있나 — 제품 폴더의 사진연결.json 이 원본이다.
        linked = set()
        for _lf in ['공정이력.json', '사진연결.json']:
            lp = os.path.join(pdir, PROC_DIRNAME, _lf)
            if not os.path.exists(lp):
                continue
            try:
                j = json.loads(rd(lp))
                for r in (j.get('사진연결') or j.get('links') or []):
                    if r.get('photo'):
                        linked.add(r['photo'])
            except Exception as e:
                say('[사진] %s — %s 를 못 읽었다: %s' % (pname, _lf, e))

        base = PHOTO_PREFIX + pname + '/photos/'
        nth, nfull, bth, bfull = 0, 0, 0, 0
        for s in shots:
            full = os.path.join(pdir, s['rel'].replace('/', os.sep))
            # ① 썸네일 — 웹게시용은 선택기를 통째로 빼므로 만들지 않는다
            if not WEB:
                d = photo_resize(full, THUMB_W, THUMB_Q)
                if d:
                    virtual_bin[base + 'th/' + s['rel']] = d
                    s['썸네일'] = base + 'th/' + s['rel']
                    nth += 1
                    bth += len(d)
            # ② 고해상 — 연결된 사진만. 연결이 는 만큼만 용량이 는다.
            if s['rel'] in linked:
                d = photo_resize(full, FULL_W, FULL_Q)
                if d:
                    virtual_bin[base + 'big/' + s['rel']] = d
                    s['큰사진'] = base + 'big/' + s['rel']
                    nfull += 1
                    bfull += len(d)
        virtual[PHOTO_PREFIX + pname + '/photos_index.json'] = json.dumps(
            {'제품': pname, '장수': len(shots), '선택기포함': (not WEB),
             '연결': len(linked), '사진': shots}, ensure_ascii=False)
        say('[사진] %s — 전체 %d장 / 썸네일 %d장 %.1fMB / 연결 고해상 %d장 %.1fMB%s'
            % (pname, len(shots), nth, bth / 1048576, nfull, bfull / 1048576,
               ' (웹게시용: 선택기 제외)' if WEB else ''))

load_photos()

# ── 3. base64 로 묶기 (웹게시용은 gzip 먼저) ───────────────────────────
def pack(data):
    if WEB:
        data = gzip.compress(data, 9)
    return base64.b64encode(data).decode('ascii')

emb, raw_bytes = {}, 0
for rel, full in files:
    data = patched[rel].encode('utf-8') if rel in patched else rd(full, b=True)
    raw_bytes += len(data)
    emb[rel] = pack(data)
for rel, txt in virtual.items():                       # 캐나다 부위 조각(디스크에 없는 가상 파일)
    data = txt.encode('utf-8')
    raw_bytes += len(data)
    emb[rel] = pack(data)
for rel, data in virtual_bin.items():                  # 사진(JPEG) — 글자가 아니라 바이트다
    raw_bytes += len(data)
    emb[rel] = pack(data)
for rel, txt in patched.items():                       # 혹시 walk 에서 빠졌다면 보강
    if rel not in emb:
        emb[rel] = pack(txt.encode('utf-8'))
if rev_block:                     # 오늘 모델 부품이 정말 HTML 안에 들어갔는지 확인
    _m2 = [p['file'] for p in rev_block['parts'] if p['file'].replace('\\', '/') not in emb]
    if _m2:
        raise SystemExit('[빌드 중단] 오늘 모델 부품 %d 개가 embed 에 없다: %s' % (len(_m2), _m2[:3]))
    say('오늘 모델 부품 embed 확인: %d / %d 개' % (len(rev_block['parts']), len(rev_block['parts'])))
say('원본 합계 %.1f MB → %s %.1f MB'
    % (raw_bytes / 1048576, 'gzip+base64' if WEB else 'base64',
       sum(len(v) for v in emb.values()) / 1048576))

# ── 4. HTML 조립 ───────────────────────────────────────────────────────
# ── 쉼 구간 표시(@FABOFF-MARK) 다루기 ──────────────────────────────────
#   [리팩토링 1단계 2026-09-14] 예전에는 주석 문구를 글자로 찾아 잘랐다
#     (s.index('/* ── 0. 저장소 안전망') …). 주석을 한 글자만 손대도 조용히
#     엉뚱한 구간이 잘리거나 빌드가 죽는 구조였다.
#   → 이제는 shim.js 안의 명시적 표시를 찾는다. 표시가 없으면 '조용히 통과'가
#     아니라 즉시 멈추고 이유를 말한다(잘못 자른 산출물을 내놓는 것보다 낫다).
MARK_START = '/* @FABOFF-MARK %s:start'
MARK_END   = '/* @FABOFF-MARK %s:end */'

def mark_span(text, name):
    """표시된 구간의 (시작, 끝) 위치. 끝은 end 표시의 뒤까지 포함한다."""
    a_pat, b_pat = MARK_START % name, MARK_END % name
    a, b = text.find(a_pat), text.find(b_pat)
    if a < 0 or b < 0:
        raise SystemExit('[빌드 중단] shim.js 에서 구간 표시를 못 찾음: %s '
                         '(start=%d, end=%d). @FABOFF-MARK 주석을 지웠는지 확인하라.'
                         % (name, a, b))
    if b < a:
        raise SystemExit('[빌드 중단] 구간 표시 순서가 뒤집힘: %s' % name)
    return a, b + len(b_pat)

# ── 쉼을 기능별 파일로 나눠 싣는다 [리팩토링 3단계 2026-09-15] ──────────
#   왜 — shim.js 한 파일이 2,300줄까지 커졌다. 구획은 뚜렷하지만 새 기능이
#     붙을 때마다 '모두가 고치는 한 파일'이 된다(구조감사 권고 4-1).
#   불변식 3가지. 깨지면 아이패드에서 흰 화면이 된다:
#     ① 00_storage.js 는 무조건 맨 앞 — 다른 코드가 localStorage 를 만지기 전에
#        안전망이 걸려야 한다.
#     ② 전부 '하나의 <script> 태그'로 이어 붙인다 — 실행 순서 보장, verify 추출 유지.
#     ③ 목록은 아래 명시 리스트로만 — os.listdir() 정렬에 기대면 파일 하나 추가할 때
#        순서가 조용히 바뀐다.
#   ※ 10_core.js 가 IIFE 를 열고 80_asmlock.js 가 닫는다. 즉 가운데 파일들은
#     '혼자서는 문법이 안 맞는 조각'이다(의도된 것이다 — 이어 붙여야 완성된다).
#     그래서 문법 검사는 낱개가 아니라 '이어 붙인 결과'에 해야 한다.
SHIM_DIR = os.path.join(SRC, '_build', 'shim')
SHIM_ORDER = [
    '00_storage.js',      # 저장소 안전망 — 반드시 맨 앞(다른 코드가 localStorage 를 만지기 전)
    '01_errbanner.js',    # 부팅 오류 화면 배너
    '10_core.js',         # ▼ 여기서 큰 IIFE 를 연다. 임베드 해제·사진/녹음 보관소·경로 키찾기
    '11_fetchsave.js',    # 응답 만들기 + 저장(POST) 라우팅 + <img> 가로채기
    '20_record.js',       # 회의 녹음 + 패널 UI
    '21_timeline.js',     # 타임라인 패널
    '30_measure.js',      # 치수 상태·적중 판정 + CAD 스냅
    '31_measure_ui.js',   # 치수 라벨·조작 줄·켜고 끄기
    '32_offlimit.js',     # 오프라인 불가 안내 + 죽은 버튼 가림 + 로더 감싸기
    '33_fetchhook.js',    # window.fetch 갈아끼우기(위 두 파일이 다 정의된 뒤에 실행돼야 한다)
    '40_backup.js',       # 내보내기 / 가져오기
    '50_toolbar.js',      # 메모리모드 경고 · 토스트 · 아이콘 · 툴바 UI
    '51_registry.js',     # 등록소(툴·절차·키) + ui() + 기본 툴 등록
    '60_fieldmode.js',    # 현장 모드 + 첫 안내 3장 + 초기화 절차(lateSetup)
    '65_process.js',      # 공정서 탭(제품 폴더에서 읽는다) + 정반 깔기
    '66_order.js',        # 순서 편집 모드(3D 탭 → 취부 순서) + 공정이력 장부
    '67_partcard.js',     # 부품 카드 + 이력 입력 + 이력 마커
    '68_photo.js',        # 사진 첨부(썸네일 그리드) + 공정이력 내보내기
    '69_stepview.js',     # 공정순서 열람 모드 — 자동 재생 없음 · 고스트 · 카메라 복원
    '70_asmlock.js',      # 조립재생 잠금 + boot() + FAB_OFFLINE 내보내기 ▲ 여기서 IIFE 를 닫는다
]

def shim_files():
    """SHIM_ORDER 와 shim/ 폴더 실제 내용이 어긋나면 즉시 멈춘다.
       목록에서 빠진 파일은 '조용히 안 실린다' — 가장 잡기 어려운 사고라서 여기서 막는다."""
    have = sorted(f for f in os.listdir(SHIM_DIR) if f.endswith('.js'))
    want = sorted(SHIM_ORDER)
    if have != want:
        raise SystemExit('[빌드 중단] shim/ 폴더와 SHIM_ORDER 가 다르다.\n'
                         '  폴더에만 있음: %s\n  목록에만 있음: %s'
                         % (sorted(set(have) - set(want)), sorted(set(want) - set(have))))
    return [os.path.join(SHIM_DIR, f) for f in SHIM_ORDER]

def shim_src():
    """--nofix : 저장소 안전망 파일만 빼는 '대조군'. 원인 규명을 위한 시험용으로만 쓴다.
       예전엔 글자(주석)를 찾아 잘랐다 → 이제는 '파일 하나를 뺀다'. 훨씬 덜 깨진다."""
    nofix = '--nofix' in sys.argv
    out = []
    for path in shim_files():
        s = rd(path)
        # 안전망은 '표시(@FABOFF-MARK)가 들어 있는 파일'에서 들어낸다.
        #   파일명을 박아 두지 않는 이유 — 쪼개는 도중에도 대조군이 계속 돌아야 한다.
        if nofix and (MARK_START % 'storage-guard') in s:
            a, b = mark_span(s, 'storage-guard')
            say('[대조군] 저장소 안전망 제거 —', os.path.basename(path), b - a, '글자')
            # 뒤 코드가 참조하는 표시 변수는 남긴다
            # (안 그러면 안전망이 아니라 ReferenceError 를 시험하게 된다).
            s = s[:a] + 'var __FABOFF_MEM={ls:false,ss:false,idb:false,why:""};\n' + s[b:]
        out.append(s)
    joined = ''.join(out)
    if not nofix:
        mark_span(joined, 'storage-guard')   # 정상 빌드에서는 안전망 존재를 늘 확인한다
    return joined

def safe(js):
    """인라인 <script> 안에서 </script 가 태그를 끊어버리는 것을 막는다."""
    return js.replace('</script', '<\\/script').replace('<!--', '<\\!--')

html = rd(os.path.join(SRC, 'fab.html'))

missing_js = []
def inline(m):
    src = m.group(1)
    p = os.path.join(SRC, src.replace('/', os.sep))
    # [2026-09-17 --rev0917] 화이트리스트에 든 파일 하나만 상위 원본에서 읽는다.
    #   '더 새것을 쓴다' 같은 규칙은 쓰지 않는다 — 지금 수정 중인 fab_process.js 가
    #   반쪽 상태로 실려 흰 화면이 되는 사고를 막기 위해 이름을 박아 둔다.
    if os.path.basename(src) in PARENT_JS:
        _pp = os.path.join(os.path.dirname(SRC), src.replace('/', os.sep))
        if os.path.isfile(_pp):
            p = _pp
            say('[상위 원본에서 인라인]', src, '(조작감 수정본)')
        else:
            say('[경고] 상위 원본에 없어 오프라인 사본을 쓴다:', src)
    if not os.path.isfile(p):
        missing_js.append(src); return '<!-- [오프라인] 없는 스크립트: %s -->' % src
    return '<script>/* ===== %s ===== */\n%s\n</script>' % (src, safe(rd(p)))

html, n = re.subn(r'<script\s+src="([^"]+)"\s*></script>', inline, html)
say('인라인한 스크립트:', n, '개', ('/ 없음: ' + ', '.join(missing_js)) if missing_js else '')

# 데이터를 '실행되는 자바스크립트'가 아니라 '읽기만 하는 JSON'으로 넣는다.
#   34MB 를 JS 객체 리터럴로 두면 아이패드 사파리가 파싱에 오래 매달리고
#   메모리도 더 쓴다. JSON.parse 가 훨씬 빠르고 가볍다.
#   JSON 안에서 '</' 는 '<\/' 로 써도 똑같은 값이라 태그가 끊기지 않는다.
emb_json = json.dumps(emb, ensure_ascii=True, separators=(',', ':')).replace('</', '<\\/')
# [리팩토링 1단계 2026-09-14] 세 <script> 에 id 를 붙인다.
#   검증(verify.js)이 예전에는 '<script> 를 몇 번째로 만나는지'를 세어 쉼을 떼어냈다.
#   (indexOf 를 연쇄로 걸어 위치를 계산 → 스크립트를 하나만 끼워 넣어도 어긋난다)
#   id 로 찾으면 순서가 바뀌어도 정확히 그 조각을 집는다.
head = ('<script type="application/json" id="__fab_embed">' + emb_json + '</script>\n' +
        '<script id="__fab_boot">window.__FAB_EMBED__=JSON.parse(' +
        'document.getElementById("__fab_embed").textContent);' +
        'window.__FAB_EMBED_GZ__=' + ('true' if WEB else 'false') + ';</script>\n' +
        '<script id="__fab_shim">' + safe(shim_src()) + '</script>\n')
# 쉼은 다른 어떤 스크립트보다 먼저 — charset 메타 바로 뒤에 끼운다.
anchor = '<meta charset="utf-8">'
assert anchor in html, 'charset 메타를 못 찾음'
html = html.replace(anchor, anchor + '\n' + head, 1)

with open(OUT, 'w', encoding='utf-8', newline='\n') as f:   # BOM 없이 UTF-8
    f.write(html)

sz = os.path.getsize(OUT)
say('산출:', OUT)
say('크기: %.1f MB' % (sz / 1048576))
say('남은 <script src=:', len(re.findall(r'<script\s+src=', html)))
json.dump({'out': OUT, 'size': sz, 'embed': len(emb), 'log': log},
          open(os.path.join(SRC, '_build', 'build_report.json'), 'w', encoding='utf-8'),
          ensure_ascii=False, indent=1)
