﻿"""
도진웍스 LiDAR 티칭 툴 v2.1
- merged.glb(단일 병합 메시) 있으면 즉시 로드
- 없으면 단일 PLY 프레임 텍스처 메시로 fallback
- 더블클릭 티칭포인트 마킹 → JSON 저장
"""

import struct, base64, json, webbrowser, threading, time
from pathlib import Path
from http.server import HTTPServer, BaseHTTPRequestHandler

SCAN_BASE  = Path(r"E:\도진팩토리\3D스캔및티칭시스템")
OUTPUT_DIR = Path(r"E:\도진팩토리\3D스캔및티칭시스템\output")
OUTPUT_DIR.mkdir(exist_ok=True)
PORT = 7788

MERGED_GLB  = OUTPUT_DIR / "merged.glb"
STATIC_DIR  = Path(r"E:\도진팩토리\3D스캔및티칭시스템\threejs")

def find_ply_frames():
    # PLY_meshes 폴더 우선 탐색 (이전 Record3D 포맷)
    dirs = sorted(SCAN_BASE.rglob("PLY_meshes"),
                  key=lambda p: p.parent.parent.stat().st_mtime, reverse=True)
    if dirs: return sorted(dirs[0].rglob("*.ply"))
    # fallback: glTF 폴더의 extracted (최신 Record3D 포맷)
    dirs = sorted(SCAN_BASE.rglob("extracted"),
                  key=lambda p: p.parent.parent.stat().st_mtime, reverse=True)
    if dirs: return sorted(dirs[0].rglob("*.ply"))
    return []

def parse_ply_frame(ply_path: Path):
    with open(ply_path, 'rb') as f:
        n_verts = n_faces = 0
        while True:
            line = f.readline().decode('utf-8','ignore').strip()
            if line.startswith('element vertex'): n_verts = int(line.split()[-1])
            elif line.startswith('element face'):  n_faces = int(line.split()[-1])
            elif line == 'end_header': break
        vdata  = f.read(n_verts * 20)
        verts  = struct.unpack_from(f'<{n_verts*5}f', vdata)
        faces  = []
        for _ in range(n_faces):
            cnt = struct.unpack_from('<B', f.read(1))[0]
            idx = struct.unpack_from(f'<{cnt}i', f.read(cnt*4))
            if cnt == 3: faces.append(idx)
            elif cnt == 4:
                faces.append((idx[0],idx[1],idx[2]))
                faces.append((idx[0],idx[2],idx[3]))

    positions = []
    uvs = []
    for i in range(n_verts):
        positions.extend([verts[i*5], verts[i*5+1], verts[i*5+2]])
        uvs.extend([verts[i*5+3], 1.0 - verts[i*5+4]])
    flat_faces = [x for tri in faces for x in tri]

    jpg = ply_path.with_suffix('.jpg')
    tex = base64.b64encode(jpg.read_bytes()).decode('ascii') if jpg.exists() else ''

    xs = verts[0::5]; ys = verts[1::5]; zs = verts[2::5]
    cx = (min(xs)+max(xs))/2; cy = (min(ys)+max(ys))/2; cz = (min(zs)+max(zs))/2
    sz = max(max(xs)-min(xs), max(ys)-min(ys), max(zs)-min(zs))
    return {"positions":positions,"uvs":uvs,"faces":flat_faces,
            "texture_b64":tex,"n_verts":n_verts,"n_faces":len(faces),
            "center":[cx,cy,cz],"size":sz}

FRAMES      = find_ply_frames()
_ply_cache  = {}

def get_ply_frame(idx):
    idx = max(0, min(len(FRAMES)-1, idx))
    if idx not in _ply_cache:
        _ply_cache[idx] = parse_ply_frame(FRAMES[idx])
    return _ply_cache[idx]

# 서버 시작 시 merged.glb 존재 여부 결정
def get_mode():
    return "glb" if MERGED_GLB.exists() else "ply"

