# -*- coding: utf-8 -*-
r"""2110DN-0031 CENTER FRAME  수동 조립 뷰어(E500PRO_수동조립.html) 생성기.

asm_viewer.py 와 별도 파일인 이유: asm_viewer.py 는 '읽기 전용 조립 시범'(E500PRO_조립시범.html)을
만드는 도구이고 그 결과물은 그대로 보존해야 한다. 이 스크립트는 CEO 가 마우스로 부재를
직접 움직여 배치를 확정하고 JSON 으로 내보내는 '편집 도구'를 만든다. 기능 목적이 달라
기존 스크립트를 고치면 시범 뷰어가 깨지므로 신규 생성한다. (사본 v2 아님)

산출물
  <scratchpad>\E500PRO_수동조립.html   : 자기완결형(three.js + OBJ 전부 인라인, 외부 로드 0)
  <scratchpad>\_manual_core.js         : 핵심 수학(기즈모 드래그·스냅·실행취소) node 단위검증용
"""
import json, os, io

SCR = r'C:\Users\user\AppData\Local\Temp\claude\E-----Claude--20260728-claude-code-2-1-219\8defc145-8ae5-48d0-9058-355f3d9ae8ed\scratchpad'
ROOT = r'E:\도진팩토리\3D스캔및티칭시스템'
ASM = ROOT + r'\fab_models\assemblies\2110DN-0031_assembly.json'
OUT = SCR + r'\E500PRO_수동조립.html'
CORE_OUT = SCR + r'\_manual_core.js'

COL = ['#4f8cff', '#ff8a3d', '#3ecf8e', '#e05c8a', '#c78bff',
       '#f2c14e', '#5fd0d8', '#9aa7b8', '#ff6b6b', '#7ee081', '#d4a5ff',
       '#8ecbff', '#ffd98e']

# ---------------------------------------------------------------- 핵심 수학
# HTML 과 node 테스트가 '같은 원본'을 쓰도록 이 문자열 하나만 유지한다.
CORE = u'''
// ===== 핵심 수학 (HTML 과 node 단위테스트 공용) =====
// 카메라 광선(o,d 단위벡터)과 축 직선(p, a 단위벡터)의 최근접점 파라미터 t.
// 시선이 축과 거의 평행하면 해가 폭주하므로 null 을 돌려 드래그를 무시한다.
function axisDragT(o, d, p, a){
  var b = a[0]*d[0] + a[1]*d[1] + a[2]*d[2];
  var den = 1 - b*b;
  if (Math.abs(den) < 1e-3) return null;
  var w = [p[0]-o[0], p[1]-o[1], p[2]-o[2]];
  var wa = w[0]*a[0] + w[1]*a[1] + w[2]*a[2];
  var wd = w[0]*d[0] + w[1]*d[1] + w[2]*d[2];
  return (b*wd - wa) / den;
}

// 면 스냅: 이동 중 박스 mov 를 axis(0=X,1=Y,2=Z) 방향으로 얼마나 보정할지.
// 나머지 두 축이 겹치는 상대 부재만 대상으로, 마주보는 면/같은 면 4가지 후보 중
// |보정량| 이 tol 이내이면서 가장 작은 것을 고른다. 없으면 0.
function snapDelta(mov, others, axis, tol){
  var best = 0, bestAbs = tol + 1e-9, i, k, o, c, cand;
  var u = (axis + 1) % 3, v = (axis + 2) % 3;
  for (i = 0; i < others.length; i++){
    o = others[i];
    if (o.min[u] > mov.max[u] + tol || o.max[u] < mov.min[u] - tol) continue;
    if (o.min[v] > mov.max[v] + tol || o.max[v] < mov.min[v] - tol) continue;
    cand = [o.min[axis] - mov.max[axis], o.max[axis] - mov.min[axis],
            o.min[axis] - mov.min[axis], o.max[axis] - mov.max[axis]];
    for (k = 0; k < 4; k++){
      c = cand[k];
      if (Math.abs(c) < bestAbs){ bestAbs = Math.abs(c); best = c; }
    }
  }
  return best;
}

// 실행취소 스택 (기본 50단계 — 요구사항 최소 20단계 충족)
function UndoStack(limit){
  this.limit = limit || 50; this.a = [];
}
UndoStack.prototype.push = function(x){
  this.a.push(x); if (this.a.length > this.limit) this.a.shift();
};
UndoStack.prototype.pop = function(){ return this.a.length ? this.a.pop() : null; };
UndoStack.prototype.size = function(){ return this.a.length; };
UndoStack.prototype.clear = function(){ this.a = []; };

if (typeof module !== 'undefined' && module.exports){
  module.exports = { axisDragT: axisDragT, snapDelta: snapDelta, UndoStack: UndoStack };
}
'''


