# dzw_ar.js — AR CAD 오버레이 정합 검증
# 원리: 정답을 아는 가상 카메라로 3D 점을 영상 픽셀에 투영해 넣고,
#       solvePnP가 그 정답 자세를 되찾아내는지 본다. 실카메라 없이 솔버의 참거짓을 가린다.
# 사전: python dzw_server.py (8090) 실행 상태
import asyncio, json
from playwright.async_api import async_playwright

def is_noise(t):
    return 'Failed to load resource' in t  # 기존 404 폴링 노이즈

# 정답 자세 4종 (rv=로드리게스 회전, tv=이동mm, f=초점거리px)
# 거리는 4점이 1280x720 안에 들어오도록 잡았다 — 정반(2.5m x 5.4m)을 몇 미터 밖에서 보는 상황.
CASES = [
    ('정면',   [3.14159, 0, 0],   [0, 0, 5000], 1200),
    ('비스듬', [2.9, 0.35, 0.12], [220, -140, 5200], 1000),
    ('측면',   [3.0, -0.6, 0.25], [-380, 200, 5600], 1300),
    ('근접',   [3.05, 0.15, -0.2], [120, 90, 3800], 900),
]

async def main():
    errors, noise = [], []
    async with async_playwright() as p:
        br = await p.chromium.launch(headless=True)
        pg = await (await br.new_context()).new_page()
        await pg.set_viewport_size({'width': 1400, 'height': 900})
        pg.on('console', lambda m: (noise if is_noise(m.text) else errors).append('[console.error] ' + m.text) if m.type == 'error' else None)
        pg.on('pageerror', lambda e: errors.append('[pageerror] ' + str(e)))
        await pg.goto('http://localhost:8090/DOZIKWORKS_OS_v5_locked.dc.html')
        await pg.wait_for_timeout(16000)
        print('=== 1. 로드. 실질 콘솔에러:', len(errors), '/ 404노이즈:', len(noise))
        for e in errors: print('   ', e[:200])

        api = await pg.evaluate("() => !!(window.DZW_AR && DZW_AR._solvePnP && DZW_AR._project)")
        launcher = await pg.evaluate("() => !!document.getElementById('dzw-ar-launch')")
        print('=== 2. DZW_AR 노출:', api, '/ AR 버튼 존재:', launcher)
        if not api:
            print('!! solvePnP 미노출 — 중단'); await br.close(); return

        results = await pg.evaluate("""(cases) => {
            const W=1280, H=720;
            // 정반 크기(2494 x 5371mm)를 본뜬 기준점 4개 — 실제 클릭 대상과 비슷한 분포
            const obj=[{x:-900,y:0,z:-1400},{x:900,y:0,z:-1400},{x:900,y:0,z:1400},{x:-700,y:180,z:900}];
            const out=[];
            for(const [name,rv,tv,f] of cases){
                // 정답 자세로 투영 → 이게 사용자가 클릭했을 영상 좌표
                const img=obj.map(p=>{ const q=DZW_AR._project(p,rv,tv,f,W/2,H/2); return [q[0],q[1]]; });
                if(img.some(q=>!isFinite(q[0])||q[0]<0||q[0]>W||q[1]<0||q[1]>H)){ out.push({name,skip:'화면밖'}); continue; }
                const sol=DZW_AR._solvePnP(obj,img,W,H);
                if(!sol){ out.push({name,fail:'solvePnP null'}); continue; }
                // 되찾은 자세로 재투영해 정답 영상좌표와 얼마나 어긋나는지
                const [r0,r1,r2,tx,ty,tz,fs]=sol.x;
                let worst=0;
                obj.forEach((p,i)=>{ const q=DZW_AR._project(p,[r0,r1,r2],[tx,ty,tz],fs,W/2,H/2);
                    worst=Math.max(worst,Math.hypot(q[0]-img[i][0],q[1]-img[i][1])); });
                out.push({name, rms:+sol.rms.toFixed(3), worstPx:+worst.toFixed(3),
                          f_true:f, f_got:+fs.toFixed(0),
                          t_err_mm:+Math.hypot(tx-tv[0],ty-tv[1],tz-tv[2]).toFixed(1)});
            }
            return out;
        }""", CASES)

        print('=== 3. 정답복원 시험 (가상 카메라 → solvePnP → 재투영)')
        ok = True
        for r in results:
            print('   ', json.dumps(r, ensure_ascii=False))
            if 'rms' not in r or r['rms'] > 1.0 or r['worstPx'] > 2.0:
                ok = False

        # 현실 조건: 사람이 손으로 찍으면 몇 px씩 어긋난다. 그때 실제 오차가 얼마인지.
        noisy = await pg.evaluate("""() => {
            const W=1280,H=720, rv=[3.0,-0.6,0.25], tv=[-380,200,5600], f=1300;
            const obj=[{x:-900,y:0,z:-1400},{x:900,y:0,z:-1400},{x:900,y:0,z:1400},{x:-700,y:180,z:900}];
            const base=obj.map(p=>{const q=DZW_AR._project(p,rv,tv,f,W/2,H/2); return [q[0],q[1]];});
            // 재현 가능한 난수(선형합동) — 매 실행 같은 결과
            let s=12345; const rnd=()=>{ s=(s*1103515245+12345)&0x7fffffff; return s/0x7fffffff*2-1; };
            const out=[];
            for(const px of [1,2,3,5]){
                const errs=[];
                for(let trial=0; trial<40; trial++){
                    const img=base.map(q=>[q[0]+rnd()*px, q[1]+rnd()*px]);
                    const sol=DZW_AR._solvePnP(obj,img,W,H);
                    if(!sol) continue;
                    const [a,b,c,tx,ty,tz]=sol.x;
                    errs.push(Math.hypot(tx-tv[0],ty-tv[1],tz-tv[2]));
                }
                errs.sort((x,y)=>x-y);
                out.push({클릭오차px:px, 시행:errs.length,
                          카메라위치오차_중앙값mm:+errs[Math.floor(errs.length/2)].toFixed(0),
                          최악mm:+errs[errs.length-1].toFixed(0)});
            }
            return out;
        }""")
        print('=== 4. 손 떨림 영향 (클릭이 N px 어긋났을 때 카메라 위치 오차)')
        for r in noisy: print('   ', json.dumps(r, ensure_ascii=False))

        # 점을 더 찍으면 손떨림이 상쇄되는가? (3px 흔들림 고정, 점 개수만 변화)
        npts = await pg.evaluate("""() => {
            const W=1280,H=720, rv=[3.0,-0.6,0.25], tv=[-380,200,5600], f=1300, PX=3;
            // 정반 위에 고르게 분포한 후보점 — 앞 4개는 기존 시험과 동일
            const pool=[{x:-900,y:0,z:-1400},{x:900,y:0,z:-1400},{x:900,y:0,z:1400},{x:-700,y:180,z:900},
                        {x:-900,y:0,z:1400},{x:0,y:180,z:-1400},{x:0,y:0,z:1400},{x:900,y:180,z:0},
                        {x:-900,y:180,z:0},{x:450,y:0,z:-700},{x:-450,y:180,z:700},{x:0,y:0,z:0}];
            let s=12345; const rnd=()=>{ s=(s*1103515245+12345)&0x7fffffff; return s/0x7fffffff*2-1; };
            const out=[];
            for(const n of [4,6,8,12]){
                const obj=pool.slice(0,n);
                const base=obj.map(p=>{const q=DZW_AR._project(p,rv,tv,f,W/2,H/2); return [q[0],q[1]];});
                if(base.some(q=>q[0]<0||q[0]>W||q[1]<0||q[1]>H)){ out.push({점수:n,skip:'화면밖'}); continue; }
                const errs=[];
                for(let t=0;t<40;t++){
                    const img=base.map(q=>[q[0]+rnd()*PX, q[1]+rnd()*PX]);
                    const sol=DZW_AR._solvePnP(obj,img,W,H);
                    if(!sol) continue;
                    const [a,b,c,tx,ty,tz]=sol.x;
                    errs.push(Math.hypot(tx-tv[0],ty-tv[1],tz-tv[2]));
                }
                errs.sort((x,y)=>x-y);
                out.push({점수:n, 시행:errs.length,
                          위치오차_중앙값mm:+errs[Math.floor(errs.length/2)].toFixed(0),
                          최악mm:+errs[errs.length-1].toFixed(0)});
            }
            return out;
        }""")
        print('=== 5. 점 개수 효과 (손떨림 3px 고정)')
        for r in npts: print('   ', json.dumps(r, ensure_ascii=False))

        # 점개수 x 클릭정밀도 — 어느 쪽에 투자해야 하나
        grid = await pg.evaluate("""() => {
            const W=1280,H=720, rv=[3.0,-0.6,0.25], tv=[-380,200,5600], f=1300;
            const pool=[{x:-900,y:0,z:-1400},{x:900,y:0,z:-1400},{x:900,y:0,z:1400},{x:-700,y:180,z:900},
                        {x:-900,y:0,z:1400},{x:0,y:180,z:-1400},{x:0,y:0,z:1400},{x:900,y:180,z:0}];
            const out=[];
            for(const n of [4,8]){
                for(const px of [3,1,0.4]){
                    let s=999; const rnd=()=>{ s=(s*1103515245+12345)&0x7fffffff; return s/0x7fffffff*2-1; };
                    const obj=pool.slice(0,n);
                    const base=obj.map(p=>{const q=DZW_AR._project(p,rv,tv,f,W/2,H/2); return [q[0],q[1]];});
                    const errs=[];
                    for(let t=0;t<60;t++){
                        const img=base.map(q=>[q[0]+rnd()*px, q[1]+rnd()*px]);
                        const sol=DZW_AR._solvePnP(obj,img,W,H);
                        if(sol){ const [a,b,c,tx,ty,tz]=sol.x; errs.push(Math.hypot(tx-tv[0],ty-tv[1],tz-tv[2])); }
                    }
                    errs.sort((x,y)=>x-y);
                    out.push({점수:n, 클릭px:px, 중앙값mm:+errs[Math.floor(errs.length/2)].toFixed(0),
                              최악mm:+errs[errs.length-1].toFixed(0)});
                }
            }
            return out;
        }""")
        print('=== 6. 점개수 x 클릭정밀도 (어디에 투자할지)')
        for r in grid: print('   ', json.dumps(r, ensure_ascii=False))

        # 렌즈 왜곡(k1) 보정 — 왜곡 넣은 가상 카메라의 자세를 되찾는가 [2026-07-18]
        dist = await pg.evaluate("""() => {
            const W=1280,H=720, rv=[3.0,-0.6,0.25], tv=[-380,200,5600], f=1300;
            const pool=[{x:-900,y:0,z:-1400},{x:900,y:0,z:-1400},{x:900,y:0,z:1400},{x:-700,y:180,z:900},
                        {x:-900,y:0,z:1400},{x:0,y:180,z:-1400},{x:0,y:0,z:1400},{x:900,y:180,z:0}];
            const out=[];
            for(const k1 of [0, -0.15, -0.3, 0.1]){   // 폰 광각은 보통 음수(술통형)
                const img=pool.map(p=>{const q=DZW_AR._project(p,rv,tv,f,W/2,H/2,k1); return [q[0],q[1]];});
                if(img.some(q=>q[0]<0||q[0]>W||q[1]<0||q[1]>H)){ out.push({k1,skip:'화면밖'}); continue; }
                const sol=DZW_AR._solvePnP(pool,img,W,H);
                if(!sol){ out.push({k1,fail:true}); continue; }
                const [a,b,c,tx,ty,tz,fs]=sol.x;
                out.push({k1_정답:k1, k1_복원:+(sol.x[7]||0).toFixed(3), 잔차px:+sol.rms.toFixed(2),
                          위치오차mm:+Math.hypot(tx-tv[0],ty-tv[1],tz-tv[2]).toFixed(1)});
            }
            return out;
        }""")
        print('=== 7. 렌즈 왜곡 보정 (k1 복원 시험)')
        dist_ok = True
        for r in dist:
            print('   ', json.dumps(r, ensure_ascii=False))
            if 'skip' in r: continue
            if r.get('fail') or r['잔차px'] > 1.0 or r['위치오차mm'] > 20:
                dist_ok = False
        if not dist_ok: ok = False

        # 캔버스↔영상 정렬은 실카메라가 있어야 검증된다(videoWidth 필요) — CEO 실기 확인 항목.
        print()
        print('=== 결과:', 'PASS' if (ok and not errors) else 'FAIL',
              '| 정답복원', sum(1 for r in results if r.get('rms') is not None and r['rms'] <= 1.0), '/', len(CASES))
        await br.close()

asyncio.run(main())