HTML_TEMPLATE = """<!DOCTYPE html>
<html><head>
<meta charset="utf-8">
<title>도진웍스 LiDAR v2.1</title>
<style>
* {{ margin:0;padding:0;box-sizing:border-box }}
body {{ background:#111; color:#eee; font-family:monospace; overflow:hidden }}
#c {{ display:block; width:100vw; height:100vh }}
#ui {{ position:fixed;bottom:0;left:0;right:0;background:rgba(0,0,0,.8);
       padding:8px 16px;display:flex;align-items:center;gap:10px;z-index:10 }}
#info {{ position:fixed;top:10px;left:10px;background:rgba(0,0,0,.7);
         padding:8px 12px;border-radius:6px;font-size:12px;line-height:1.9 }}
#mbox {{ position:fixed;top:10px;right:10px;background:rgba(0,0,0,.7);
         padding:8px 12px;border-radius:6px;font-size:12px;
         max-height:320px;overflow-y:auto;min-width:220px }}
#loading {{ position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);
            background:rgba(0,0,0,.9);padding:30px 50px;border-radius:10px;
            font-size:16px;text-align:center }}
#pbar {{ width:280px;height:8px;background:#333;border-radius:4px;margin-top:12px }}
#pb   {{ height:100%;background:#2a82d4;border-radius:4px;width:0;transition:width .2s }}
#ppct {{ margin-top:6px;font-size:13px }}
button {{ background:#1a5fac;color:#fff;border:none;padding:6px 14px;
           border-radius:4px;cursor:pointer;font-size:12px }}
button:hover {{ background:#2a7fd4 }}
.danger {{ background:#7a1a1a!important }}
.danger:hover {{ background:#b02020!important }}
.ok {{ background:#1a7a3a!important }}
</style>
</head><body>
<canvas id="c"></canvas>
<div id="loading">
  <div id="load-msg">3D 모델 로딩 중...</div>
  <div id="pbar"><div id="pb"></div></div>
  <div id="ppct">잠시만 기다려주세요</div>
</div>
<div id="info" style="display:none">
  <b>LiDAR 티칭 툴 v2.1</b><br>
  드래그=회전 &nbsp; 휠=줌 &nbsp; 우클릭=이동<br>
  <b>더블클릭</b>=마킹 &nbsp; R=카메라초기화
  {ply_controls}
</div>
<div id="mbox" style="display:none">
  <b>마킹</b> <span id="mcnt"></span>
  <div id="mlist"></div>
</div>
<div id="ui" style="display:none">
  <button onclick="resetCam()">카메라초기화 (R)</button>
  <button onclick="toggleWire()">와이어</button>
  <button class="danger" onclick="clearMarkers()">마킹초기화</button>
  <button class="ok" onclick="exportMarkers()">마킹저장</button>
</div>

<script type="importmap">
{{"imports": {{
  "three": "/static/three.module.js",
  "three/addons/controls/OrbitControls.js": "/static/OrbitControls.js",
  "three/addons/loaders/GLTFLoader.js": "/static/GLTFLoader.js",
  "three/addons/utils/BufferGeometryUtils.js": "/static/BufferGeometryUtils.js"
}}}}
</script>
<script type="module">
import * as THREE from 'three';
import {{ OrbitControls }} from 'three/addons/controls/OrbitControls.js';
import {{ GLTFLoader }} from 'three/addons/loaders/GLTFLoader.js';

const renderer = new THREE.WebGLRenderer({{canvas:document.getElementById('c'),antialias:true}});
renderer.setPixelRatio(Math.min(devicePixelRatio,2));
renderer.outputColorSpace = THREE.SRGBColorSpace;
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x111111);
const camera = new THREE.PerspectiveCamera(55,1,.001,5000);
const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping=true; controls.dampingFactor=0.08;
scene.add(new THREE.AmbientLight(0xffffff,1.2));
const dl=new THREE.DirectionalLight(0xffffff,.8); dl.position.set(5,10,5); scene.add(dl);

function resize(){{
  renderer.setSize(innerWidth,innerHeight);
  camera.aspect=innerWidth/innerHeight; camera.updateProjectionMatrix();
}}
addEventListener('resize',resize); resize();

let root=null, bbox=new THREE.Box3(), wireOn=false;

function fitCamera(box){{
  const c=new THREE.Vector3(), s=new THREE.Vector3();
  box.getCenter(c); box.getSize(s);
  const d=Math.max(s.x,s.y,s.z);
  controls.target.copy(c);
  camera.position.set(c.x, c.y+d*.3, c.z+d*1.3);
  camera.lookAt(c); controls.update();
}}
function resetCam(){{ if(root){{ bbox.setFromObject(root); fitCamera(bbox); }} }}

{load_code}

const raycaster=new THREE.Raycaster();
const mouse=new THREE.Vector2();
const markers=[], markerGroup=new THREE.Group();
scene.add(markerGroup);

renderer.domElement.addEventListener('dblclick', e=>{{
  if(!root) return;
  const rect=renderer.domElement.getBoundingClientRect();
  mouse.x=((e.clientX-rect.left)/rect.width)*2-1;
  mouse.y=-((e.clientY-rect.top)/rect.height)*2+1;
  raycaster.setFromCamera(mouse,camera);
  const obs=[]; root.traverse(o=>{{if(o.isMesh)obs.push(o);}});
  const hits=raycaster.intersectObjects(obs,true);
  if(!hits.length) return;
  const p=hits[0].point;
  const r=bbox.max.distanceTo(bbox.min)*.015;
  const sp=new THREE.Mesh(new THREE.SphereGeometry(r,10,10),
                           new THREE.MeshBasicMaterial({{color:0xff3333}}));
  sp.position.copy(p); markerGroup.add(sp);
  markers.push({{idx:markers.length+1,x:+p.x.toFixed(4),y:+p.y.toFixed(4),z:+p.z.toFixed(4)}});
  updML();
}});

function updML(){{
  document.getElementById('mcnt').textContent=markers.length+'개';
  document.getElementById('mlist').innerHTML=markers.map(m=>
    `<div style="padding:2px 0;border-bottom:1px solid #333">#${{m.idx}} (${{m.x}},${{m.y}},${{m.z}})</div>`).join('');
}}
function clearMarkers(){{
  while(markerGroup.children.length) markerGroup.remove(markerGroup.children[0]);
  markers.length=0; updML();
}}
function exportMarkers(){{
  if(!markers.length){{alert('마킹 없음');return;}}
  fetch('/save',{{method:'POST',headers:{{'Content-Type':'application/json'}},
    body:JSON.stringify({{markers}})}})
  .then(r=>r.json()).then(d=>alert('저장: '+d.path));
}}
function toggleWire(){{
  wireOn=!wireOn;
  if(root) root.traverse(o=>{{
    if(o.isMesh){{
      const ms=Array.isArray(o.material)?o.material:[o.material];
      ms.forEach(m=>m.wireframe=wireOn);
    }}
  }});
}}
window.resetCam=resetCam; window.toggleWire=toggleWire;
window.clearMarkers=clearMarkers; window.exportMarkers=exportMarkers;

document.addEventListener('keydown',e=>{{
  if(e.key==='r'||e.key==='R') resetCam();
}});

(function loop(){{ requestAnimationFrame(loop); controls.update(); renderer.render(scene,camera); }})();
</script></body></html>"""

