refactor: 작업실 스크립트에서 조립·장비 탭을 별도 모듈로 분리하고 지연 로딩
- workshop-app.js 114KB → 83KB(핵심), workshop-assembly.js(22KB)·workshop-equip.js(12KB)는 해당 탭을 처음 열 때만 로드 - 코드 내용 변경 없음(구간을 잘라 옮김). 모듈 밖에서 부르던 이름은 refreshAssemblyPane(shim)과 startEquipTimers/stopEquipTimers 뿐이라 가드된 로더(loadWorkshopModule/withLazyModule)로 감쌈 - 로드 중엔 패널을 흐리게(.rb-loading)+클릭 차단, 로드 중 다른 탭으로 옮기면 실행 안 함, 실패 시 안내 표시 + 다시 열면 재시도(같은 모듈은 한 번만 받음) - 브라우저 하네스에 지연 로딩 5개 항목 추가(시작 시 미로드/탭별 로드/재요청 없음/실패 안내/재시도), 전체 43개 항목 통과 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -39,6 +39,8 @@ const OUT = '/tmp/claude-1000/-srv-homeclaw/20a28794-71fc-428a-b8ac-286538537d9d
|
||||
const browser = await chromium.launch({ executablePath: '/usr/bin/google-chrome', args: ['--no-sandbox'] });
|
||||
const page = await browser.newPage({ viewport: { width: 1400, height: 850 } });
|
||||
const errors: string[] = [];
|
||||
const loadedJs: string[] = []; // 지연 로딩 검증용: 실제로 받은 스크립트
|
||||
page.on('request', (r: any) => { const m = /\/js\/app\/(workshop-[\w-]+\.js)/.exec(r.url()); if (m) loadedJs.push(m[1]); });
|
||||
page.on('pageerror', (e: any) => errors.push('pageerror: ' + e.message));
|
||||
page.on('dialog', (d: any) => d.accept());
|
||||
await page.addInitScript(() => { try { sessionStorage.setItem('smallclaw_token', 't'); } catch {} });
|
||||
@@ -130,6 +132,36 @@ const OUT = '/tmp/claude-1000/-srv-homeclaw/20a28794-71fc-428a-b8ac-286538537d9d
|
||||
await page.evaluate(() => (window as any).attachPhoto('사진/motor.jpg'));
|
||||
check('[첨부 사진: …] 태그가 입력창에 들어감', (await page.inputValue('#chat-input')).includes('[첨부 사진: 사진/motor.jpg]'));
|
||||
|
||||
// ── 지연 로딩: 조립/장비 모듈은 시작할 때 받지 않고, 탭을 처음 열 때만 받는다 ──
|
||||
check('시작 시 조립/장비 모듈은 받지 않음', !loadedJs.includes('workshop-assembly.js') && !loadedJs.includes('workshop-equip.js'), loadedJs.join(','));
|
||||
await page.click('.rb-tab-btn[data-tab=equip]');
|
||||
await page.waitForFunction(() => typeof (window as any).startEquipTimers === 'function', null, { timeout: 15000 });
|
||||
check('장비 탭을 열면 장비 모듈만 로드', loadedJs.includes('workshop-equip.js') && !loadedJs.includes('workshop-assembly.js'), loadedJs.join(','));
|
||||
check('장비 탭 로딩 표시가 사라짐(클릭 가능)', !(await page.locator('#pane-equip.rb-loading').count()));
|
||||
await page.click('.rb-tab-btn[data-tab=assembly]');
|
||||
await page.waitForSelector('#asm-scad', { timeout: 15000 });
|
||||
check('조립 탭을 열면 조립 모듈 로드 + 패널 렌더', loadedJs.includes('workshop-assembly.js'));
|
||||
await page.click('.rb-tab-btn[data-tab=parts]');
|
||||
await page.click('.rb-tab-btn[data-tab=assembly]');
|
||||
await page.waitForTimeout(300);
|
||||
check('같은 모듈을 다시 받지 않음', loadedJs.filter(x => x === 'workshop-assembly.js').length === 1 && loadedJs.filter(x => x === 'workshop-equip.js').length === 1);
|
||||
|
||||
// ── 지연 로딩 실패: 모듈 요청이 막히면 안내가 뜨고, 다시 누르면 재시도해서 로드된다 ──
|
||||
const page3 = await browser.newPage({ viewport: { width: 1400, height: 900 } });
|
||||
await page3.addInitScript(() => { try { sessionStorage.setItem('smallclaw_token', 't'); } catch {} });
|
||||
let block = true;
|
||||
await page3.route('**/js/app/workshop-assembly.js', (route: any) => block ? route.abort() : route.continue());
|
||||
await page3.goto(`${base}/html/workshop-app.html`);
|
||||
await page3.waitForSelector('#project-select');
|
||||
await page3.click('.rb-tab-btn[data-tab=assembly]');
|
||||
await page3.waitForSelector('#pane-assembly .rb-empty', { timeout: 10000 });
|
||||
check('모듈 로드 실패 시 안내 표시(패널이 흐림 상태로 안 남음)', (await page3.locator('#pane-assembly').innerText()).includes('불러오지 못했습니다') && !(await page3.locator('#pane-assembly.rb-loading').count()));
|
||||
block = false;
|
||||
await page3.click('.rb-tab-btn[data-tab=parts]');
|
||||
await page3.click('.rb-tab-btn[data-tab=assembly]');
|
||||
await page3.waitForSelector('#asm-scad', { timeout: 15000 });
|
||||
check('네트워크 복구 후 다시 열면 재시도 성공', true);
|
||||
|
||||
console.log(results.join('\n'));
|
||||
console.log('페이지 JS 오류:', errors.length ? errors.join('; ') : '없음');
|
||||
await browser.close();
|
||||
|
||||
@@ -285,3 +285,6 @@ body{background:var(--bg);color:var(--text);font-family:system-ui,sans-serif;dis
|
||||
.rb-file-stl-sel{position:absolute;top:4px;left:4px;z-index:2;width:16px;height:16px;cursor:pointer;opacity:0;transition:.15s;accent-color:#f59e0b;}
|
||||
.rb-file-card:hover .rb-file-stl-sel,.rb-file-stl-sel:checked{opacity:1;}
|
||||
.rb-file-card.stl-selected{outline:2px solid #f59e0b;outline-offset:-1px;}
|
||||
|
||||
/* 지연 로딩 모듈을 받는 동안 탭 패널을 흐리게 하고 클릭을 막는다 */
|
||||
.rb-pane.rb-loading{opacity:.45;pointer-events:none;transition:opacity .15s;}
|
||||
|
||||
+35
-609
@@ -445,260 +445,50 @@ function switchTab(tab){
|
||||
document.querySelectorAll('.rb-tab-btn').forEach(b=>b.classList.toggle('active',b.dataset.tab===tab));
|
||||
document.querySelectorAll('.rb-pane').forEach(p=>p.classList.remove('active'));
|
||||
document.getElementById('pane-'+tab).classList.add('active');
|
||||
if(tab==='equip')startEquipTimers();else stopEquipTimers();
|
||||
// 장비 탭 위젯은 지연 로딩 모듈(workshop-equip.js) — 다른 탭으로 나가면 로드돼 있을 때만 정리한다.
|
||||
if(tab==='equip')withLazyModule('equip','pane-equip',()=>startEquipTimers());
|
||||
else if(typeof stopEquipTimers==='function')stopEquipTimers();
|
||||
if(tab==='files')loadFiles();
|
||||
if(tab==='assembly')refreshAssemblyPane(true);
|
||||
}
|
||||
|
||||
// ── 장비 위젯(K2/밀링) ────────────────────────────────────────────────────────
|
||||
// "장비" 탭을 보고 있을 때만 폴링한다 — 계속 켜두면 K2는 매번 짧은 WebRTC 연결을,
|
||||
// 밀링은 go2rtc 프레임 요청을 반복하니 안 보는 동안은 자원을 안 쓰는 게 맞다.
|
||||
const MILL_CHANNEL_ID=1000;
|
||||
const MILL_GO2RTC_SRC='cam'; // go2rtc(127.0.0.1:1985) 릴레이 스트림 이름(config nvr.extraCameras.1000.go2rtcSrc)
|
||||
// 밀링 카드 라이브(2026-09-24) — go2rtc video-stream 커스텀 엘리먼트(WebRTC→MSE→MJPEG
|
||||
// 자동폴백)를 nvr-app 라이브 모달과 동일 패턴으로 쓴다. go2rtc(1985)에 브라우저가
|
||||
// 직접 붙으면 Origin 검사로 거부되므로 같은 오리진 WS 프록시(/api/nvr/go2rtc-ws)를 거친다.
|
||||
// https 페이지에서 LAN IP를 ws://로 하드코딩하면 mixed-content로 막히므로 반드시
|
||||
// 현재 오리진 기준 상대경로로 만든다(K2 카메라와 동일 패턴, 09-18 발견).
|
||||
const GO2RTC_WS=(location.protocol==='https:'?'wss://':'ws://')+location.host+'/api/nvr/go2rtc-ws';
|
||||
let millLive=false;
|
||||
function toggleMillLive(){
|
||||
if(millLive)stopMillLive();else startMillLive();
|
||||
}
|
||||
function startMillLive(){
|
||||
if(millLive)return;
|
||||
const vs=document.getElementById('mill-live');
|
||||
millLive=true;
|
||||
const btn=document.getElementById('mill-live-btn');
|
||||
btn.classList.add('on');btn.textContent='⏹ 라이브 종료';
|
||||
document.getElementById('mill-frame').style.display='none';
|
||||
document.getElementById('mill-placeholder').style.display='none';
|
||||
vs.style.display='block';
|
||||
vs.src=GO2RTC_WS+'?src='+encodeURIComponent(MILL_GO2RTC_SRC)+'&token='+encodeURIComponent(getToken());
|
||||
document.getElementById('mill-live-marker').innerHTML=' · <b style="color:#ef4444">● 실시간</b>';
|
||||
}
|
||||
function stopMillLive(){
|
||||
if(!millLive)return;
|
||||
millLive=false;
|
||||
const vs=document.getElementById('mill-live');
|
||||
try{vs.disconnectedCallback();}catch{} // WS/WebRTC 연결 정리(라이브러리 내부 정리 로직 재사용, nvr-app closeLive와 동일)
|
||||
vs.style.display='none';
|
||||
const btn=document.getElementById('mill-live-btn');
|
||||
btn.classList.remove('on');btn.textContent='▶ 라이브';
|
||||
document.getElementById('mill-live-marker').innerHTML='';
|
||||
// 스냅샷으로 즉시 복귀 — 단, 장비 탭을 벗어나며 stopEquipTimers가 부른 경우엔 안 그래도 된다.
|
||||
const pane=document.getElementById('pane-equip');
|
||||
if(pane&&pane.classList.contains('active'))refreshMillWidget();
|
||||
}
|
||||
let equipTimer=null;
|
||||
function startEquipTimers(){
|
||||
if(equipTimer)return;
|
||||
refreshK2Widget();
|
||||
refreshMillWidget();
|
||||
equipTimer=setInterval(()=>{if(document.hidden)return;refreshK2Widget();refreshMillWidget();},20000);
|
||||
}
|
||||
function stopEquipTimers(){
|
||||
clearInterval(equipTimer);
|
||||
equipTimer=null;
|
||||
stopK2Live(); // 장비 탭을 벗어나면 라이브 WebRTC 연결도 닫는다(안 보는 동안 자원 안 쓰기)
|
||||
stopMillLive();
|
||||
}
|
||||
|
||||
// 백그라운드 탭에선 폴링을 멈췄다가 다시 보이면 즉시 한 번 갱신한다.
|
||||
document.addEventListener('visibilitychange',()=>{
|
||||
if(!document.hidden&&equipTimer){refreshK2Widget();refreshMillWidget();}
|
||||
});
|
||||
|
||||
function refreshMillWidget(){
|
||||
if(millLive)return; // 라이브 중엔 스냅샷 폴링 건너뜀 — 실시간 비디오가 이미 떠있고 간섭·낭비 방지
|
||||
const img=document.getElementById('mill-frame');
|
||||
img.src='/api/nvr/frame/'+MILL_CHANNEL_ID+'?t='+Date.now();
|
||||
}
|
||||
function millFrameLoaded(){
|
||||
document.getElementById('mill-frame').style.display='block';
|
||||
document.getElementById('mill-placeholder').style.display='none';
|
||||
document.getElementById('mill-dot').className='rb-equip-dot on';
|
||||
}
|
||||
function millFrameError(){
|
||||
document.getElementById('mill-frame').style.display='none';
|
||||
document.getElementById('mill-placeholder').style.display='block';
|
||||
document.getElementById('mill-placeholder').textContent='카메라를 불러올 수 없습니다';
|
||||
document.getElementById('mill-dot').className='rb-equip-dot off';
|
||||
}
|
||||
|
||||
// K2 카메라는 go2rtc 없이 프린터 자체 WebRTC(:8000)로만 나오므로 정지 프레임을 얻으려면
|
||||
// 짧게 연결→ontrack에서 캔버스로 한 프레임 캡처→즉시 연결 종료한다(k2-app.html의 상시
|
||||
// 라이브 연결과 달리 "장비" 탭을 볼 때만, 20초 간격으로만 짧게 붙었다 끊는 방식).
|
||||
function grabK2Frame(){
|
||||
return new Promise((resolve)=>{
|
||||
let settled=false;
|
||||
const pc=new RTCPeerConnection({iceServers:[{urls:'stun:stun.l.google.com:19302'}]});
|
||||
const video=document.createElement('video');
|
||||
video.autoplay=true;video.muted=true;video.playsInline=true;
|
||||
const finish=(dataUrl)=>{
|
||||
if(settled)return;
|
||||
settled=true;
|
||||
try{pc.close();}catch{}
|
||||
try{video.remove();}catch{}
|
||||
resolve(dataUrl);
|
||||
};
|
||||
// 비디오 엘리먼트를 DOM에 붙이지 않으면 일부 브라우저에서 프레임 디코딩이 실제로
|
||||
// 시작되지 않아(재생 안 함) videoWidth/Height가 계속 0으로 남고 캔버스가 새까맣게
|
||||
// 찍힌다 — 화면 밖(offscreen)에 실제로 붙이고 명시적으로 play()까지 호출해야 한다.
|
||||
video.style.cssText='position:fixed;left:-9999px;top:-9999px;width:1px;height:1px;';
|
||||
document.body.appendChild(video);
|
||||
const captureWhenReady=(triesLeft)=>{
|
||||
if(settled)return;
|
||||
if(video.videoWidth>0&&video.videoHeight>0){
|
||||
try{
|
||||
const c=document.createElement('canvas');
|
||||
c.width=video.videoWidth;c.height=video.videoHeight;
|
||||
c.getContext('2d').drawImage(video,0,0,c.width,c.height);
|
||||
finish(c.toDataURL('image/jpeg',0.7));
|
||||
}catch{finish(null);}
|
||||
return;
|
||||
}
|
||||
if(triesLeft<=0){finish(null);return;}
|
||||
setTimeout(()=>captureWhenReady(triesLeft-1),200);
|
||||
};
|
||||
pc.ontrack=(ev)=>{
|
||||
video.srcObject=ev.streams[0];
|
||||
video.play().catch(()=>{});
|
||||
video.onloadeddata=()=>captureWhenReady(15);
|
||||
};
|
||||
pc.addTransceiver('video',{direction:'sendrecv'});
|
||||
let offerSent=false;
|
||||
const sendOfferOnce=()=>{
|
||||
if(offerSent||settled)return;
|
||||
offerSent=true;
|
||||
fetch('/api/k2/webrtc-signal',{method:'POST',headers:{'Content-Type':'text/plain',...authH()},body:btoa(JSON.stringify({type:'offer',sdp:pc.localDescription.sdp}))})
|
||||
.then(r=>r.text()).then(txt=>{
|
||||
const res=JSON.parse(atob(txt));
|
||||
if(res.type==='answer'&&!settled)pc.setRemoteDescription(new RTCSessionDescription(res));
|
||||
}).catch(()=>finish(null));
|
||||
};
|
||||
pc.onicecandidate=(ev)=>{if(ev.candidate===null)sendOfferOnce();};
|
||||
pc.createOffer().then(d=>pc.setLocalDescription(d)).then(()=>{setTimeout(sendOfferOnce,1500);}).catch(()=>finish(null));
|
||||
setTimeout(()=>finish(null),8000);
|
||||
// ── 지연 로딩 모듈(2026-09-25) ────────────────────────────────────────────────
|
||||
// 조립 설명서 탭(22KB)과 장비 탭(12KB)은 처음 열 때만 받는다 — 시작 로딩에서 약 34KB(30%)를 뺀다.
|
||||
// 별개 classic script라 이 파일의 전역 let/함수를 그대로 공유하고, 이 파일에서 그쪽 이름을 직접 부르는 곳은
|
||||
// 아래 shim(refreshAssemblyPane)과 switchTab의 장비 호출뿐이다(로드 전엔 부르지 않도록 가드).
|
||||
const lazyModules={}; // name → Promise
|
||||
const lazyLoaded={}; // name → true(로드 완료)
|
||||
function loadWorkshopModule(name){
|
||||
if(lazyModules[name])return lazyModules[name];
|
||||
lazyModules[name]=new Promise((resolve,reject)=>{
|
||||
const s=document.createElement('script');
|
||||
s.src='../js/app/workshop-'+name+'.js';
|
||||
s.onload=()=>{lazyLoaded[name]=true;resolve();};
|
||||
s.onerror=()=>{delete lazyModules[name];s.remove();reject(new Error(name+' 모듈을 불러오지 못했습니다(네트워크/서버 확인 후 탭을 다시 눌러 주세요)'));};
|
||||
document.head.appendChild(s);
|
||||
});
|
||||
return lazyModules[name];
|
||||
}
|
||||
|
||||
// ── K2 라이브 뷰(2026-09-24) ─────────────────────────────────────────────────
|
||||
// ▶ 라이브 버튼으로 카드 안에서 상시 WebRTC 스트리밍(k2-app.html의 검증된 연결 로직
|
||||
// 이식). 라이브 중엔 20초 스냅샷 그랩을 멈추고(refreshK2Widget 참조), 장비 탭을
|
||||
// 벗어나면 stopEquipTimers가 연결을 닫는다 — 안 보는 동안엔 자원을 안 쓴다.
|
||||
let k2LivePC=null;
|
||||
function k2Note(msg){
|
||||
const ph=document.getElementById('k2-placeholder');
|
||||
const img=document.getElementById('k2-frame');
|
||||
const video=document.getElementById('k2-live');
|
||||
video.style.display='none';
|
||||
img.style.display='none';
|
||||
ph.style.display='block';
|
||||
ph.textContent=msg;
|
||||
}
|
||||
function toggleK2Live(){
|
||||
if(k2LivePC)stopK2Live();else startK2Live();
|
||||
}
|
||||
function startK2Live(){
|
||||
if(k2LivePC)return;
|
||||
const video=document.getElementById('k2-live');
|
||||
const btn=document.getElementById('k2-live-btn');
|
||||
btn.classList.add('on');btn.textContent='⏹ 라이브 종료';
|
||||
const ph=document.getElementById('k2-placeholder');
|
||||
ph.style.display='none';
|
||||
// STUN을 빼면(host candidate만) 이 프린터의 ICE 응답이 실제로는 안 옴(DTLS failed,
|
||||
// bytesReceived 0) — k2-app.html에서 2026-09-18 확인된 퀴크라 그대로 유지.
|
||||
const pc=new RTCPeerConnection({iceServers:[{urls:'stun:stun.l.google.com:19302'}]});
|
||||
k2LivePC=pc;
|
||||
pc.ontrack=(ev)=>{
|
||||
if(k2LivePC!==pc)return;
|
||||
video.srcObject=ev.streams[0];
|
||||
video.play().catch(()=>{});
|
||||
ph.style.display='none';
|
||||
};
|
||||
pc.oniceconnectionstatechange=()=>{
|
||||
if(k2LivePC!==pc)return;
|
||||
const st=pc.iceConnectionState;
|
||||
if(st==='failed'){
|
||||
stopK2Live();
|
||||
k2Note('라이브 연결 실패 — ▶ 라이브를 다시 눌러주세요');
|
||||
}else if(st==='disconnected'){
|
||||
k2Note('라이브 신호 끊김(일시적) — 복구되면 그려집니다');
|
||||
}
|
||||
};
|
||||
// 오퍼는 ICE gathering 완전종료(null candidate) 또는 1.5초 중 빠른 쪽에 보낸다 —
|
||||
// 완전종료가 이 네트워크에서 20초+ 걸리는 k2-app.html의 실측 퀴크 그대로.
|
||||
let offerSent=false;
|
||||
const sendOfferOnce=()=>{
|
||||
if(offerSent||k2LivePC!==pc)return;
|
||||
offerSent=true;
|
||||
fetch('/api/k2/webrtc-signal',{method:'POST',headers:{'Content-Type':'text/plain',...authH()},body:btoa(JSON.stringify({type:'offer',sdp:pc.localDescription.sdp}))})
|
||||
.then(r=>r.text()).then(txt=>{
|
||||
const res=JSON.parse(atob(txt));
|
||||
if(res.type==='answer'&&k2LivePC===pc)pc.setRemoteDescription(new RTCSessionDescription(res));
|
||||
}).catch(()=>{if(k2LivePC===pc)stopK2Live();});
|
||||
};
|
||||
pc.onicecandidate=(ev)=>{if(ev.candidate===null)sendOfferOnce();};
|
||||
pc.addTransceiver('video',{direction:'sendrecv'});
|
||||
pc.createOffer().then(d=>pc.setLocalDescription(d)).then(()=>{setTimeout(sendOfferOnce,1500);}).catch(()=>{if(k2LivePC===pc)stopK2Live();});
|
||||
}
|
||||
function stopK2Live(){
|
||||
if(!k2LivePC)return;
|
||||
try{k2LivePC.close();}catch{}
|
||||
k2LivePC=null;
|
||||
const video=document.getElementById('k2-live');
|
||||
try{video.pause();video.srcObject=null;}catch{}
|
||||
video.style.display='none';
|
||||
const btn=document.getElementById('k2-live-btn');
|
||||
btn.classList.remove('on');btn.textContent='▶ 라이브';
|
||||
// 스냅샷으로 즉시 복귀 — 단, 장비 탭을 벗어나며 stopEquipTimers가 부른 경우엔
|
||||
// 안 그래도 된다(안 보는 탭에 프레임 그랩을 트리거하는 셈).
|
||||
const pane=document.getElementById('pane-equip');
|
||||
if(pane&&pane.classList.contains('active'))refreshK2Widget();
|
||||
}
|
||||
|
||||
function fmtK2Duration(sec){
|
||||
const h=Math.floor(sec/3600),m=Math.floor((sec%3600)/60);
|
||||
if(h>0)return h+'시간 '+m+'분';
|
||||
return m+'분';
|
||||
}
|
||||
const K2_STATE_LABEL={standby:'대기중',printing:'출력중',paused:'일시정지',complete:'완료',cancelled:'취소됨',error:'오류'};
|
||||
|
||||
async function refreshK2Widget(){
|
||||
const img=document.getElementById('k2-frame');
|
||||
const placeholder=document.getElementById('k2-placeholder');
|
||||
// 라이브 연결이 살아있으면 프레임 그랩(WebRTC 풀협상)을 건너뛴다 — 비디오가 이미
|
||||
// 실시간으로 흐르고 있고, 짧은 연결을 또 열면 둘이 간섭한다. 상태 조회는 계속한다.
|
||||
if(!k2LivePC){
|
||||
grabK2Frame().then(dataUrl=>{
|
||||
if(dataUrl){img.src=dataUrl;img.style.display='block';placeholder.style.display='none';}
|
||||
else if(img.style.display==='none'){placeholder.textContent='카메라를 불러올 수 없습니다';}
|
||||
});
|
||||
}
|
||||
// 모듈이 로드된 뒤 fn 실행. 로드 중엔 패널을 흐리게(.rb-loading) 눌러 클릭을 막고, 그 사이 다른 탭으로 옮겼으면 실행하지 않는다.
|
||||
async function withLazyModule(name,paneId,fn){
|
||||
const pane=document.getElementById(paneId);
|
||||
try{
|
||||
const r=await fetch('/api/k2/status',{headers:authH()});
|
||||
const d=await r.json();
|
||||
const dot=document.getElementById('k2-dot');
|
||||
const statusEl=document.getElementById('k2-status-text');
|
||||
if(!d.online){
|
||||
dot.className='rb-equip-dot off';
|
||||
statusEl.innerHTML='<b>오프라인</b> — 프린터가 꺼져있거나 네트워크에 없음';
|
||||
return;
|
||||
}
|
||||
dot.className='rb-equip-dot '+(d.state==='printing'?'on':'');
|
||||
const stateLabel=K2_STATE_LABEL[d.state]||d.state;
|
||||
let html='상태: <b>'+escAttr(stateLabel)+'</b>';
|
||||
if(d.filename)html+=' · 파일: <b>'+escAttr(d.filename)+'</b>';
|
||||
if(d.state==='printing'){
|
||||
html+='<br>진행률: <b>'+d.progress+'%</b> · 경과: <b>'+fmtK2Duration(d.printDurationSec)+'</b>';
|
||||
}
|
||||
if(k2LivePC)html+=' · <b style="color:#ef4444">● 실시간</b>';
|
||||
statusEl.innerHTML=html;
|
||||
}catch{
|
||||
document.getElementById('k2-status-text').textContent='상태 조회 실패';
|
||||
if(!lazyLoaded[name]){pane.classList.add('rb-loading');await loadWorkshopModule(name);}
|
||||
pane.classList.remove('rb-loading');
|
||||
if(pane.classList.contains('active'))fn();
|
||||
}catch(e){
|
||||
pane.classList.remove('rb-loading');
|
||||
if(!lazyLoaded[name]){const box=document.createElement('div');box.className='rb-empty';box.textContent=e.message;pane.prepend(box);setTimeout(()=>box.remove(),6000);}
|
||||
else console.error(e);
|
||||
}
|
||||
}
|
||||
// 조립 탭 갱신 shim — 핵심 코드(상태 갱신/폴링/탭 전환)는 이 이름만 부른다. 탭이 열려 있을 때만 모듈을 로드한다.
|
||||
async function refreshAssemblyPane(forceLoad){
|
||||
const pane=document.getElementById('pane-assembly');
|
||||
if(!pane||!pane.classList.contains('active'))return;
|
||||
if(!lazyLoaded.assembly&&!pane.firstChild)pane.innerHTML='<div class="rb-empty">불러오는 중…</div>';
|
||||
await withLazyModule('assembly','pane-assembly',()=>refreshAssemblyPaneImpl(forceLoad));
|
||||
}
|
||||
|
||||
// ── 부품 ──────────────────────────────────────────────────────────────────────
|
||||
function fmtWon(n){return (n||0).toLocaleString('ko-KR')+'원';}
|
||||
@@ -1579,370 +1369,6 @@ async function clearChat(){
|
||||
addMsg('system','새 대화가 시작되었습니다.');
|
||||
}
|
||||
|
||||
// ── 조립 설명서 탭 ────────────────────────────────────────────────────────────
|
||||
// 명세(부품/단계)는 project.assembly에 들어 있어 저장/이력/충돌감지가 다른 데이터와 같다. 그림은 서버가
|
||||
// OpenSCAD로 렌더해 workshop/<id>/조립설명서/ 에 저장한다(설명서 페이지는 assembly-manual.html).
|
||||
let asmInfo=null; // 서버 조회 결과(그림 URL, stale 등)
|
||||
let asmScanMods=null; // 스캔한 .scad module 목록
|
||||
let asmScadFiles=[]; // 프로젝트 하위 .scad 후보
|
||||
let asmBusy=false;
|
||||
let asmLoadSeq=0;
|
||||
let asmAutoDone=''; // 자동 연결/초안을 이미 시도한 프로젝트 id(한 번만)
|
||||
let asmDraftInfo=null; // 마지막 자동 초안의 제외/경고 안내
|
||||
let asmLastError='';
|
||||
function asmPaneActive(){return document.getElementById('pane-assembly').classList.contains('active');}
|
||||
let asmProjectId='';
|
||||
function refreshAssemblyPane(forceLoad){
|
||||
const pid=(currentProject()||{}).id||'';
|
||||
if(asmProjectId!==pid){asmInfo=null;asmScanMods=null;asmScadFiles=[];asmProjectId=pid;asmDraftInfo=null;asmLastError='';} // 프로젝트가 바뀌면 그림 정보 초기화
|
||||
if(!asmPaneActive())return;
|
||||
renderAssembly();
|
||||
if(forceLoad||asmInfo===null)loadAssemblyInfo();
|
||||
}
|
||||
function ensureAssembly(){
|
||||
if(!assembly)assembly={scad:'',view:{rx:60,rz:35},explode:1,parts:[],steps:[]};
|
||||
return assembly;
|
||||
}
|
||||
async function loadAssemblyInfo(){
|
||||
const proj=currentProject();
|
||||
if(!proj)return;
|
||||
const seq=++asmLoadSeq;
|
||||
try{
|
||||
const r=await fetch('/api/workshop/assembly/'+encodeURIComponent(proj.id),{headers:authH()});
|
||||
if(!r.ok)return;
|
||||
const d=await r.json();
|
||||
if(seq!==asmLoadSeq)return;
|
||||
asmInfo=d;
|
||||
const fr=await fetch('/api/workshop/assembly-scad-files?projectId='+encodeURIComponent(proj.id),{headers:authH()});
|
||||
if(fr.ok)asmScadFiles=(await fr.json()).files||[];
|
||||
if(seq!==asmLoadSeq||!asmPaneActive())return;
|
||||
renderAssemblyImages();
|
||||
await asmAutoLink();
|
||||
}catch{}
|
||||
}
|
||||
|
||||
// .scad 자동 연동: ① 조립 모델이 비어 있으면 프로젝트의 가장 최근 .scad를 자동 연결하고, 부품이 없으면 초안까지
|
||||
// 만들어 그림을 렌더한다 ② 이미 연결돼 있고 .scad가 바뀌어 그림이 오래됐으면(autoRender 켜짐) 자동 재렌더.
|
||||
// 사용자가 만든 내용은 덮어쓰지 않는다(빈 명세일 때만 채움).
|
||||
async function asmAutoLink(){
|
||||
if(asmBusy)return;
|
||||
const a=assembly;
|
||||
if(!a||!a.scad){
|
||||
if(asmAutoDone===asmProjectId||!asmScadFiles.length)return;
|
||||
asmAutoDone=asmProjectId;
|
||||
ensureAssembly().scad=asmScadFiles[0];
|
||||
asmMarkChanged();
|
||||
renderAssembly();
|
||||
if(!assembly.parts.length&&await asmDraft(true))await asmRender(true);
|
||||
return;
|
||||
}
|
||||
// 연결만 돼 있고 부품/단계가 하나도 없으면(자동 연결 직후 중단됐거나 수동으로 경로만 넣은 경우) 초안을 한 번 시도
|
||||
if(a.scad&&!a.parts.length&&!a.steps.length&&asmAutoDone!==asmProjectId+':draft'){
|
||||
asmAutoDone=asmProjectId+':draft';
|
||||
if(await asmDraft(true))await asmRender(true);
|
||||
return;
|
||||
}
|
||||
if(asmInfo&&asmInfo.stale&&a.autoRender!==false&&a.parts.length&&a.steps.length&&asmAutoDone!==asmProjectId+':stale'){
|
||||
asmAutoDone=asmProjectId+':stale'; // 실패해도 이 화면에서 무한 재시도하지 않는다
|
||||
await asmRender(true);
|
||||
}
|
||||
}
|
||||
function asmMarkChanged(){if(asmInfo&&asmInfo.rendered)asmInfo.specChanged=true;scheduleSave();renderAsmStatus();}
|
||||
|
||||
function renderAssembly(){
|
||||
const pane=document.getElementById('pane-assembly');
|
||||
const a=assembly;
|
||||
const bomNames=parts.map(p=>p.name).filter(Boolean);
|
||||
const partRows=(a?a.parts:[]).map((p,i)=>`
|
||||
<div class="asm-row asm-part" data-id="${escAttr(p.id)}">
|
||||
<span class="num">${i+1}</span>
|
||||
<input type="text" style="flex:1;min-width:110px" placeholder="부품 이름" value="${escAttr(p.name)}" oninput="asmPart('${p.id}','name',this.value)">
|
||||
<input type="text" style="flex:1.2;min-width:120px;font-family:monospace" placeholder="모듈 호출 예: camera(80)" value="${escAttr(p.call)}" oninput="asmPart('${p.id}','call',this.value)" title=".scad의 조립 좌표 모듈 호출">
|
||||
<span style="font-size:10px;color:var(--muted)">분해</span>
|
||||
${[0,1,2].map(k=>`<input type="number" class="off" title="${'XYZ'[k]} 방향으로 띄울 거리(mm)" value="${escAttr(p.offset[k])}" oninput="asmOffset('${p.id}',${k},this.value)">`).join('')}
|
||||
<select title="대시보드 BOM 부품과 연결(구매 상태 표시)" onchange="asmPart('${p.id}','bom',this.value)">
|
||||
<option value="">BOM 연결 없음</option>
|
||||
${bomNames.map(n=>`<option value="${escAttr(n)}" ${p.bom===n?'selected':''}>${escAttr(n)}</option>`).join('')}
|
||||
${p.bom&&!bomNames.includes(p.bom)?`<option value="${escAttr(p.bom)}" selected>${escAttr(p.bom)}</option>`:''}
|
||||
</select>
|
||||
<input type="number" min="1" style="width:52px" title="수량" value="${escAttr(p.qty)}" oninput="asmPart('${p.id}','qty',this.value)">
|
||||
<button class="rb-mini-btn" onclick="asmMovePart('${p.id}',-1)" title="위로(번호 변경)">↑</button>
|
||||
<button class="rb-mini-btn" onclick="asmMovePart('${p.id}',1)" title="아래로(번호 변경)">↓</button>
|
||||
<button class="rb-del-btn" onclick="asmDelPart('${p.id}')">✕</button>
|
||||
</div>`).join('')||'<div class="rb-empty">부품이 없습니다. 위에서 .scad를 스캔해 모듈을 추가하거나 직접 추가하세요.</div>';
|
||||
const stepCards=(a?a.steps:[]).map((st,i)=>`
|
||||
<div class="asm-step" data-id="${escAttr(st.id)}">
|
||||
<div class="asm-row" style="margin-bottom:2px">
|
||||
<b style="color:#f59e0b">STEP ${i+1}</b>
|
||||
<input type="text" style="flex:1;min-width:140px" placeholder="단계 제목" value="${escAttr(st.title)}" oninput="asmStep('${st.id}','title',this.value)">
|
||||
<button class="rb-mini-btn" onclick="asmMoveStep('${st.id}',-1)">↑</button>
|
||||
<button class="rb-mini-btn" onclick="asmMoveStep('${st.id}',1)">↓</button>
|
||||
<button class="rb-del-btn" onclick="asmDelStep('${st.id}')">✕</button>
|
||||
</div>
|
||||
<textarea placeholder="작업 내용(어떻게 끼우고 조이는지)" oninput="asmStep('${st.id}','desc',this.value)">${escAttr(st.desc)}</textarea>
|
||||
<div class="asm-lbl">이번 단계에 붙는 부품</div>
|
||||
<div class="asm-chips">${a.parts.length?a.parts.map((p,k)=>`<label class="asm-chip${st.partIds.includes(p.id)?' on':''}"><input type="checkbox" ${st.partIds.includes(p.id)?'checked':''} onchange="asmToggleStepPart('${st.id}','${p.id}',this.checked)"><span class="num">${k+1}</span>${escAttr(p.name)}</label>`).join(''):'<span class="rb-empty">먼저 부품을 추가하세요</span>'}</div>
|
||||
<div class="asm-lbl">체결 부품 (한 줄에 하나: 이름 | 수량)</div>
|
||||
<textarea style="min-height:38px" placeholder="M4x10 볼트 | 4" oninput="asmFasteners('${st.id}',this.value)">${escAttr((st.fasteners||[]).map(f=>f.name+' | '+f.qty).join('\n'))}</textarea>
|
||||
<div class="asm-lbl">공구 (쉼표로 구분)</div>
|
||||
<div class="asm-row"><input type="text" style="flex:1" placeholder="2.5mm 육각렌치, 니퍼" value="${escAttr((st.tools||[]).join(', '))}" oninput="asmTools('${st.id}',this.value)"></div>
|
||||
</div>`).join('')||'<div class="rb-empty">단계가 없습니다. "+ 단계 추가"를 누르세요.</div>';
|
||||
|
||||
pane.innerHTML=`
|
||||
<div class="asm-sec">
|
||||
<div class="asm-sec-hdr">📐 조립 모델(.scad) <span style="font-weight:400;color:var(--muted)">— 프로젝트의 OpenSCAD 조립 파일. 원본은 수정하지 않고 부품만 골라 그립니다.</span></div>
|
||||
<div class="asm-sec-body">
|
||||
<div class="asm-row">
|
||||
<input type="text" id="asm-scad" list="asm-scad-list" style="flex:1;min-width:220px" placeholder="workshop/<프로젝트id>/CAD/xxx.scad" value="${escAttr(a?a.scad:'')}" oninput="asmSet('scad',this.value)">
|
||||
<datalist id="asm-scad-list">${asmScadFiles.map(f=>`<option value="${escAttr(f)}">`).join('')}</datalist>
|
||||
<button class="rb-mini-btn" onclick="asmScan()">🔍 모듈 스캔</button>
|
||||
<button class="rb-mini-btn" onclick="asmDraft(false)" title=".scad의 조립 블록에서 부품·분해 위치·조립 순서를 자동으로 뽑습니다">✨ 자동 초안</button>
|
||||
</div>
|
||||
<div class="asm-row">
|
||||
<label style="font-size:11px;color:var(--muted);display:flex;align-items:center;gap:5px;cursor:pointer" title="채팅에서 .scad를 수정하거나, 이 탭을 열었을 때 그림이 오래됐으면 자동으로 다시 렌더링합니다">
|
||||
<input type="checkbox" ${!a||a.autoRender!==false?'checked':''} onchange="asmSet('autoRender',this.checked)">.scad가 바뀌면 그림 자동 갱신</label>
|
||||
<span style="font-size:10px;color:var(--muted)">${asmScadFiles.length>1?'· 이 프로젝트의 .scad '+asmScadFiles.length+'개 (입력칸에서 선택)':''}</span>
|
||||
</div>
|
||||
<div id="asm-draft-note">${asmDraftNoteHtml()}</div>
|
||||
<div class="asm-row">
|
||||
<span style="font-size:11px;color:var(--muted)">보는 각도</span>
|
||||
<input type="number" style="width:64px" title="위에서 내려다보는 각도(0~90)" value="${escAttr(a?a.view.rx:60)}" oninput="asmView('rx',this.value)">
|
||||
<input type="number" style="width:64px" title="회전 각도" value="${escAttr(a?a.view.rz:35)}" oninput="asmView('rz',this.value)">
|
||||
<span style="font-size:11px;color:var(--muted);margin-left:8px">분해 간격 ×</span>
|
||||
<input type="number" step="0.1" min="0.1" max="5" style="width:64px" value="${escAttr(a?a.explode:1)}" oninput="asmSet('explode',this.value)">
|
||||
</div>
|
||||
<div id="asm-scan-out">${asmScanHtml()}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="asm-sec">
|
||||
<div class="asm-sec-hdr">🔩 부품 <span style="font-weight:400;color:var(--muted)">번호 = 순서 (그림의 풍선 번호)</span><span class="rb-hdr-sp"></span>
|
||||
<button class="rb-mini-btn" onclick="asmAddPart()">+ 부품 추가</button></div>
|
||||
<div class="asm-sec-body">${partRows}</div>
|
||||
</div>
|
||||
<div class="asm-sec">
|
||||
<div class="asm-sec-hdr">🪜 조립 순서<span class="rb-hdr-sp"></span>
|
||||
<button class="rb-mini-btn" onclick="asmStepsToTasks()" title="STEP들을 '작업' 탭에 조립 단계로 추가">→ 작업 탭으로 가져오기</button>
|
||||
<button class="rb-mini-btn" onclick="asmAddStep()">+ 단계 추가</button></div>
|
||||
<div class="asm-sec-body">${stepCards}</div>
|
||||
</div>
|
||||
<div class="asm-sec">
|
||||
<div class="asm-sec-hdr">🖼 설명서 <span id="asm-status" style="font-weight:400"></span><span class="rb-hdr-sp"></span>
|
||||
<button class="rb-add-btn" style="margin:0" id="asm-render-btn" onclick="asmRender()">🖼 그림 렌더링</button>
|
||||
<a class="rb-mini-btn" style="text-decoration:none" id="asm-open" href="/html/assembly-manual.html?projectId=${encodeURIComponent((currentProject()||{}).id||'')}" target="_blank">📄 설명서 열기 / 인쇄</a></div>
|
||||
<div class="asm-sec-body">
|
||||
<div class="rb-empty" style="padding:0 0 6px">💬 채팅에 <b>"조립 설명서 만들어줘"</b>라고 하면 AI가 .scad를 읽고 부품/단계 초안을 잡아 줍니다.</div>
|
||||
<div id="asm-imgs"></div>
|
||||
</div>
|
||||
</div>`;
|
||||
renderAsmStatus();
|
||||
renderAssemblyImages();
|
||||
}
|
||||
|
||||
function renderAsmStatus(){
|
||||
const el=document.getElementById('asm-status');
|
||||
if(!el)return;
|
||||
const a=assembly;
|
||||
let t='';
|
||||
if(asmBusy)t='<span style="color:#f59e0b">렌더링 중… (부품 수에 따라 10~60초)</span>';
|
||||
else if(asmLastError)t='<span style="color:#ef4444">⚠ '+escHtml(asmLastError)+'</span>';
|
||||
else if(!a||!a.parts.length||!a.steps.length)t='<span style="color:var(--muted)">부품과 단계를 채운 뒤 렌더링하세요</span>';
|
||||
else if(!asmInfo||!asmInfo.rendered)t='<span style="color:var(--muted)">아직 렌더링 안 됨</span>';
|
||||
else t='<span style="color:var(--muted)">마지막 렌더 '+new Date(asmInfo.renderedAt).toLocaleString('ko-KR')+'</span>'+(asmInfo.stale?' <span style="color:#f59e0b">⚠ 모델이 바뀜</span>':'')+(asmInfo.specChanged?' <span style="color:#f59e0b">⚠ 명세 변경됨 — 다시 렌더링</span>':'');
|
||||
el.innerHTML=t;
|
||||
const b=document.getElementById('asm-render-btn');
|
||||
if(b){b.disabled=asmBusy;b.textContent=asmBusy?'렌더링 중…':'🖼 그림 렌더링';}
|
||||
}
|
||||
function renderAssemblyImages(){
|
||||
renderAsmStatus();
|
||||
const box=document.getElementById('asm-imgs');
|
||||
if(!box)return;
|
||||
if(!asmInfo||!asmInfo.images){box.innerHTML='';return;}
|
||||
const im=asmInfo.images;
|
||||
box.innerHTML='<div class="asm-imgs">'
|
||||
+'<figure><img src="'+escAttr(im.overview)+'" loading="lazy"><figcaption>조립 완성</figcaption></figure>'
|
||||
+(im.exploded?'<figure><img src="'+escAttr(im.exploded)+'" loading="lazy"><figcaption>분해도</figcaption></figure>':'')
|
||||
+im.steps.map((u,i)=>'<figure><img src="'+escAttr(u)+'" loading="lazy"><figcaption>STEP '+(i+1)+'</figcaption></figure>').join('')+'</div>'
|
||||
+(asmInfo.unassigned&&asmInfo.unassigned.length?'<div class="asm-warn">⚠ 어느 단계에도 없는 부품: '+asmInfo.unassigned.map(escHtml).join(', ')+'</div>':'');
|
||||
}
|
||||
|
||||
// ── 편집 핸들러(입력 중엔 다시 그리지 않아 포커스 유지, 구조가 바뀔 때만 renderAssembly) ──
|
||||
function asmSet(k,v){
|
||||
const a=ensureAssembly();
|
||||
a[k]=k==='explode'?(Number(v)||1):(k==='autoRender'?!!v:v);
|
||||
asmMarkChanged();
|
||||
}
|
||||
function asmView(k,v){ensureAssembly().view[k]=Number(v)||0;asmMarkChanged();}
|
||||
function asmFind(id){return ensureAssembly().parts.find(p=>p.id===id);}
|
||||
function asmPart(id,k,v){
|
||||
const p=asmFind(id);if(!p)return;
|
||||
if(k==='qty')p.qty=Math.max(1,Math.round(Number(v)||1));
|
||||
else if(k==='bom'){if(v)p.bom=v;else delete p.bom;}
|
||||
else p[k]=v;
|
||||
asmMarkChanged();
|
||||
}
|
||||
function asmOffset(id,k,v){const p=asmFind(id);if(!p)return;p.offset[k]=Number(v)||0;asmMarkChanged();}
|
||||
function asmAddPart(mod){
|
||||
const a=ensureAssembly();
|
||||
a.parts.push({id:genId('ap'),name:mod?mod.name:'',call:mod?mod.call:'',offset:[0,0,0],qty:1});
|
||||
renderAssembly();scheduleSave();
|
||||
}
|
||||
function asmDelPart(id){
|
||||
const a=ensureAssembly();
|
||||
a.parts=a.parts.filter(p=>p.id!==id);
|
||||
a.steps.forEach(s=>{s.partIds=s.partIds.filter(x=>x!==id);});
|
||||
renderAssembly();scheduleSave();
|
||||
}
|
||||
function asmMovePart(id,dir){
|
||||
const a=ensureAssembly();
|
||||
const i=a.parts.findIndex(p=>p.id===id),j=i+dir;
|
||||
if(i<0||j<0||j>=a.parts.length)return;
|
||||
[a.parts[i],a.parts[j]]=[a.parts[j],a.parts[i]];
|
||||
renderAssembly();scheduleSave();
|
||||
}
|
||||
function asmAddStep(){
|
||||
const a=ensureAssembly();
|
||||
a.steps.push({id:genId('as'),title:'',desc:'',partIds:[],fasteners:[],tools:[]});
|
||||
renderAssembly();scheduleSave();
|
||||
}
|
||||
function asmDelStep(id){const a=ensureAssembly();a.steps=a.steps.filter(s=>s.id!==id);renderAssembly();scheduleSave();}
|
||||
function asmMoveStep(id,dir){
|
||||
const a=ensureAssembly();
|
||||
const i=a.steps.findIndex(s=>s.id===id),j=i+dir;
|
||||
if(i<0||j<0||j>=a.steps.length)return;
|
||||
[a.steps[i],a.steps[j]]=[a.steps[j],a.steps[i]];
|
||||
renderAssembly();scheduleSave();
|
||||
}
|
||||
function asmStepFind(id){return ensureAssembly().steps.find(s=>s.id===id);}
|
||||
function asmStep(id,k,v){const s=asmStepFind(id);if(!s)return;s[k]=v;asmMarkChanged();}
|
||||
function asmToggleStepPart(stepId,partId,on){
|
||||
const s=asmStepFind(stepId);if(!s)return;
|
||||
s.partIds=on?[...new Set([...s.partIds,partId])]:s.partIds.filter(x=>x!==partId);
|
||||
renderAssembly();
|
||||
scheduleSave();
|
||||
}
|
||||
// "이름 | 수량" / "이름 × 수량" / "이름 x 수량" 한 줄씩 → [{name,qty}]
|
||||
function asmFasteners(id,text){
|
||||
const s=asmStepFind(id);if(!s)return;
|
||||
s.fasteners=String(text||'').split('\n').map(l=>l.trim()).filter(Boolean).map(l=>{
|
||||
const m=/^(.*?)\s*(?:[|×]\s*|\s[xX*]\s+)(\d+)\s*$/.exec(l);
|
||||
return m?{name:m[1].trim(),qty:Math.max(1,Number(m[2])||1)}:{name:l,qty:1};
|
||||
}).filter(f=>f.name);
|
||||
asmMarkChanged();
|
||||
}
|
||||
function asmTools(id,text){
|
||||
const s=asmStepFind(id);if(!s)return;
|
||||
s.tools=String(text||'').split(',').map(t=>t.trim()).filter(Boolean);
|
||||
asmMarkChanged();
|
||||
}
|
||||
|
||||
async function asmScan(){
|
||||
const a=ensureAssembly();
|
||||
const out=document.getElementById('asm-scan-out');
|
||||
if(!a.scad){out.innerHTML='<div class="asm-warn">.scad 경로를 먼저 입력하세요(입력칸을 누르면 프로젝트의 .scad 후보가 뜹니다).</div>';return;}
|
||||
out.innerHTML='<div class="rb-empty">스캔 중…</div>';
|
||||
try{
|
||||
const r=await fetch('/api/workshop/assembly-scan?path='+encodeURIComponent(a.scad),{headers:authH()});
|
||||
const d=await r.json();
|
||||
if(!r.ok)throw new Error(d.error||('HTTP '+r.status));
|
||||
asmScanMods=d.modules;
|
||||
out.innerHTML=asmScanHtml();
|
||||
}catch(e){out.innerHTML='<div class="asm-warn">스캔 실패: '+escHtml(e.message)+'</div>';}
|
||||
}
|
||||
// 스캔 결과는 부품을 추가해 패널이 다시 그려져도 남아 있어야 한다(연속으로 여러 모듈을 담는 흐름).
|
||||
function asmScanHtml(){
|
||||
if(!asmScanMods)return '';
|
||||
const usable=asmScanMods.filter(m=>!m.isPrintPart);
|
||||
return '<div class="asm-lbl">조립 좌표 모듈 — 눌러서 부품으로 추가 (part_* 는 프린트 배치용이라 제외)</div><div class="asm-mods">'
|
||||
+(usable.length?usable.map((m,i)=>'<button class="rb-mini-btn" onclick="asmAddFromScan('+i+')" title="'+escAttr(m.params.join(', '))+'">+ '+escHtml(m.name)+(m.requiredParams.length?'('+escHtml(m.requiredParams.join(', '))+')':'()')+'</button>').join(''):'<span class="rb-empty">module이 없습니다.</span>')+'</div>';
|
||||
}
|
||||
function asmAddFromScan(i){
|
||||
const m=(asmScanMods||[]).filter(x=>!x.isPrintPart)[i];
|
||||
if(!m)return;
|
||||
// 필수 인자가 있는 모듈은 0으로 채운 호출을 만들어 두고 사용자가 고치게 한다(예: camera(0) → camera(80)).
|
||||
asmAddPart({name:m.name,call:m.name+'('+m.requiredParams.map(()=>'0').join(', ')+')'});
|
||||
}
|
||||
|
||||
async function asmRender(silent){
|
||||
const a=assembly;
|
||||
if(!a||!a.parts.length||!a.steps.length){if(!silent)alert('부품과 단계를 먼저 채워 주세요.');return;}
|
||||
if(asmBusy)return;
|
||||
const proj=currentProject();
|
||||
if(!proj)return;
|
||||
asmBusy=true;renderAsmStatus();
|
||||
try{
|
||||
await flushNow(); // 명세 저장이 끝난 뒤 렌더(서버는 저장된 명세로 그린다)
|
||||
if(conflict)throw new Error('저장 충돌을 먼저 해결하세요.');
|
||||
const r=await fetch('/api/workshop/assembly/'+encodeURIComponent(proj.id)+'/render',{method:'POST',headers:authH()});
|
||||
const d=await r.json().catch(()=>({}));
|
||||
if(!r.ok)throw new Error(d.error||('HTTP '+r.status));
|
||||
asmInfo=d;
|
||||
asmLastError='';
|
||||
if(!silent&&d.warnings&&d.warnings.length)alert(d.warnings.join('\n'));
|
||||
}catch(e){
|
||||
asmLastError='렌더 실패: '+e.message;
|
||||
if(!silent)alert(asmLastError);
|
||||
}
|
||||
finally{asmBusy=false;renderAssemblyImages();}
|
||||
}
|
||||
|
||||
// 제외/경고 안내(자동 초안 결과) — 패널이 다시 그려져도 남는다
|
||||
function asmDraftNoteHtml(){
|
||||
if(!asmDraftInfo)return '';
|
||||
const i=asmDraftInfo;
|
||||
return (i.excluded.length?'<div class="asm-warn" style="color:var(--muted)">제외(투명/참고용으로 추정): '+i.excluded.map(escHtml).join(', ')+' — 필요하면 위 스캔 목록에서 직접 추가</div>':'')
|
||||
+i.warnings.map(w=>'<div class="asm-warn">⚠ '+escHtml(w)+'</div>').join('');
|
||||
}
|
||||
|
||||
// .scad에서 부품/분해 offset/조립 순서 초안을 자동으로 만든다. 반환: 성공 여부(자동 흐름이 이어서 렌더할지 판단).
|
||||
async function asmDraft(auto){
|
||||
const a=ensureAssembly();
|
||||
if(!a.scad){if(!auto)alert('.scad 경로를 먼저 입력하세요.');return false;}
|
||||
if(!auto&&a.parts.length&&!confirm('기존 부품/단계를 자동 초안으로 덮어쓸까요?\n(이름·설명·체결부품 편집 내용이 사라집니다. 되돌리기는 🕘 이력에서 가능)'))return false;
|
||||
const out=document.getElementById('asm-draft-note');
|
||||
if(out)out.innerHTML='<div class="rb-empty">.scad 분석 중…</div>';
|
||||
try{
|
||||
const r=await fetch('/api/workshop/assembly-draft',{method:'POST',headers:authH({'Content-Type':'application/json'}),body:JSON.stringify({scad:a.scad})});
|
||||
const d=await r.json().catch(()=>({}));
|
||||
if(!r.ok)throw new Error(d.error||('HTTP '+r.status));
|
||||
// 자동 흐름에서는 "조립 블록(if PART=="all")"이 있는 .scad만 초안을 채운다 — 데모/애니메이션 파일로 엉뚱한
|
||||
// 명세가 조용히 만들어지는 걸 막는다(수동 ✨ 버튼은 그래도 허용하고 경고를 보여준다).
|
||||
if(auto&&d.region!=='all-block'){
|
||||
asmDraftInfo={excluded:[],warnings:['자동 연결만 했습니다 — 이 .scad엔 조립 블록(if (PART == "all"))이 없어 자동 초안은 만들지 않았습니다. 필요하면 ✨ 자동 초안을 직접 눌러 보세요.']};
|
||||
renderAssembly();
|
||||
return false;
|
||||
}
|
||||
const keep={scad:a.scad,autoRender:a.autoRender!==false,view:a.view,explode:a.explode};
|
||||
assembly=Object.assign({},d.assembly,keep);
|
||||
asmDraftInfo={excluded:d.excluded||[],warnings:d.warnings||[]};
|
||||
if(asmInfo&&asmInfo.rendered)asmInfo.specChanged=true;
|
||||
asmLastError='';
|
||||
renderAssembly();
|
||||
scheduleSave();
|
||||
return true;
|
||||
}catch(e){
|
||||
asmDraftInfo=null;
|
||||
asmLastError='자동 초안 실패: '+e.message;
|
||||
renderAssembly();
|
||||
if(!auto)alert(asmLastError);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// STEP들을 작업 탭의 "조립" 단계로 추가(이미 같은 문구가 있으면 건너뜀)
|
||||
function asmStepsToTasks(){
|
||||
const a=assembly;
|
||||
if(!a||!a.steps.length){alert('가져올 단계가 없습니다.');return;}
|
||||
let ph=phases.find(p=>p.name==='조립');
|
||||
if(!ph){ph={id:genId('ph'),name:'조립',tasks:[]};phases.push(ph);}
|
||||
let added=0;
|
||||
a.steps.forEach((s,i)=>{
|
||||
const text='STEP '+(i+1)+': '+(s.title||'(제목 없음)');
|
||||
if(!ph.tasks.some(t=>t.text===text)){ph.tasks.push({id:genId('t'),text,done:false});added++;}
|
||||
});
|
||||
renderPhases();scheduleSave();
|
||||
alert(added?('작업 탭의 "조립" 단계에 '+added+'개를 추가했습니다.'):'이미 모두 추가돼 있습니다.');
|
||||
}
|
||||
|
||||
// ── 사진 첨부(📷) ─────────────────────────────────────────────────────────────
|
||||
// 이 프로젝트의 사진을 골라 채팅 입력에 "[첨부 사진: 경로]"를 넣는다. 서버가 이 표시를 보면
|
||||
// workshop_project view_image로 그 사진을 모델이 직접 보게 한다(비전 모델일 때).
|
||||
|
||||
@@ -0,0 +1,367 @@
|
||||
// 작업실 앱 — 조립 설명서 탭 모듈(2026-09-25 workshop-app.js에서 분리, 내용 변경 없음).
|
||||
// 명세 편집(부품/단계), .scad 스캔·자동 초안, 렌더, 자동 연결/재렌더.
|
||||
// workshop-app.js가 탭을 처음 열 때 지연 로딩한다(classic script라 핵심 코드의 전역 변수/함수를 그대로 쓴다).
|
||||
// ── 조립 설명서 탭 ────────────────────────────────────────────────────────────
|
||||
// 명세(부품/단계)는 project.assembly에 들어 있어 저장/이력/충돌감지가 다른 데이터와 같다. 그림은 서버가
|
||||
// OpenSCAD로 렌더해 workshop/<id>/조립설명서/ 에 저장한다(설명서 페이지는 assembly-manual.html).
|
||||
let asmInfo=null; // 서버 조회 결과(그림 URL, stale 등)
|
||||
let asmScanMods=null; // 스캔한 .scad module 목록
|
||||
let asmScadFiles=[]; // 프로젝트 하위 .scad 후보
|
||||
let asmBusy=false;
|
||||
let asmLoadSeq=0;
|
||||
let asmAutoDone=''; // 자동 연결/초안을 이미 시도한 프로젝트 id(한 번만)
|
||||
let asmDraftInfo=null; // 마지막 자동 초안의 제외/경고 안내
|
||||
let asmLastError='';
|
||||
function asmPaneActive(){return document.getElementById('pane-assembly').classList.contains('active');}
|
||||
let asmProjectId='';
|
||||
function refreshAssemblyPaneImpl(forceLoad){
|
||||
const pid=(currentProject()||{}).id||'';
|
||||
if(asmProjectId!==pid){asmInfo=null;asmScanMods=null;asmScadFiles=[];asmProjectId=pid;asmDraftInfo=null;asmLastError='';} // 프로젝트가 바뀌면 그림 정보 초기화
|
||||
if(!asmPaneActive())return;
|
||||
renderAssembly();
|
||||
if(forceLoad||asmInfo===null)loadAssemblyInfo();
|
||||
}
|
||||
function ensureAssembly(){
|
||||
if(!assembly)assembly={scad:'',view:{rx:60,rz:35},explode:1,parts:[],steps:[]};
|
||||
return assembly;
|
||||
}
|
||||
async function loadAssemblyInfo(){
|
||||
const proj=currentProject();
|
||||
if(!proj)return;
|
||||
const seq=++asmLoadSeq;
|
||||
try{
|
||||
const r=await fetch('/api/workshop/assembly/'+encodeURIComponent(proj.id),{headers:authH()});
|
||||
if(!r.ok)return;
|
||||
const d=await r.json();
|
||||
if(seq!==asmLoadSeq)return;
|
||||
asmInfo=d;
|
||||
const fr=await fetch('/api/workshop/assembly-scad-files?projectId='+encodeURIComponent(proj.id),{headers:authH()});
|
||||
if(fr.ok)asmScadFiles=(await fr.json()).files||[];
|
||||
if(seq!==asmLoadSeq||!asmPaneActive())return;
|
||||
renderAssemblyImages();
|
||||
await asmAutoLink();
|
||||
}catch{}
|
||||
}
|
||||
|
||||
// .scad 자동 연동: ① 조립 모델이 비어 있으면 프로젝트의 가장 최근 .scad를 자동 연결하고, 부품이 없으면 초안까지
|
||||
// 만들어 그림을 렌더한다 ② 이미 연결돼 있고 .scad가 바뀌어 그림이 오래됐으면(autoRender 켜짐) 자동 재렌더.
|
||||
// 사용자가 만든 내용은 덮어쓰지 않는다(빈 명세일 때만 채움).
|
||||
async function asmAutoLink(){
|
||||
if(asmBusy)return;
|
||||
const a=assembly;
|
||||
if(!a||!a.scad){
|
||||
if(asmAutoDone===asmProjectId||!asmScadFiles.length)return;
|
||||
asmAutoDone=asmProjectId;
|
||||
ensureAssembly().scad=asmScadFiles[0];
|
||||
asmMarkChanged();
|
||||
renderAssembly();
|
||||
if(!assembly.parts.length&&await asmDraft(true))await asmRender(true);
|
||||
return;
|
||||
}
|
||||
// 연결만 돼 있고 부품/단계가 하나도 없으면(자동 연결 직후 중단됐거나 수동으로 경로만 넣은 경우) 초안을 한 번 시도
|
||||
if(a.scad&&!a.parts.length&&!a.steps.length&&asmAutoDone!==asmProjectId+':draft'){
|
||||
asmAutoDone=asmProjectId+':draft';
|
||||
if(await asmDraft(true))await asmRender(true);
|
||||
return;
|
||||
}
|
||||
if(asmInfo&&asmInfo.stale&&a.autoRender!==false&&a.parts.length&&a.steps.length&&asmAutoDone!==asmProjectId+':stale'){
|
||||
asmAutoDone=asmProjectId+':stale'; // 실패해도 이 화면에서 무한 재시도하지 않는다
|
||||
await asmRender(true);
|
||||
}
|
||||
}
|
||||
function asmMarkChanged(){if(asmInfo&&asmInfo.rendered)asmInfo.specChanged=true;scheduleSave();renderAsmStatus();}
|
||||
|
||||
function renderAssembly(){
|
||||
const pane=document.getElementById('pane-assembly');
|
||||
const a=assembly;
|
||||
const bomNames=parts.map(p=>p.name).filter(Boolean);
|
||||
const partRows=(a?a.parts:[]).map((p,i)=>`
|
||||
<div class="asm-row asm-part" data-id="${escAttr(p.id)}">
|
||||
<span class="num">${i+1}</span>
|
||||
<input type="text" style="flex:1;min-width:110px" placeholder="부품 이름" value="${escAttr(p.name)}" oninput="asmPart('${p.id}','name',this.value)">
|
||||
<input type="text" style="flex:1.2;min-width:120px;font-family:monospace" placeholder="모듈 호출 예: camera(80)" value="${escAttr(p.call)}" oninput="asmPart('${p.id}','call',this.value)" title=".scad의 조립 좌표 모듈 호출">
|
||||
<span style="font-size:10px;color:var(--muted)">분해</span>
|
||||
${[0,1,2].map(k=>`<input type="number" class="off" title="${'XYZ'[k]} 방향으로 띄울 거리(mm)" value="${escAttr(p.offset[k])}" oninput="asmOffset('${p.id}',${k},this.value)">`).join('')}
|
||||
<select title="대시보드 BOM 부품과 연결(구매 상태 표시)" onchange="asmPart('${p.id}','bom',this.value)">
|
||||
<option value="">BOM 연결 없음</option>
|
||||
${bomNames.map(n=>`<option value="${escAttr(n)}" ${p.bom===n?'selected':''}>${escAttr(n)}</option>`).join('')}
|
||||
${p.bom&&!bomNames.includes(p.bom)?`<option value="${escAttr(p.bom)}" selected>${escAttr(p.bom)}</option>`:''}
|
||||
</select>
|
||||
<input type="number" min="1" style="width:52px" title="수량" value="${escAttr(p.qty)}" oninput="asmPart('${p.id}','qty',this.value)">
|
||||
<button class="rb-mini-btn" onclick="asmMovePart('${p.id}',-1)" title="위로(번호 변경)">↑</button>
|
||||
<button class="rb-mini-btn" onclick="asmMovePart('${p.id}',1)" title="아래로(번호 변경)">↓</button>
|
||||
<button class="rb-del-btn" onclick="asmDelPart('${p.id}')">✕</button>
|
||||
</div>`).join('')||'<div class="rb-empty">부품이 없습니다. 위에서 .scad를 스캔해 모듈을 추가하거나 직접 추가하세요.</div>';
|
||||
const stepCards=(a?a.steps:[]).map((st,i)=>`
|
||||
<div class="asm-step" data-id="${escAttr(st.id)}">
|
||||
<div class="asm-row" style="margin-bottom:2px">
|
||||
<b style="color:#f59e0b">STEP ${i+1}</b>
|
||||
<input type="text" style="flex:1;min-width:140px" placeholder="단계 제목" value="${escAttr(st.title)}" oninput="asmStep('${st.id}','title',this.value)">
|
||||
<button class="rb-mini-btn" onclick="asmMoveStep('${st.id}',-1)">↑</button>
|
||||
<button class="rb-mini-btn" onclick="asmMoveStep('${st.id}',1)">↓</button>
|
||||
<button class="rb-del-btn" onclick="asmDelStep('${st.id}')">✕</button>
|
||||
</div>
|
||||
<textarea placeholder="작업 내용(어떻게 끼우고 조이는지)" oninput="asmStep('${st.id}','desc',this.value)">${escAttr(st.desc)}</textarea>
|
||||
<div class="asm-lbl">이번 단계에 붙는 부품</div>
|
||||
<div class="asm-chips">${a.parts.length?a.parts.map((p,k)=>`<label class="asm-chip${st.partIds.includes(p.id)?' on':''}"><input type="checkbox" ${st.partIds.includes(p.id)?'checked':''} onchange="asmToggleStepPart('${st.id}','${p.id}',this.checked)"><span class="num">${k+1}</span>${escAttr(p.name)}</label>`).join(''):'<span class="rb-empty">먼저 부품을 추가하세요</span>'}</div>
|
||||
<div class="asm-lbl">체결 부품 (한 줄에 하나: 이름 | 수량)</div>
|
||||
<textarea style="min-height:38px" placeholder="M4x10 볼트 | 4" oninput="asmFasteners('${st.id}',this.value)">${escAttr((st.fasteners||[]).map(f=>f.name+' | '+f.qty).join('\n'))}</textarea>
|
||||
<div class="asm-lbl">공구 (쉼표로 구분)</div>
|
||||
<div class="asm-row"><input type="text" style="flex:1" placeholder="2.5mm 육각렌치, 니퍼" value="${escAttr((st.tools||[]).join(', '))}" oninput="asmTools('${st.id}',this.value)"></div>
|
||||
</div>`).join('')||'<div class="rb-empty">단계가 없습니다. "+ 단계 추가"를 누르세요.</div>';
|
||||
|
||||
pane.innerHTML=`
|
||||
<div class="asm-sec">
|
||||
<div class="asm-sec-hdr">📐 조립 모델(.scad) <span style="font-weight:400;color:var(--muted)">— 프로젝트의 OpenSCAD 조립 파일. 원본은 수정하지 않고 부품만 골라 그립니다.</span></div>
|
||||
<div class="asm-sec-body">
|
||||
<div class="asm-row">
|
||||
<input type="text" id="asm-scad" list="asm-scad-list" style="flex:1;min-width:220px" placeholder="workshop/<프로젝트id>/CAD/xxx.scad" value="${escAttr(a?a.scad:'')}" oninput="asmSet('scad',this.value)">
|
||||
<datalist id="asm-scad-list">${asmScadFiles.map(f=>`<option value="${escAttr(f)}">`).join('')}</datalist>
|
||||
<button class="rb-mini-btn" onclick="asmScan()">🔍 모듈 스캔</button>
|
||||
<button class="rb-mini-btn" onclick="asmDraft(false)" title=".scad의 조립 블록에서 부품·분해 위치·조립 순서를 자동으로 뽑습니다">✨ 자동 초안</button>
|
||||
</div>
|
||||
<div class="asm-row">
|
||||
<label style="font-size:11px;color:var(--muted);display:flex;align-items:center;gap:5px;cursor:pointer" title="채팅에서 .scad를 수정하거나, 이 탭을 열었을 때 그림이 오래됐으면 자동으로 다시 렌더링합니다">
|
||||
<input type="checkbox" ${!a||a.autoRender!==false?'checked':''} onchange="asmSet('autoRender',this.checked)">.scad가 바뀌면 그림 자동 갱신</label>
|
||||
<span style="font-size:10px;color:var(--muted)">${asmScadFiles.length>1?'· 이 프로젝트의 .scad '+asmScadFiles.length+'개 (입력칸에서 선택)':''}</span>
|
||||
</div>
|
||||
<div id="asm-draft-note">${asmDraftNoteHtml()}</div>
|
||||
<div class="asm-row">
|
||||
<span style="font-size:11px;color:var(--muted)">보는 각도</span>
|
||||
<input type="number" style="width:64px" title="위에서 내려다보는 각도(0~90)" value="${escAttr(a?a.view.rx:60)}" oninput="asmView('rx',this.value)">
|
||||
<input type="number" style="width:64px" title="회전 각도" value="${escAttr(a?a.view.rz:35)}" oninput="asmView('rz',this.value)">
|
||||
<span style="font-size:11px;color:var(--muted);margin-left:8px">분해 간격 ×</span>
|
||||
<input type="number" step="0.1" min="0.1" max="5" style="width:64px" value="${escAttr(a?a.explode:1)}" oninput="asmSet('explode',this.value)">
|
||||
</div>
|
||||
<div id="asm-scan-out">${asmScanHtml()}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="asm-sec">
|
||||
<div class="asm-sec-hdr">🔩 부품 <span style="font-weight:400;color:var(--muted)">번호 = 순서 (그림의 풍선 번호)</span><span class="rb-hdr-sp"></span>
|
||||
<button class="rb-mini-btn" onclick="asmAddPart()">+ 부품 추가</button></div>
|
||||
<div class="asm-sec-body">${partRows}</div>
|
||||
</div>
|
||||
<div class="asm-sec">
|
||||
<div class="asm-sec-hdr">🪜 조립 순서<span class="rb-hdr-sp"></span>
|
||||
<button class="rb-mini-btn" onclick="asmStepsToTasks()" title="STEP들을 '작업' 탭에 조립 단계로 추가">→ 작업 탭으로 가져오기</button>
|
||||
<button class="rb-mini-btn" onclick="asmAddStep()">+ 단계 추가</button></div>
|
||||
<div class="asm-sec-body">${stepCards}</div>
|
||||
</div>
|
||||
<div class="asm-sec">
|
||||
<div class="asm-sec-hdr">🖼 설명서 <span id="asm-status" style="font-weight:400"></span><span class="rb-hdr-sp"></span>
|
||||
<button class="rb-add-btn" style="margin:0" id="asm-render-btn" onclick="asmRender()">🖼 그림 렌더링</button>
|
||||
<a class="rb-mini-btn" style="text-decoration:none" id="asm-open" href="/html/assembly-manual.html?projectId=${encodeURIComponent((currentProject()||{}).id||'')}" target="_blank">📄 설명서 열기 / 인쇄</a></div>
|
||||
<div class="asm-sec-body">
|
||||
<div class="rb-empty" style="padding:0 0 6px">💬 채팅에 <b>"조립 설명서 만들어줘"</b>라고 하면 AI가 .scad를 읽고 부품/단계 초안을 잡아 줍니다.</div>
|
||||
<div id="asm-imgs"></div>
|
||||
</div>
|
||||
</div>`;
|
||||
renderAsmStatus();
|
||||
renderAssemblyImages();
|
||||
}
|
||||
|
||||
function renderAsmStatus(){
|
||||
const el=document.getElementById('asm-status');
|
||||
if(!el)return;
|
||||
const a=assembly;
|
||||
let t='';
|
||||
if(asmBusy)t='<span style="color:#f59e0b">렌더링 중… (부품 수에 따라 10~60초)</span>';
|
||||
else if(asmLastError)t='<span style="color:#ef4444">⚠ '+escHtml(asmLastError)+'</span>';
|
||||
else if(!a||!a.parts.length||!a.steps.length)t='<span style="color:var(--muted)">부품과 단계를 채운 뒤 렌더링하세요</span>';
|
||||
else if(!asmInfo||!asmInfo.rendered)t='<span style="color:var(--muted)">아직 렌더링 안 됨</span>';
|
||||
else t='<span style="color:var(--muted)">마지막 렌더 '+new Date(asmInfo.renderedAt).toLocaleString('ko-KR')+'</span>'+(asmInfo.stale?' <span style="color:#f59e0b">⚠ 모델이 바뀜</span>':'')+(asmInfo.specChanged?' <span style="color:#f59e0b">⚠ 명세 변경됨 — 다시 렌더링</span>':'');
|
||||
el.innerHTML=t;
|
||||
const b=document.getElementById('asm-render-btn');
|
||||
if(b){b.disabled=asmBusy;b.textContent=asmBusy?'렌더링 중…':'🖼 그림 렌더링';}
|
||||
}
|
||||
function renderAssemblyImages(){
|
||||
renderAsmStatus();
|
||||
const box=document.getElementById('asm-imgs');
|
||||
if(!box)return;
|
||||
if(!asmInfo||!asmInfo.images){box.innerHTML='';return;}
|
||||
const im=asmInfo.images;
|
||||
box.innerHTML='<div class="asm-imgs">'
|
||||
+'<figure><img src="'+escAttr(im.overview)+'" loading="lazy"><figcaption>조립 완성</figcaption></figure>'
|
||||
+(im.exploded?'<figure><img src="'+escAttr(im.exploded)+'" loading="lazy"><figcaption>분해도</figcaption></figure>':'')
|
||||
+im.steps.map((u,i)=>'<figure><img src="'+escAttr(u)+'" loading="lazy"><figcaption>STEP '+(i+1)+'</figcaption></figure>').join('')+'</div>'
|
||||
+(asmInfo.unassigned&&asmInfo.unassigned.length?'<div class="asm-warn">⚠ 어느 단계에도 없는 부품: '+asmInfo.unassigned.map(escHtml).join(', ')+'</div>':'');
|
||||
}
|
||||
|
||||
// ── 편집 핸들러(입력 중엔 다시 그리지 않아 포커스 유지, 구조가 바뀔 때만 renderAssembly) ──
|
||||
function asmSet(k,v){
|
||||
const a=ensureAssembly();
|
||||
a[k]=k==='explode'?(Number(v)||1):(k==='autoRender'?!!v:v);
|
||||
asmMarkChanged();
|
||||
}
|
||||
function asmView(k,v){ensureAssembly().view[k]=Number(v)||0;asmMarkChanged();}
|
||||
function asmFind(id){return ensureAssembly().parts.find(p=>p.id===id);}
|
||||
function asmPart(id,k,v){
|
||||
const p=asmFind(id);if(!p)return;
|
||||
if(k==='qty')p.qty=Math.max(1,Math.round(Number(v)||1));
|
||||
else if(k==='bom'){if(v)p.bom=v;else delete p.bom;}
|
||||
else p[k]=v;
|
||||
asmMarkChanged();
|
||||
}
|
||||
function asmOffset(id,k,v){const p=asmFind(id);if(!p)return;p.offset[k]=Number(v)||0;asmMarkChanged();}
|
||||
function asmAddPart(mod){
|
||||
const a=ensureAssembly();
|
||||
a.parts.push({id:genId('ap'),name:mod?mod.name:'',call:mod?mod.call:'',offset:[0,0,0],qty:1});
|
||||
renderAssembly();scheduleSave();
|
||||
}
|
||||
function asmDelPart(id){
|
||||
const a=ensureAssembly();
|
||||
a.parts=a.parts.filter(p=>p.id!==id);
|
||||
a.steps.forEach(s=>{s.partIds=s.partIds.filter(x=>x!==id);});
|
||||
renderAssembly();scheduleSave();
|
||||
}
|
||||
function asmMovePart(id,dir){
|
||||
const a=ensureAssembly();
|
||||
const i=a.parts.findIndex(p=>p.id===id),j=i+dir;
|
||||
if(i<0||j<0||j>=a.parts.length)return;
|
||||
[a.parts[i],a.parts[j]]=[a.parts[j],a.parts[i]];
|
||||
renderAssembly();scheduleSave();
|
||||
}
|
||||
function asmAddStep(){
|
||||
const a=ensureAssembly();
|
||||
a.steps.push({id:genId('as'),title:'',desc:'',partIds:[],fasteners:[],tools:[]});
|
||||
renderAssembly();scheduleSave();
|
||||
}
|
||||
function asmDelStep(id){const a=ensureAssembly();a.steps=a.steps.filter(s=>s.id!==id);renderAssembly();scheduleSave();}
|
||||
function asmMoveStep(id,dir){
|
||||
const a=ensureAssembly();
|
||||
const i=a.steps.findIndex(s=>s.id===id),j=i+dir;
|
||||
if(i<0||j<0||j>=a.steps.length)return;
|
||||
[a.steps[i],a.steps[j]]=[a.steps[j],a.steps[i]];
|
||||
renderAssembly();scheduleSave();
|
||||
}
|
||||
function asmStepFind(id){return ensureAssembly().steps.find(s=>s.id===id);}
|
||||
function asmStep(id,k,v){const s=asmStepFind(id);if(!s)return;s[k]=v;asmMarkChanged();}
|
||||
function asmToggleStepPart(stepId,partId,on){
|
||||
const s=asmStepFind(stepId);if(!s)return;
|
||||
s.partIds=on?[...new Set([...s.partIds,partId])]:s.partIds.filter(x=>x!==partId);
|
||||
renderAssembly();
|
||||
scheduleSave();
|
||||
}
|
||||
// "이름 | 수량" / "이름 × 수량" / "이름 x 수량" 한 줄씩 → [{name,qty}]
|
||||
function asmFasteners(id,text){
|
||||
const s=asmStepFind(id);if(!s)return;
|
||||
s.fasteners=String(text||'').split('\n').map(l=>l.trim()).filter(Boolean).map(l=>{
|
||||
const m=/^(.*?)\s*(?:[|×]\s*|\s[xX*]\s+)(\d+)\s*$/.exec(l);
|
||||
return m?{name:m[1].trim(),qty:Math.max(1,Number(m[2])||1)}:{name:l,qty:1};
|
||||
}).filter(f=>f.name);
|
||||
asmMarkChanged();
|
||||
}
|
||||
function asmTools(id,text){
|
||||
const s=asmStepFind(id);if(!s)return;
|
||||
s.tools=String(text||'').split(',').map(t=>t.trim()).filter(Boolean);
|
||||
asmMarkChanged();
|
||||
}
|
||||
|
||||
async function asmScan(){
|
||||
const a=ensureAssembly();
|
||||
const out=document.getElementById('asm-scan-out');
|
||||
if(!a.scad){out.innerHTML='<div class="asm-warn">.scad 경로를 먼저 입력하세요(입력칸을 누르면 프로젝트의 .scad 후보가 뜹니다).</div>';return;}
|
||||
out.innerHTML='<div class="rb-empty">스캔 중…</div>';
|
||||
try{
|
||||
const r=await fetch('/api/workshop/assembly-scan?path='+encodeURIComponent(a.scad),{headers:authH()});
|
||||
const d=await r.json();
|
||||
if(!r.ok)throw new Error(d.error||('HTTP '+r.status));
|
||||
asmScanMods=d.modules;
|
||||
out.innerHTML=asmScanHtml();
|
||||
}catch(e){out.innerHTML='<div class="asm-warn">스캔 실패: '+escHtml(e.message)+'</div>';}
|
||||
}
|
||||
// 스캔 결과는 부품을 추가해 패널이 다시 그려져도 남아 있어야 한다(연속으로 여러 모듈을 담는 흐름).
|
||||
function asmScanHtml(){
|
||||
if(!asmScanMods)return '';
|
||||
const usable=asmScanMods.filter(m=>!m.isPrintPart);
|
||||
return '<div class="asm-lbl">조립 좌표 모듈 — 눌러서 부품으로 추가 (part_* 는 프린트 배치용이라 제외)</div><div class="asm-mods">'
|
||||
+(usable.length?usable.map((m,i)=>'<button class="rb-mini-btn" onclick="asmAddFromScan('+i+')" title="'+escAttr(m.params.join(', '))+'">+ '+escHtml(m.name)+(m.requiredParams.length?'('+escHtml(m.requiredParams.join(', '))+')':'()')+'</button>').join(''):'<span class="rb-empty">module이 없습니다.</span>')+'</div>';
|
||||
}
|
||||
function asmAddFromScan(i){
|
||||
const m=(asmScanMods||[]).filter(x=>!x.isPrintPart)[i];
|
||||
if(!m)return;
|
||||
// 필수 인자가 있는 모듈은 0으로 채운 호출을 만들어 두고 사용자가 고치게 한다(예: camera(0) → camera(80)).
|
||||
asmAddPart({name:m.name,call:m.name+'('+m.requiredParams.map(()=>'0').join(', ')+')'});
|
||||
}
|
||||
|
||||
async function asmRender(silent){
|
||||
const a=assembly;
|
||||
if(!a||!a.parts.length||!a.steps.length){if(!silent)alert('부품과 단계를 먼저 채워 주세요.');return;}
|
||||
if(asmBusy)return;
|
||||
const proj=currentProject();
|
||||
if(!proj)return;
|
||||
asmBusy=true;renderAsmStatus();
|
||||
try{
|
||||
await flushNow(); // 명세 저장이 끝난 뒤 렌더(서버는 저장된 명세로 그린다)
|
||||
if(conflict)throw new Error('저장 충돌을 먼저 해결하세요.');
|
||||
const r=await fetch('/api/workshop/assembly/'+encodeURIComponent(proj.id)+'/render',{method:'POST',headers:authH()});
|
||||
const d=await r.json().catch(()=>({}));
|
||||
if(!r.ok)throw new Error(d.error||('HTTP '+r.status));
|
||||
asmInfo=d;
|
||||
asmLastError='';
|
||||
if(!silent&&d.warnings&&d.warnings.length)alert(d.warnings.join('\n'));
|
||||
}catch(e){
|
||||
asmLastError='렌더 실패: '+e.message;
|
||||
if(!silent)alert(asmLastError);
|
||||
}
|
||||
finally{asmBusy=false;renderAssemblyImages();}
|
||||
}
|
||||
|
||||
// 제외/경고 안내(자동 초안 결과) — 패널이 다시 그려져도 남는다
|
||||
function asmDraftNoteHtml(){
|
||||
if(!asmDraftInfo)return '';
|
||||
const i=asmDraftInfo;
|
||||
return (i.excluded.length?'<div class="asm-warn" style="color:var(--muted)">제외(투명/참고용으로 추정): '+i.excluded.map(escHtml).join(', ')+' — 필요하면 위 스캔 목록에서 직접 추가</div>':'')
|
||||
+i.warnings.map(w=>'<div class="asm-warn">⚠ '+escHtml(w)+'</div>').join('');
|
||||
}
|
||||
|
||||
// .scad에서 부품/분해 offset/조립 순서 초안을 자동으로 만든다. 반환: 성공 여부(자동 흐름이 이어서 렌더할지 판단).
|
||||
async function asmDraft(auto){
|
||||
const a=ensureAssembly();
|
||||
if(!a.scad){if(!auto)alert('.scad 경로를 먼저 입력하세요.');return false;}
|
||||
if(!auto&&a.parts.length&&!confirm('기존 부품/단계를 자동 초안으로 덮어쓸까요?\n(이름·설명·체결부품 편집 내용이 사라집니다. 되돌리기는 🕘 이력에서 가능)'))return false;
|
||||
const out=document.getElementById('asm-draft-note');
|
||||
if(out)out.innerHTML='<div class="rb-empty">.scad 분석 중…</div>';
|
||||
try{
|
||||
const r=await fetch('/api/workshop/assembly-draft',{method:'POST',headers:authH({'Content-Type':'application/json'}),body:JSON.stringify({scad:a.scad})});
|
||||
const d=await r.json().catch(()=>({}));
|
||||
if(!r.ok)throw new Error(d.error||('HTTP '+r.status));
|
||||
// 자동 흐름에서는 "조립 블록(if PART=="all")"이 있는 .scad만 초안을 채운다 — 데모/애니메이션 파일로 엉뚱한
|
||||
// 명세가 조용히 만들어지는 걸 막는다(수동 ✨ 버튼은 그래도 허용하고 경고를 보여준다).
|
||||
if(auto&&d.region!=='all-block'){
|
||||
asmDraftInfo={excluded:[],warnings:['자동 연결만 했습니다 — 이 .scad엔 조립 블록(if (PART == "all"))이 없어 자동 초안은 만들지 않았습니다. 필요하면 ✨ 자동 초안을 직접 눌러 보세요.']};
|
||||
renderAssembly();
|
||||
return false;
|
||||
}
|
||||
const keep={scad:a.scad,autoRender:a.autoRender!==false,view:a.view,explode:a.explode};
|
||||
assembly=Object.assign({},d.assembly,keep);
|
||||
asmDraftInfo={excluded:d.excluded||[],warnings:d.warnings||[]};
|
||||
if(asmInfo&&asmInfo.rendered)asmInfo.specChanged=true;
|
||||
asmLastError='';
|
||||
renderAssembly();
|
||||
scheduleSave();
|
||||
return true;
|
||||
}catch(e){
|
||||
asmDraftInfo=null;
|
||||
asmLastError='자동 초안 실패: '+e.message;
|
||||
renderAssembly();
|
||||
if(!auto)alert(asmLastError);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// STEP들을 작업 탭의 "조립" 단계로 추가(이미 같은 문구가 있으면 건너뜀)
|
||||
function asmStepsToTasks(){
|
||||
const a=assembly;
|
||||
if(!a||!a.steps.length){alert('가져올 단계가 없습니다.');return;}
|
||||
let ph=phases.find(p=>p.name==='조립');
|
||||
if(!ph){ph={id:genId('ph'),name:'조립',tasks:[]};phases.push(ph);}
|
||||
let added=0;
|
||||
a.steps.forEach((s,i)=>{
|
||||
const text='STEP '+(i+1)+': '+(s.title||'(제목 없음)');
|
||||
if(!ph.tasks.some(t=>t.text===text)){ph.tasks.push({id:genId('t'),text,done:false});added++;}
|
||||
});
|
||||
renderPhases();scheduleSave();
|
||||
alert(added?('작업 탭의 "조립" 단계에 '+added+'개를 추가했습니다.'):'이미 모두 추가돼 있습니다.');
|
||||
}
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
// 작업실 앱 — 장비(K2/밀링) 모듈(2026-09-25 workshop-app.js에서 분리, 내용 변경 없음).
|
||||
// K2 콤보/밀링 카메라 위젯 — 스냅샷 폴링, 라이브 WebRTC/go2rtc.
|
||||
// workshop-app.js가 탭을 처음 열 때 지연 로딩한다(classic script라 핵심 코드의 전역 변수/함수를 그대로 쓴다).
|
||||
// ── 장비 위젯(K2/밀링) ────────────────────────────────────────────────────────
|
||||
// "장비" 탭을 보고 있을 때만 폴링한다 — 계속 켜두면 K2는 매번 짧은 WebRTC 연결을,
|
||||
// 밀링은 go2rtc 프레임 요청을 반복하니 안 보는 동안은 자원을 안 쓰는 게 맞다.
|
||||
const MILL_CHANNEL_ID=1000;
|
||||
const MILL_GO2RTC_SRC='cam'; // go2rtc(127.0.0.1:1985) 릴레이 스트림 이름(config nvr.extraCameras.1000.go2rtcSrc)
|
||||
// 밀링 카드 라이브(2026-09-24) — go2rtc video-stream 커스텀 엘리먼트(WebRTC→MSE→MJPEG
|
||||
// 자동폴백)를 nvr-app 라이브 모달과 동일 패턴으로 쓴다. go2rtc(1985)에 브라우저가
|
||||
// 직접 붙으면 Origin 검사로 거부되므로 같은 오리진 WS 프록시(/api/nvr/go2rtc-ws)를 거친다.
|
||||
// https 페이지에서 LAN IP를 ws://로 하드코딩하면 mixed-content로 막히므로 반드시
|
||||
// 현재 오리진 기준 상대경로로 만든다(K2 카메라와 동일 패턴, 09-18 발견).
|
||||
const GO2RTC_WS=(location.protocol==='https:'?'wss://':'ws://')+location.host+'/api/nvr/go2rtc-ws';
|
||||
let millLive=false;
|
||||
function toggleMillLive(){
|
||||
if(millLive)stopMillLive();else startMillLive();
|
||||
}
|
||||
function startMillLive(){
|
||||
if(millLive)return;
|
||||
const vs=document.getElementById('mill-live');
|
||||
millLive=true;
|
||||
const btn=document.getElementById('mill-live-btn');
|
||||
btn.classList.add('on');btn.textContent='⏹ 라이브 종료';
|
||||
document.getElementById('mill-frame').style.display='none';
|
||||
document.getElementById('mill-placeholder').style.display='none';
|
||||
vs.style.display='block';
|
||||
vs.src=GO2RTC_WS+'?src='+encodeURIComponent(MILL_GO2RTC_SRC)+'&token='+encodeURIComponent(getToken());
|
||||
document.getElementById('mill-live-marker').innerHTML=' · <b style="color:#ef4444">● 실시간</b>';
|
||||
}
|
||||
function stopMillLive(){
|
||||
if(!millLive)return;
|
||||
millLive=false;
|
||||
const vs=document.getElementById('mill-live');
|
||||
try{vs.disconnectedCallback();}catch{} // WS/WebRTC 연결 정리(라이브러리 내부 정리 로직 재사용, nvr-app closeLive와 동일)
|
||||
vs.style.display='none';
|
||||
const btn=document.getElementById('mill-live-btn');
|
||||
btn.classList.remove('on');btn.textContent='▶ 라이브';
|
||||
document.getElementById('mill-live-marker').innerHTML='';
|
||||
// 스냅샷으로 즉시 복귀 — 단, 장비 탭을 벗어나며 stopEquipTimers가 부른 경우엔 안 그래도 된다.
|
||||
const pane=document.getElementById('pane-equip');
|
||||
if(pane&&pane.classList.contains('active'))refreshMillWidget();
|
||||
}
|
||||
let equipTimer=null;
|
||||
function startEquipTimers(){
|
||||
if(equipTimer)return;
|
||||
refreshK2Widget();
|
||||
refreshMillWidget();
|
||||
equipTimer=setInterval(()=>{if(document.hidden)return;refreshK2Widget();refreshMillWidget();},20000);
|
||||
}
|
||||
function stopEquipTimers(){
|
||||
clearInterval(equipTimer);
|
||||
equipTimer=null;
|
||||
stopK2Live(); // 장비 탭을 벗어나면 라이브 WebRTC 연결도 닫는다(안 보는 동안 자원 안 쓰기)
|
||||
stopMillLive();
|
||||
}
|
||||
|
||||
// 백그라운드 탭에선 폴링을 멈췄다가 다시 보이면 즉시 한 번 갱신한다.
|
||||
document.addEventListener('visibilitychange',()=>{
|
||||
if(!document.hidden&&equipTimer){refreshK2Widget();refreshMillWidget();}
|
||||
});
|
||||
|
||||
function refreshMillWidget(){
|
||||
if(millLive)return; // 라이브 중엔 스냅샷 폴링 건너뜀 — 실시간 비디오가 이미 떠있고 간섭·낭비 방지
|
||||
const img=document.getElementById('mill-frame');
|
||||
img.src='/api/nvr/frame/'+MILL_CHANNEL_ID+'?t='+Date.now();
|
||||
}
|
||||
function millFrameLoaded(){
|
||||
document.getElementById('mill-frame').style.display='block';
|
||||
document.getElementById('mill-placeholder').style.display='none';
|
||||
document.getElementById('mill-dot').className='rb-equip-dot on';
|
||||
}
|
||||
function millFrameError(){
|
||||
document.getElementById('mill-frame').style.display='none';
|
||||
document.getElementById('mill-placeholder').style.display='block';
|
||||
document.getElementById('mill-placeholder').textContent='카메라를 불러올 수 없습니다';
|
||||
document.getElementById('mill-dot').className='rb-equip-dot off';
|
||||
}
|
||||
|
||||
// K2 카메라는 go2rtc 없이 프린터 자체 WebRTC(:8000)로만 나오므로 정지 프레임을 얻으려면
|
||||
// 짧게 연결→ontrack에서 캔버스로 한 프레임 캡처→즉시 연결 종료한다(k2-app.html의 상시
|
||||
// 라이브 연결과 달리 "장비" 탭을 볼 때만, 20초 간격으로만 짧게 붙었다 끊는 방식).
|
||||
function grabK2Frame(){
|
||||
return new Promise((resolve)=>{
|
||||
let settled=false;
|
||||
const pc=new RTCPeerConnection({iceServers:[{urls:'stun:stun.l.google.com:19302'}]});
|
||||
const video=document.createElement('video');
|
||||
video.autoplay=true;video.muted=true;video.playsInline=true;
|
||||
const finish=(dataUrl)=>{
|
||||
if(settled)return;
|
||||
settled=true;
|
||||
try{pc.close();}catch{}
|
||||
try{video.remove();}catch{}
|
||||
resolve(dataUrl);
|
||||
};
|
||||
// 비디오 엘리먼트를 DOM에 붙이지 않으면 일부 브라우저에서 프레임 디코딩이 실제로
|
||||
// 시작되지 않아(재생 안 함) videoWidth/Height가 계속 0으로 남고 캔버스가 새까맣게
|
||||
// 찍힌다 — 화면 밖(offscreen)에 실제로 붙이고 명시적으로 play()까지 호출해야 한다.
|
||||
video.style.cssText='position:fixed;left:-9999px;top:-9999px;width:1px;height:1px;';
|
||||
document.body.appendChild(video);
|
||||
const captureWhenReady=(triesLeft)=>{
|
||||
if(settled)return;
|
||||
if(video.videoWidth>0&&video.videoHeight>0){
|
||||
try{
|
||||
const c=document.createElement('canvas');
|
||||
c.width=video.videoWidth;c.height=video.videoHeight;
|
||||
c.getContext('2d').drawImage(video,0,0,c.width,c.height);
|
||||
finish(c.toDataURL('image/jpeg',0.7));
|
||||
}catch{finish(null);}
|
||||
return;
|
||||
}
|
||||
if(triesLeft<=0){finish(null);return;}
|
||||
setTimeout(()=>captureWhenReady(triesLeft-1),200);
|
||||
};
|
||||
pc.ontrack=(ev)=>{
|
||||
video.srcObject=ev.streams[0];
|
||||
video.play().catch(()=>{});
|
||||
video.onloadeddata=()=>captureWhenReady(15);
|
||||
};
|
||||
pc.addTransceiver('video',{direction:'sendrecv'});
|
||||
let offerSent=false;
|
||||
const sendOfferOnce=()=>{
|
||||
if(offerSent||settled)return;
|
||||
offerSent=true;
|
||||
fetch('/api/k2/webrtc-signal',{method:'POST',headers:{'Content-Type':'text/plain',...authH()},body:btoa(JSON.stringify({type:'offer',sdp:pc.localDescription.sdp}))})
|
||||
.then(r=>r.text()).then(txt=>{
|
||||
const res=JSON.parse(atob(txt));
|
||||
if(res.type==='answer'&&!settled)pc.setRemoteDescription(new RTCSessionDescription(res));
|
||||
}).catch(()=>finish(null));
|
||||
};
|
||||
pc.onicecandidate=(ev)=>{if(ev.candidate===null)sendOfferOnce();};
|
||||
pc.createOffer().then(d=>pc.setLocalDescription(d)).then(()=>{setTimeout(sendOfferOnce,1500);}).catch(()=>finish(null));
|
||||
setTimeout(()=>finish(null),8000);
|
||||
});
|
||||
}
|
||||
|
||||
// ── K2 라이브 뷰(2026-09-24) ─────────────────────────────────────────────────
|
||||
// ▶ 라이브 버튼으로 카드 안에서 상시 WebRTC 스트리밍(k2-app.html의 검증된 연결 로직
|
||||
// 이식). 라이브 중엔 20초 스냅샷 그랩을 멈추고(refreshK2Widget 참조), 장비 탭을
|
||||
// 벗어나면 stopEquipTimers가 연결을 닫는다 — 안 보는 동안엔 자원을 안 쓴다.
|
||||
let k2LivePC=null;
|
||||
function k2Note(msg){
|
||||
const ph=document.getElementById('k2-placeholder');
|
||||
const img=document.getElementById('k2-frame');
|
||||
const video=document.getElementById('k2-live');
|
||||
video.style.display='none';
|
||||
img.style.display='none';
|
||||
ph.style.display='block';
|
||||
ph.textContent=msg;
|
||||
}
|
||||
function toggleK2Live(){
|
||||
if(k2LivePC)stopK2Live();else startK2Live();
|
||||
}
|
||||
function startK2Live(){
|
||||
if(k2LivePC)return;
|
||||
const video=document.getElementById('k2-live');
|
||||
const btn=document.getElementById('k2-live-btn');
|
||||
btn.classList.add('on');btn.textContent='⏹ 라이브 종료';
|
||||
const ph=document.getElementById('k2-placeholder');
|
||||
ph.style.display='none';
|
||||
// STUN을 빼면(host candidate만) 이 프린터의 ICE 응답이 실제로는 안 옴(DTLS failed,
|
||||
// bytesReceived 0) — k2-app.html에서 2026-09-18 확인된 퀴크라 그대로 유지.
|
||||
const pc=new RTCPeerConnection({iceServers:[{urls:'stun:stun.l.google.com:19302'}]});
|
||||
k2LivePC=pc;
|
||||
pc.ontrack=(ev)=>{
|
||||
if(k2LivePC!==pc)return;
|
||||
video.srcObject=ev.streams[0];
|
||||
video.play().catch(()=>{});
|
||||
ph.style.display='none';
|
||||
};
|
||||
pc.oniceconnectionstatechange=()=>{
|
||||
if(k2LivePC!==pc)return;
|
||||
const st=pc.iceConnectionState;
|
||||
if(st==='failed'){
|
||||
stopK2Live();
|
||||
k2Note('라이브 연결 실패 — ▶ 라이브를 다시 눌러주세요');
|
||||
}else if(st==='disconnected'){
|
||||
k2Note('라이브 신호 끊김(일시적) — 복구되면 그려집니다');
|
||||
}
|
||||
};
|
||||
// 오퍼는 ICE gathering 완전종료(null candidate) 또는 1.5초 중 빠른 쪽에 보낸다 —
|
||||
// 완전종료가 이 네트워크에서 20초+ 걸리는 k2-app.html의 실측 퀴크 그대로.
|
||||
let offerSent=false;
|
||||
const sendOfferOnce=()=>{
|
||||
if(offerSent||k2LivePC!==pc)return;
|
||||
offerSent=true;
|
||||
fetch('/api/k2/webrtc-signal',{method:'POST',headers:{'Content-Type':'text/plain',...authH()},body:btoa(JSON.stringify({type:'offer',sdp:pc.localDescription.sdp}))})
|
||||
.then(r=>r.text()).then(txt=>{
|
||||
const res=JSON.parse(atob(txt));
|
||||
if(res.type==='answer'&&k2LivePC===pc)pc.setRemoteDescription(new RTCSessionDescription(res));
|
||||
}).catch(()=>{if(k2LivePC===pc)stopK2Live();});
|
||||
};
|
||||
pc.onicecandidate=(ev)=>{if(ev.candidate===null)sendOfferOnce();};
|
||||
pc.addTransceiver('video',{direction:'sendrecv'});
|
||||
pc.createOffer().then(d=>pc.setLocalDescription(d)).then(()=>{setTimeout(sendOfferOnce,1500);}).catch(()=>{if(k2LivePC===pc)stopK2Live();});
|
||||
}
|
||||
function stopK2Live(){
|
||||
if(!k2LivePC)return;
|
||||
try{k2LivePC.close();}catch{}
|
||||
k2LivePC=null;
|
||||
const video=document.getElementById('k2-live');
|
||||
try{video.pause();video.srcObject=null;}catch{}
|
||||
video.style.display='none';
|
||||
const btn=document.getElementById('k2-live-btn');
|
||||
btn.classList.remove('on');btn.textContent='▶ 라이브';
|
||||
// 스냅샷으로 즉시 복귀 — 단, 장비 탭을 벗어나며 stopEquipTimers가 부른 경우엔
|
||||
// 안 그래도 된다(안 보는 탭에 프레임 그랩을 트리거하는 셈).
|
||||
const pane=document.getElementById('pane-equip');
|
||||
if(pane&&pane.classList.contains('active'))refreshK2Widget();
|
||||
}
|
||||
|
||||
function fmtK2Duration(sec){
|
||||
const h=Math.floor(sec/3600),m=Math.floor((sec%3600)/60);
|
||||
if(h>0)return h+'시간 '+m+'분';
|
||||
return m+'분';
|
||||
}
|
||||
const K2_STATE_LABEL={standby:'대기중',printing:'출력중',paused:'일시정지',complete:'완료',cancelled:'취소됨',error:'오류'};
|
||||
|
||||
async function refreshK2Widget(){
|
||||
const img=document.getElementById('k2-frame');
|
||||
const placeholder=document.getElementById('k2-placeholder');
|
||||
// 라이브 연결이 살아있으면 프레임 그랩(WebRTC 풀협상)을 건너뛴다 — 비디오가 이미
|
||||
// 실시간으로 흐르고 있고, 짧은 연결을 또 열면 둘이 간섭한다. 상태 조회는 계속한다.
|
||||
if(!k2LivePC){
|
||||
grabK2Frame().then(dataUrl=>{
|
||||
if(dataUrl){img.src=dataUrl;img.style.display='block';placeholder.style.display='none';}
|
||||
else if(img.style.display==='none'){placeholder.textContent='카메라를 불러올 수 없습니다';}
|
||||
});
|
||||
}
|
||||
try{
|
||||
const r=await fetch('/api/k2/status',{headers:authH()});
|
||||
const d=await r.json();
|
||||
const dot=document.getElementById('k2-dot');
|
||||
const statusEl=document.getElementById('k2-status-text');
|
||||
if(!d.online){
|
||||
dot.className='rb-equip-dot off';
|
||||
statusEl.innerHTML='<b>오프라인</b> — 프린터가 꺼져있거나 네트워크에 없음';
|
||||
return;
|
||||
}
|
||||
dot.className='rb-equip-dot '+(d.state==='printing'?'on':'');
|
||||
const stateLabel=K2_STATE_LABEL[d.state]||d.state;
|
||||
let html='상태: <b>'+escAttr(stateLabel)+'</b>';
|
||||
if(d.filename)html+=' · 파일: <b>'+escAttr(d.filename)+'</b>';
|
||||
if(d.state==='printing'){
|
||||
html+='<br>진행률: <b>'+d.progress+'%</b> · 경과: <b>'+fmtK2Duration(d.printDurationSec)+'</b>';
|
||||
}
|
||||
if(k2LivePC)html+=' · <b style="color:#ef4444">● 실시간</b>';
|
||||
statusEl.innerHTML=html;
|
||||
}catch{
|
||||
document.getElementById('k2-status-text').textContent='상태 조회 실패';
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user