def read_obj(p):
    V, F = [], []
    for ln in io.open(p, encoding='utf-8', errors='replace'):
        if ln.startswith('v '):
            V.append([float(x) for x in ln.split()[1:4]])
        elif ln.startswith('f '):
            idx = [int(t.split('/')[0]) - 1 for t in ln.split()[1:]]
            for k in range(1, len(idx) - 1):
                F += [idx[0], idx[k], idx[k + 1]]
    return V, F


def build():
    meta = json.load(io.open(ASM, encoding='utf-8'))
    parts, raw = [], []
    for i, p in enumerate(meta['parts']):
        fp = os.path.join(ROOT, p['obj'].replace('/', os.sep))
        if not os.path.exists(fp):
            print(u'!! OBJ 없음, 건너뜀:', fp)
            continue
        V, F = read_obj(fp)
        parts.append({'id': len(parts),            # ← 인스턴스 고유키(항번은 2EA 중복이라 못 씀)
                      'item': p['item'], 'pn': p['pn'], 'size': p['size_mm'],
                      'rot': p['rot_deg_xyz'], 'pos': p['pos_mm'],
                      'conf': p['confidence'], 'ceo': p['ceo_check'],
                      'ev': p['evidence'], 'note': p['note'],
                      'col': COL[len(parts) % len(COL)],
                      'v': [round(c, 2) for xyz in V for c in xyz], 'f': F})
        raw.append(p)

    three = io.open(ROOT + r'\threejs\three.min.js', encoding='utf-8',
                    errors='replace').read().lstrip(u'\ufeff')

    D = {'meta': {k: v for k, v in meta.items() if k != 'parts'},
         'parts': parts, 'raw': raw}

    H = []
    H.append(HEAD)
    H.append(json.dumps(D, ensure_ascii=False))
    H.append(u'</script>\n<script>\n')
    H.append(three)
    H.append(u'\n</script>\n<script>\n')
    H.append(CORE)
    H.append(APP)

    io.open(OUT, 'w', encoding='utf-8').write(u''.join(H))
    io.open(CORE_OUT, 'w', encoding='utf-8').write(CORE)
    print(OUT, os.path.getsize(OUT) // 1024, 'KB /', len(parts), u'부재 인스턴스')
    return len(parts)


HEAD = u'''<!DOCTYPE html><html lang="ko"><head><meta charset="utf-8">
<title>2110DN-0031 CENTER FRAME 수동 조립</title><style>
html,body{margin:0;height:100%;overflow:hidden;background:#10141a;font-family:"Malgun Gothic",sans-serif;color:#e8eef6}
#side{position:fixed;left:0;top:0;bottom:0;width:310px;z-index:20;background:#151a21;
 border-right:1px solid #232c37;display:flex;flex-direction:column}
#side h1{margin:0;padding:12px 12px 6px;font-size:14px}
#side h1 span{display:block;font-size:11px;color:#7b8ea3;margin-top:4px;font-weight:normal;line-height:1.5}
#list{flex:1;overflow-y:auto;padding:6px 8px 10px}
.row{display:flex;align-items:center;gap:6px;margin:4px 0}
.row input{flex:none;cursor:pointer}
.row button{flex:1;text-align:left;background:#1b2530;color:#c9d6e4;border:1px solid #2c3a49;
 border-left:5px solid #444;border-radius:6px;padding:6px 8px;font-size:12px;cursor:pointer;
 font-family:inherit;line-height:1.45}
.row button:hover{background:#243244}
.row button.on{background:#2f6feb;color:#fff}
.row button small{display:block;color:#8fa3b8;font-size:11px}
.row button.on small{color:#dbe7ff}
.row button.ceo small b{color:#ffb545}
#tools{border-top:1px solid #232c37;padding:8px 10px 10px;font-size:12px;color:#9fb2c6}
#tools b{color:#e8eef6}
#tools .g{margin:6px 0}
#tools button{background:#22303f;color:#dbe7ff;border:1px solid #35485c;border-radius:5px;
 padding:4px 8px;margin:2px 2px 0 0;font-size:12px;cursor:pointer;font-family:inherit}
#tools button:hover{background:#2f4257}
#tools button.ax{width:30px}
#tools button.ax.on{background:#2f6feb;border-color:#2f6feb;color:#fff}
#save{width:100%;background:#2f8f4f !important;border-color:#3aa85e !important;
 padding:8px !important;font-size:13px !important;margin-top:6px !important}
#top{position:fixed;left:310px;right:0;top:0;height:34px;z-index:15;background:#1b2a1e;
 border-bottom:1px solid #2c4432;color:#a8e6b8;font-size:12.5px;line-height:34px;padding-left:14px}
#info{position:fixed;left:326px;top:44px;right:14px;z-index:10;pointer-events:none;
 font-size:13px;line-height:1.6;text-shadow:0 1px 3px #000}
#info b{font-size:17px}
#info em{color:#ffb545;font-style:normal}
#hint{position:fixed;right:14px;bottom:10px;color:#6f8299;font-size:11.5px;z-index:10;text-align:right;line-height:1.6}
#dump{position:fixed;left:50%;top:50%;transform:translate(-50%,-50%);width:70%;height:60%;z-index:60;
 display:none;background:#0d1117;border:1px solid #35485c;border-radius:8px;padding:10px}
#dump textarea{width:100%;height:calc(100% - 30px);background:#0d1117;color:#c9d6e4;border:0;
 font-family:Consolas,monospace;font-size:11px}
</style></head><body>
<div id="side">
 <h1>2110DN-0031 수동 조립<span>부재를 클릭 → 화살표를 끌어서 이동. 반투명 = [CEO확인] 필요</span></h1>
 <div id="list"></div>
 <div id="tools">
  <div class="g"><b>회전축</b>
   <button class="ax on" data-ax="0">X</button><button class="ax" data-ax="1">Y</button><button class="ax" data-ax="2">Z</button>
   <button id="r90m">-90&deg;</button><button id="r90p">+90&deg;</button>
   <button id="r15m">-15&deg;</button><button id="r15p">+15&deg;</button></div>
  <div class="g"><b>보기</b>
   <button data-v="front">정면</button><button data-v="top">평면</button>
   <button data-v="side">측면</button><button data-v="iso">입체</button>
   <button id="fit">전체맞춤</button></div>
  <div class="g"><label><input type="checkbox" id="snapchk" checked> 면 스냅(5mm) 켜기</label>
   <button id="undo">실행취소 (Ctrl+Z)</button></div>
  <button id="save">배치 저장 (JSON 내려받기)</button>
  <div class="g"><button id="showtxt" style="width:100%">내려받기가 안 되면 여기 → 글자로 보기</button></div>
  <div class="g" style="font-size:11px;color:#7b8ea3">자동백업: 이 브라우저에 계속 남습니다.
   <button id="reset" style="font-size:11px">처음 배치로</button></div>
 </div>
</div>
<div id="top">저장한 파일을 Claude에게 주시면 반영됩니다. (배치 저장 → 2110DN-0031_assembly_CEO.json)</div>
<div id="info"></div>
<div id="hint">빈곳 드래그=회전 &middot; 휠=확대 &middot; 우클릭드래그=화면이동<br>
부재 클릭=선택 &middot; 화살표 끌기=이동 &middot; 키보드 &larr;&uarr;&rarr;&darr;/PgUp/PgDn=1mm(Shift=10mm)</div>
<div id="dump"><textarea readonly></textarea>
 <button onclick="document.getElementById('dump').style.display='none'">닫기</button>
 <span style="color:#9fb2c6;font-size:11px">&nbsp;내려받기가 막히면 이 글자를 전부 복사해서 주세요.</span></div>
<script id="ASM" type="application/json">'''


APP = u'''
// ================= 화면 구성 =================
var D = JSON.parse(document.getElementById('ASM').textContent);
var TOL = 5.0;                       // 스냅 허용오차 5mm
var scene = new THREE.Scene(); scene.background = new THREE.Color(0x10141a);
var W = innerWidth, Hh = innerHeight;
var cam = new THREE.PerspectiveCamera(45, W/Hh, 1, 60000);
var rnd = new THREE.WebGLRenderer({antialias:true}); rnd.setSize(W,Hh);
document.body.appendChild(rnd.domElement);
rnd.domElement.style.position='fixed'; rnd.domElement.style.left='0'; rnd.domElement.style.top='0';
scene.add(new THREE.AmbientLight(0xffffff,0.62));
var dl=new THREE.DirectionalLight(0xffffff,0.85); dl.position.set(1,1.4,1); scene.add(dl);
var dl2=new THREE.DirectionalLight(0xffffff,0.35); dl2.position.set(-1,-0.6,-1); scene.add(dl2);
var root=new THREE.Group(); scene.add(root);
root.rotation.x = -Math.PI/2;        // 도면 Z-up → 화면 Y-up

var meshes=[], HOME=[];
D.parts.forEach(function(p){
  var g=new THREE.BufferGeometry();
  g.setAttribute('position', new THREE.Float32BufferAttribute(p.v,3));
  g.setIndex(p.f); g.computeVertexNormals(); g.computeBoundingBox();
  var m=new THREE.MeshLambertMaterial({color:new THREE.Color(p.col),
        transparent:p.ceo, opacity:p.ceo?0.45:1.0, side:THREE.DoubleSide});
  var o=new THREE.Mesh(g,m);
  o.rotation.set(p.rot[0]*Math.PI/180, p.rot[1]*Math.PI/180, p.rot[2]*Math.PI/180);
  o.position.set(p.pos[0],p.pos[1],p.pos[2]);
  o.userData=p; root.add(o); meshes.push(o);
  // 외곽선은 반드시 mesh 의 '자식' — 부모를 따라 움직여야 잔상이 안 남는다
  var e=new THREE.LineSegments(new THREE.EdgesGeometry(g,25),
       new THREE.LineBasicMaterial({color:0x0b0e12,transparent:true,opacity:0.55}));
  o.add(e);
  HOME.push({pos:p.pos.slice(), rot:p.rot.slice()});
});

// 중심 보정은 최초 1회만 (매번 다시 잡으면 부재를 옮길 때마다 화면이 튄다).
// 보정은 root.position 에만 걸리므로 각 mesh.position 은 계속 도면 mm 좌표 그대로다 = 저장이 곧 원좌표.
var bb0=new THREE.Box3().setFromObject(root);
var ctr0=bb0.getCenter(new THREE.Vector3()), sz0=bb0.getSize(new THREE.Vector3());
var R0=Math.max(sz0.x,sz0.y,sz0.z)||500;
root.position.sub(ctr0);

// ================= 이동 기즈모 =================
var AXCOL=[0xff4d4d,0x4dff7a,0x4d9cff];          // X 빨강 / Y 초록 / Z 파랑
var AXV=[new THREE.Vector3(1,0,0),new THREE.Vector3(0,1,0),new THREE.Vector3(0,0,1)];
var giz=new THREE.Group(); giz.visible=false; root.add(giz);
var arrows=[];
for (var ai=0; ai<3; ai++){
  var mt=new THREE.MeshBasicMaterial({color:AXCOL[ai],depthTest:false,transparent:true,opacity:0.95});
  var sh=new THREE.Mesh(new THREE.CylinderGeometry(0.035,0.035,1,10), mt);
  sh.position.y=0.5;
  var hd=new THREE.Mesh(new THREE.ConeGeometry(0.11,0.3,14), mt);
  hd.position.y=1.15;
  var arm=new THREE.Group(); arm.add(sh); arm.add(hd);
  if (ai===0) arm.rotation.z=-Math.PI/2;          // +X
  if (ai===2) arm.rotation.x= Math.PI/2;          // +Z
  arm.userData={axis:ai};
  sh.userData={axis:ai}; hd.userData={axis:ai};
  sh.renderOrder=999; hd.renderOrder=999;
  giz.add(arm); arrows.push(sh); arrows.push(hd);
}

// ================= 카메라 조작 =================
var st={th:0.9, ph:0.95, r:R0*2.6, px:0, py:0};
function upd(){
  cam.position.set(st.r*Math.sin(st.ph)*Math.cos(st.th)+st.px,
                   st.r*Math.cos(st.ph)+st.py,
                   st.r*Math.sin(st.ph)*Math.sin(st.th));
  cam.lookAt(st.px,st.py,0);
}
upd();

// ================= 상태 =================
var sel=-1, undoS=new UndoStack(50);
var drag=null;        // 기즈모 드래그
var orb={on:false,pan:false,x:0,y:0,moved:0};
var ray=new THREE.Raycaster(), mo=new THREE.Vector2();
ray.params.Line.threshold=0;

function boxOf(o){                       // 도면(root local) 좌표계 AABB
  o.updateMatrix();
  var b=new THREE.Box3().copy(o.geometry.boundingBox).applyMatrix4(o.matrix);
  return {min:[b.min.x,b.min.y,b.min.z], max:[b.max.x,b.max.y,b.max.z]};
}
function ctrOf(o){ var b=boxOf(o);
  return new THREE.Vector3((b.min[0]+b.max[0])/2,(b.min[1]+b.max[1])/2,(b.min[2]+b.max[2])/2); }

function snapshot(i){ var o=meshes[i];
  undoS.push({i:i, p:o.position.clone(), q:o.quaternion.clone()}); }

function localRay(ev){                   // 화면 좌표 → root 로컬 광선
  mo.x=ev.clientX/W*2-1; mo.y=-ev.clientY/Hh*2+1;
  ray.setFromCamera(mo,cam);
  root.updateMatrixWorld();
  var inv=new THREE.Matrix4().copy(root.matrixWorld).invert();
  var o=ray.ray.origin.clone().applyMatrix4(inv);
  var d=ray.ray.direction.clone().transformDirection(inv).normalize();
  return {o:[o.x,o.y,o.z], d:[d.x,d.y,d.z]};
}

function select(i){
  sel=i;
  giz.visible = (i>=0);
  if (i>=0){ var c=ctrOf(meshes[i]); giz.position.copy(c); }
  [].forEach.call(document.querySelectorAll('#list button'), function(b){
    b.classList.toggle('on', +b.dataset.id === i); });
  var e=document.getElementById('info');
  if (i<0){ e.innerHTML=''; return; }
  var p=meshes[i].userData, q=meshes[i].position;
  e.innerHTML='<b>'+p.pn+'</b> <span style="color:#8fa3b8">항번 '+p.item+' &middot; '
    +p.size.join(' x ')+' mm</span>'
    +(p.ceo?'<br><em>[CEO확인] 신뢰도 '+p.conf+'</em>':'<br><span style="color:#3ecf8e">신뢰도 '+p.conf+'</span>')
    +'<br><span style="color:#9fb2c6">현재 위치 X '+q.x.toFixed(1)+' / Y '+q.y.toFixed(1)+' / Z '+q.z.toFixed(1)+' mm</span>'
    +'<br><span style="color:#8a99ab">근거: '+p.ev+'</span>';
}
function refreshInfo(){ if(sel>=0) select(sel); }

// ================= 마우스 =================
var cv=rnd.domElement;
cv.addEventListener('contextmenu', function(e){e.preventDefault();});
cv.addEventListener('mousedown', function(e){
  if (e.button===0 && giz.visible){
    mo.x=e.clientX/W*2-1; mo.y=-e.clientY/Hh*2+1; ray.setFromCamera(mo,cam);
    var hit=ray.intersectObjects(arrows,false);
    if (hit.length){
      var ax=hit[0].object.userData.axis;
      var L=localRay(e);
      var a=[AXV[ax].x,AXV[ax].y,AXV[ax].z];
      var pv=meshes[sel].position;
      var t0=axisDragT(L.o,L.d,[pv.x,pv.y,pv.z],a);
      if (t0===null) return;                 // 축이 시선과 평행 — 무시
      snapshot(sel);                          // 실행취소는 드래그 '시작'에 1번만
      drag={ax:ax, t0:t0, start:pv.clone()};
      return;
    }
  }
  orb.on=true; orb.pan=(e.button===2); orb.x=e.clientX; orb.y=e.clientY; orb.moved=0;
});
addEventListener('mousemove', function(e){
  if (drag){
    var L=localRay(e);
    var a=[AXV[drag.ax].x,AXV[drag.ax].y,AXV[drag.ax].z];
    var s=[drag.start.x,drag.start.y,drag.start.z];
    var t=axisDragT(L.o,L.d,s,a);
    if (t===null) return;
    // 항상 '드래그 시작 위치 + 이동량' 으로 다시 계산 → 스냅이 누적되어 커서에서 밀리는 현상 방지
    var o=meshes[sel];
    o.position.set(s[0]+a[0]*(t-drag.t0), s[1]+a[1]*(t-drag.t0), s[2]+a[2]*(t-drag.t0));
    if (document.getElementById('snapchk').checked){
      var mv=boxOf(o), others=[];
      for (var i=0;i<meshes.length;i++) if (i!==sel && meshes[i].visible) others.push(boxOf(meshes[i]));
      var dd=snapDelta(mv,others,drag.ax,TOL);
      if (dd) o.position.setComponent(drag.ax, o.position.getComponent(drag.ax)+dd);
    }
    giz.position.copy(ctrOf(o));
    refreshInfo();
    return;
  }
  if (!orb.on) return;
  var dx=e.clientX-orb.x, dy=e.clientY-orb.y; orb.x=e.clientX; orb.y=e.clientY;
  orb.moved += Math.abs(dx)+Math.abs(dy);
  if (orb.pan){ st.px-=dx*st.r/900; st.py+=dy*st.r/900; }
  else { st.th+=dx*0.008; st.ph=Math.max(0.05,Math.min(3.09,st.ph-dy*0.008)); }
  upd();
});
addEventListener('mouseup', function(e){
  if (drag){ drag=null; save(); return; }
  var wasOrbit = orb.on && orb.moved>4;      // 4px 이상 움직였으면 '회전'이지 클릭 아님
  orb.on=false;
  if (wasOrbit || e.button!==0) return;
  mo.x=e.clientX/W*2-1; mo.y=-e.clientY/Hh*2+1; ray.setFromCamera(mo,cam);
  var h=ray.intersectObjects(meshes,false);
  if (h.length){ select(meshes.indexOf(h[0].object)); }
  else { select(-1); }
});
cv.addEventListener('wheel', function(e){ e.preventDefault();
  if (drag) return;                          // 드래그 중 확대하면 기준이 흔들려 부재가 튄다
  st.r *= (e.deltaY>0?1.12:0.89); upd(); }, {passive:false});

// ================= 이동/회전 명령 =================
function move(ax, mm){
  if (sel<0) return; snapshot(sel);
  var o=meshes[sel];
  o.position.setComponent(ax, o.position.getComponent(ax)+mm);
  giz.position.copy(ctrOf(o)); refreshInfo(); save();
}
var rotAx=0;
[].forEach.call(document.querySelectorAll('#tools button.ax'), function(b){
  b.onclick=function(){ rotAx=+b.dataset.ax;
    [].forEach.call(document.querySelectorAll('#tools button.ax'),function(x){
      x.classList.toggle('on', x===b); }); };
});
function rot(deg){
  if (sel<0) return; snapshot(sel);
  var o=meshes[sel], c=ctrOf(o);
  var q=new THREE.Quaternion().setFromAxisAngle(AXV[rotAx], deg*Math.PI/180);
  o.quaternion.premultiply(q);                 // 도면 좌표축 기준 회전
  o.position.sub(c).applyQuaternion(q).add(c); // 부재 '중심' 기준으로 돌린다
  giz.position.copy(ctrOf(o)); refreshInfo(); save();
}
document.getElementById('r90p').onclick=function(){rot(90);};
document.getElementById('r90m').onclick=function(){rot(-90);};
document.getElementById('r15p').onclick=function(){rot(15);};
document.getElementById('r15m').onclick=function(){rot(-15);};
document.getElementById('undo').onclick=function(){ doUndo(); };
function doUndo(){
  var u=undoS.pop(); if(!u) return;
  meshes[u.i].position.copy(u.p); meshes[u.i].quaternion.copy(u.q);
  select(u.i); save();
}
addEventListener('keydown', function(e){
  if (e.ctrlKey && (e.key==='z'||e.key==='Z')){ e.preventDefault(); doUndo(); return; }
  if (sel<0) return;
  var s=e.shiftKey?10:1, k=e.key;
  if (k==='ArrowLeft'){ move(0,-s); } else if (k==='ArrowRight'){ move(0,s); }
  else if (k==='ArrowUp'){ move(1,s); } else if (k==='ArrowDown'){ move(1,-s); }
  else if (k==='PageUp'){ move(2,s); } else if (k==='PageDown'){ move(2,-s); }
  else return;
  e.preventDefault();
});

// ================= 보기 =================
// root.rotation.x=-90도 이므로 도면(x,y,z) → 화면(x, z, -y).
// 정면 = 프레임 앞면(도면 Y=0) 쪽에서 보기 = 카메라가 도면 -Y 에 있어야 한다 → th=+90도.
var VIEWS={front:[Math.PI/2,Math.PI/2], side:[0,Math.PI/2], top:[Math.PI/2,0.02], iso:[0.9,0.95]};
[].forEach.call(document.querySelectorAll('#tools button[data-v]'), function(b){
  b.onclick=function(){ var v=VIEWS[b.dataset.v]; st.th=v[0]; st.ph=v[1]; upd(); };
});
document.getElementById('fit').onclick=function(){ st.r=R0*2.6; st.px=0; st.py=0; upd(); };

// ================= 목록 =================
var L=document.getElementById('list');
D.parts.forEach(function(p,i){
  var row=document.createElement('div'); row.className='row';
  var ck=document.createElement('input'); ck.type='checkbox'; ck.checked=true;
  ck.title='체크 해제 = 숨김';
  ck.onchange=function(){ meshes[i].visible=ck.checked;
    if(!ck.checked && sel===i) select(-1); };
  var b=document.createElement('button');
  b.style.borderLeftColor=p.col; b.dataset.id=i;
  if (p.ceo) b.className='ceo';
  b.innerHTML='항번 '+p.item+' &nbsp;'+p.pn+'<small>'+p.size.join(' x ')+' mm &middot; '
    +(p.ceo?'<b>[CEO확인] '+p.conf+'</b>':'신뢰도 '+p.conf)+'</small>';
  b.onclick=function(){ select(i); };
  row.appendChild(ck); row.appendChild(b); L.appendChild(row);
});

// ================= 저장 / 자동백업 =================
var KEY='e500pro_0031_manual_v1';
function snapAll(){
  return meshes.map(function(o){
    var e=new THREE.Euler().setFromQuaternion(o.quaternion,'XYZ');
    return {pos:[o.position.x,o.position.y,o.position.z],
            rot:[e.x*180/Math.PI, e.y*180/Math.PI, e.z*180/Math.PI]};
  });
}
function save(){ try{ localStorage.setItem(KEY, JSON.stringify(snapAll())); }catch(err){} }
function restore(){
  try{
    var s=localStorage.getItem(KEY); if(!s) return;
    var a=JSON.parse(s); if(!a || a.length!==meshes.length) return;
    a.forEach(function(r,i){
      meshes[i].position.set(r.pos[0],r.pos[1],r.pos[2]);
      meshes[i].rotation.set(r.rot[0]*Math.PI/180,r.rot[1]*Math.PI/180,r.rot[2]*Math.PI/180);
    });
  }catch(err){}
}
restore();

function buildJSON(){
  var out={}; for (var k in D.meta) out[k]=D.meta[k];
  out['수동조립_편집'] = 'CEO 가 3D 뷰어(E500PRO_수동조립.html)에서 직접 배치한 결과. ' +
    '저장시각 ' + new Date().toISOString() + '. pos_mm / rot_deg_xyz(XYZ 오일러, 도) 만 갱신됨.';
  var cur=snapAll();
  out.parts = D.raw.map(function(p,i){
    var q={}; for (var k2 in p) if (k2!=='bbox_global' && k2!=='ext_global') q[k2]=p[k2];
    q.pos_mm = cur[i].pos.map(function(v){return Math.round(v*100)/100;});
    q.rot_deg_xyz = cur[i].rot.map(function(v){return Math.round(v*100)/100;});
    q['_편집'] = (Math.abs(cur[i].pos[0]-p.pos_mm[0])+Math.abs(cur[i].pos[1]-p.pos_mm[1])
                 +Math.abs(cur[i].pos[2]-p.pos_mm[2]) > 0.01) ? 'CEO 이동함' : '초기값 유지';
    return q;
  });
  return JSON.stringify(out,null,1);
}
function showText(){
  var d=document.getElementById('dump');
  d.style.display='block';
  var ta=d.querySelector('textarea'); ta.value=buildJSON(); ta.removeAttribute('readonly');
  ta.focus(); ta.select();
}
// file:// 에서는 내려받기가 '조용히' 무시될 수 있다(예외도 안 남).
// 그래서 글자로 보기 버튼을 항상 보이게 둔다 — 이게 막히면 결과를 넘길 방법이 없어진다.
document.getElementById('showtxt').onclick=showText;
document.getElementById('save').onclick=function(){
  var txt=buildJSON();
  try{
    var bl=new Blob([txt],{type:'application/json;charset=utf-8'});
    var a=document.createElement('a');
    a.href=URL.createObjectURL(bl); a.download='2110DN-0031_assembly_CEO.json';
    document.body.appendChild(a); a.click(); document.body.removeChild(a);
    setTimeout(function(){ URL.revokeObjectURL(a.href); }, 4000);
  }catch(err){ showText(); }
};
document.getElementById('reset').onclick=function(){
  if(!confirm('처음(초벌) 배치로 되돌립니다. 계속할까요?')) return;
  meshes.forEach(function(o,i){
    o.position.set(HOME[i].pos[0],HOME[i].pos[1],HOME[i].pos[2]);
    o.rotation.set(HOME[i].rot[0]*Math.PI/180,HOME[i].rot[1]*Math.PI/180,HOME[i].rot[2]*Math.PI/180);
  });
  undoS.clear(); select(sel); save();
};

// ================= 루프 =================
addEventListener('resize', function(){ W=innerWidth; Hh=innerHeight;
  cam.aspect=W/Hh; cam.updateProjectionMatrix(); rnd.setSize(W,Hh); });
(function loop(){
  requestAnimationFrame(loop);
  // 화살표는 잡기 좋되 부재보다 길면 안 된다(부재 클릭을 가로채므로) — 조립체 최소폭보다 짧게 유지
  if (giz.visible){ var s=Math.min(st.r*0.07, 130); giz.scale.set(s,s,s); }
  rnd.render(scene,cam);
})();
console.log('[수동조립] 준비 완료 — 부재 '+meshes.length+'개, three r'+THREE.REVISION);
</script></body></html>'''


if __name__ == '__main__':
    build()