PLY_CONTROLS = "<br>← → 키 = 프레임이동"
PLY_SLIDER   = """<br>
  <label style='font-size:11px'>프레임
    <input id='fslider' type='range' min='0' max='{max_frame}' value='{init_frame}'
           style='width:200px' oninput='loadFrame(+this.value)'>
    <span id='fidx'>{init_frame}</span>
  </label>"""

GLB_LOAD = """
const loader = new GLTFLoader();
document.getElementById('load-msg').textContent = 'merged.glb 로딩 중...';
loader.load('/model',
  gltf => {
    root = gltf.scene;
    root.traverse(o => {
      if(o.isMesh) {
        const hasColor = o.geometry.attributes.color !== undefined;
        if(!o.material || (Array.isArray(o.material) ? o.material.every(m=>!m||m.color?.getHex()===0) : !o.material || o.material.color?.getHex()===0)) {
          o.material = new THREE.MeshStandardMaterial({
            vertexColors: hasColor,
            color: hasColor ? 0xffffff : 0xcccccc,
            roughness: 0.8, metalness: 0.1, side: THREE.DoubleSide
          });
        }
      }
    });
    scene.add(root);
    bbox.setFromObject(root); fitCamera(bbox);
    document.getElementById('loading').style.display='none';
    document.getElementById('info').style.display='block';
    document.getElementById('mbox').style.display='block';
    document.getElementById('ui').style.display='flex';
  },
  xhr => {
    if(xhr.total){
      const p=Math.round(xhr.loaded/xhr.total*100);
      document.getElementById('pb').style.width=p+'%';
      document.getElementById('ppct').textContent=p+'% ('+Math.round(xhr.loaded/1024/1024)+'MB)';
    }
  },
  err => { document.getElementById('load-msg').textContent='로드 실패: '+err.message; console.error(err); }
);"""

PLY_LOAD = """
function loadFrame(idx){{
  document.getElementById('fidx').textContent=idx;
  fetch('/frame/'+idx).then(r=>r.json()).then(data=>{{
    if(root){{ scene.remove(root); root.traverse(o=>{{if(o.isMesh){{o.geometry.dispose();}}}}); }}
    const geo=new THREE.BufferGeometry();
    geo.setAttribute('position',new THREE.Float32BufferAttribute(data.positions,3));
    geo.setAttribute('uv',new THREE.Float32BufferAttribute(data.uvs,2));
    geo.setIndex(data.faces); geo.computeVertexNormals();
    let mat;
    if(data.texture_b64){{
      const img=new Image(); img.src='data:image/jpeg;base64,'+data.texture_b64;
      const tex=new THREE.Texture(img); img.onload=()=>tex.needsUpdate=true;
      mat=new THREE.MeshStandardMaterial({{map:tex,side:THREE.DoubleSide}});
    }} else mat=new THREE.MeshStandardMaterial({{color:0x88aacc,side:THREE.DoubleSide}});
    root=new THREE.Mesh(geo,mat); scene.add(root);
    bbox.setFromObject(root);
    if(idx===initFrame) fitCamera(bbox);
    document.getElementById('loading').style.display='none';
    document.getElementById('info').style.display='block';
    document.getElementById('mbox').style.display='block';
    document.getElementById('ui').style.display='flex';
  }});
}}
const initFrame={init_frame};
document.addEventListener('keydown',e=>{{
  const sl=document.getElementById('fslider');
  if(e.key==='ArrowLeft'){{ sl.value=Math.max(0,+sl.value-1); loadFrame(+sl.value); }}
  if(e.key==='ArrowRight'){{ sl.value=Math.min({max_frame},+sl.value+1); loadFrame(+sl.value); }}
}});
loadFrame(initFrame);"""


def make_html():
    mode = get_mode()
    if mode == "glb":
        return HTML_TEMPLATE.format(
            ply_controls="",
            load_code=GLB_LOAD,
        )
    else:
        n = len(FRAMES)
        init = n // 2
        slider = PLY_SLIDER.format(max_frame=n-1, init_frame=init)
        load   = PLY_LOAD.format(init_frame=init, max_frame=n-1)
        return HTML_TEMPLATE.format(
            ply_controls=PLY_CONTROLS + slider,
            load_code=load,
        )


class Handler(BaseHTTPRequestHandler):
    def log_message(self, *a): pass

    def do_GET(self):
        if self.path == '/':
            body = make_html().encode('utf-8')
            self.send_response(200)
            self.send_header('Content-Type','text/html; charset=utf-8')
            self.send_header('Content-Length', len(body))
            self.end_headers(); self.wfile.write(body)

        elif self.path.startswith('/static/'):
            fname = self.path[8:]  # '/static/' 제거
            fpath = STATIC_DIR / fname
            if not fpath.exists():
                self.send_response(404); self.end_headers(); return
            ct = 'application/javascript'
            data = fpath.read_bytes()
            self.send_response(200)
            self.send_header('Content-Type', ct)
            self.send_header('Content-Length', len(data))
            self.end_headers(); self.wfile.write(data)

        elif self.path == '/model':
            # merged.glb 서빙
            if not MERGED_GLB.exists():
                self.send_response(404); self.end_headers(); return
            size = MERGED_GLB.stat().st_size
            self.send_response(200)
            self.send_header('Content-Type','model/gltf-binary')
            self.send_header('Content-Length', size)
            self.end_headers()
            with open(MERGED_GLB,'rb') as f:
                while True:
                    chunk = f.read(512*1024)
                    if not chunk: break
                    try: self.wfile.write(chunk)
                    except: break

        elif self.path.startswith('/frame/'):
            idx = int(self.path.split('/')[-1])
            data = get_ply_frame(idx)
            body = json.dumps(data).encode('utf-8')
            self.send_response(200)
            self.send_header('Content-Type','application/json')
            self.send_header('Content-Length', len(body))
            self.end_headers(); self.wfile.write(body)

        elif self.path == '/status':
            body = json.dumps({"mode": get_mode(), "merged_ready": MERGED_GLB.exists()}).encode()
            self.send_response(200)
            self.send_header('Content-Type','application/json')
            self.end_headers(); self.wfile.write(body)

        else:
            self.send_response(404); self.end_headers()

    def do_POST(self):
        if self.path == '/save':
            length = int(self.headers.get('Content-Length',0))
            body   = json.loads(self.rfile.read(length))
            out    = OUTPUT_DIR / f"markers_{int(time.time())}.json"
            out.write_text(json.dumps(body, ensure_ascii=False, indent=2), encoding='utf-8')
            resp   = json.dumps({"path":str(out)}).encode()
            self.send_response(200)
            self.send_header('Content-Type','application/json')
            self.end_headers(); self.wfile.write(resp)


if __name__ == "__main__":
    mode = get_mode()
    if mode == "glb":
        print(f"모드: GLB | {MERGED_GLB.name} ({MERGED_GLB.stat().st_size//1024//1024}MB)")
    else:
        print(f"모드: PLY 프레임 | {len(FRAMES)}개 (merged.glb 변환 중...)")
        if FRAMES:
            print("초기 프레임 로드 중...")
            get_ply_frame(len(FRAMES)//2)
            print("준비 완료")

    # merged.glb 완성 감지 → 자동 전환 (백그라운드)
    def watch_merged():
        while True:
            time.sleep(10)
            if MERGED_GLB.exists():
                sz = MERGED_GLB.stat().st_size
                if sz > 1024*1024:  # 1MB 이상이면 완성으로 간주
                    print(f"\n✅ merged.glb 완성 ({sz//1024//1024}MB) — 새로고침하면 완성 모델 표시")
                    break
    threading.Thread(target=watch_merged, daemon=True).start()

    server = HTTPServer(("localhost", PORT), Handler)
    threading.Thread(target=server.serve_forever, daemon=True).start()
    url = f"http://localhost:{PORT}"
    print(f"브라우저: {url}")
    print("R=카메라초기화 / W=와이어 / 더블클릭=마킹 / Ctrl+C=종료")
    webbrowser.open(url)
    try:
        while True: time.sleep(1)
    except KeyboardInterrupt:
        print("종료.")
