Files
homeclaw/web-ui/html/k2-app.html
T
kimandClaude Code 5920540689 feat: k2-app 슬라이싱 풀 프로필 에디터 추가
단순 6파라미터 모달을 PrusaSlicer --load <ini> 기반 풀 에디터로 교체.
베이스 ini(K2_BASE_INI, 약 60키 큐레이션)를 TS 문자열로 임베드하고
buildProfileIni가 오버라이드 머지 + canonical start_gcode(13줄 K2 프라이밍
시퀀스, placeholder 온도 치환)를 강제 주입 — k2_config.ini 원본의 3줄
start_gcode를 그대로 쓰면 프라이밍이 날아가 출력이 망가지는 회귀 회피.

웹 UI: 9개 섹션(기본/소재·온도/서포트/브림/속도/팬/표면/리트랙션/고급)
약 40파라미터 편집 + 프로파일 저장/불러오기/삭제. 속도·소재 프리셋 연동.

라우트: /api/k2/profile/base·/profiles CRUD + /api/k2/slice?profile= 변경.
print3d_model 채팅도구는 기존 buildBaseArgs 경로 유지(동작 변경 회피).

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-20 20:47:31 +09:00

711 lines
39 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>🖨️ K2 콤보</title>
<link rel="icon" type="image/png" sizes="64x64" href="/homeclaw_logo.png">
<link rel="stylesheet" href="../css/styles.css">
<script>try{var _t=localStorage.getItem('homeclaw_theme')||'dark';document.documentElement.setAttribute('data-theme',_t);}catch(e){}</script>
<style>
:root{--brand:#f97316;}
html,body{height:100%;margin:0;padding:0;}
body{background:var(--bg);color:var(--text);font-family:system-ui,sans-serif;display:flex;flex-direction:column;overflow:hidden;height:100dvh;}
.hdr{display:flex;align-items:center;gap:8px;padding:6px 12px;background:var(--panel);border-bottom:1px solid var(--line);flex-shrink:0;}
.hdr h1{font-size:13px;font-weight:700;margin:0;}
.hdr-sp{flex:1;}
.hdr-btn{background:none;border:1.5px solid var(--line);border-radius:6px;padding:3px 9px;font-size:11px;color:var(--muted);cursor:pointer;font-family:inherit;transition:.15s;text-decoration:none;display:inline-flex;align-items:center;}
.hdr-btn:hover{border-color:var(--brand);color:var(--brand);}
.body-wrap{flex:1;min-height:0;display:flex;flex-direction:column;}
.cam-bar{flex-shrink:0;background:#000;display:flex;justify-content:center;position:relative;max-height:32vh;}
.cam-bar video{max-width:100%;max-height:32vh;display:block;background:#000;}
.cam-status{position:absolute;top:6px;right:10px;font-size:11px;color:#9ca3af;background:rgba(0,0,0,.5);padding:2px 8px;border-radius:6px;}
.cam-empty{color:var(--muted);font-size:12px;padding:16px;text-align:center;}
.frame-wrap{flex:1;min-height:0;position:relative;}
.frame-wrap iframe{width:100%;height:100%;border:none;display:block;}
.err{display:none;position:absolute;inset:0;align-items:center;justify-content:center;flex-direction:column;gap:10px;background:var(--bg);color:var(--muted);font-size:13px;text-align:center;padding:20px;}
.err.show{display:flex;}
.err a{color:var(--brand);}
/* ── 슬라이싱 프로파일 에디터 모달 ── */
.slice-overlay{display:none;position:fixed;inset:0;background:rgba(0,0,0,.5);z-index:50;align-items:center;justify-content:center;padding:16px;}
.slice-overlay.show{display:flex;}
.slice-modal{width:100%;max-width:720px;max-height:92vh;overflow-y:auto;background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:16px;}
.slice-modal h2{font-size:14px;margin:0 0 12px;}
.slice-field{margin-bottom:10px;}
.slice-field label{display:block;font-size:11px;color:var(--muted);margin-bottom:3px;}
.slice-field input[type=file],.slice-field input[type=number],.slice-field input[type=text],.slice-field select{width:100%;box-sizing:border-box;background:var(--panel-2);border:1px solid var(--line);border-radius:6px;padding:6px 8px;font-size:12px;color:var(--text);font-family:inherit;outline:none;}
.slice-field input:focus,.slice-field select:focus{border-color:var(--brand);}
.slice-row{display:flex;gap:8px;}
.slice-row .slice-field{flex:1;}
.slice-check{display:flex;align-items:center;gap:6px;font-size:12px;color:var(--text);margin-bottom:8px;}
.slice-btn{background:var(--brand);border:none;border-radius:8px;padding:9px 14px;font-size:13px;color:#fff;cursor:pointer;font-family:inherit;font-weight:700;}
.slice-btn:disabled{opacity:.5;cursor:not-allowed;}
.slice-btn.ghost{background:none;border:1.5px solid var(--line);color:var(--muted);font-weight:600;padding:6px 10px;font-size:11px;}
.slice-btn.ghost:hover{border-color:var(--brand);color:var(--brand);}
.slice-close{float:right;background:none;border:none;font-size:16px;color:var(--muted);cursor:pointer;}
.slice-result{margin-top:12px;padding:10px;background:var(--panel-2);border-radius:8px;font-size:12px;line-height:1.7;display:none;}
.slice-result.show{display:block;}
.slice-err{margin-top:10px;color:#ef4444;font-size:12px;display:none;}
.slice-err.show{display:block;}
.start-btn{width:100%;margin-top:8px;background:#16a34a;border:none;border-radius:8px;padding:9px;font-size:13px;color:#fff;cursor:pointer;font-family:inherit;font-weight:700;}
.start-btn:disabled{opacity:.5;cursor:not-allowed;}
.prof-bar{display:flex;align-items:center;gap:6px;flex-wrap:wrap;margin-bottom:12px;padding:10px;background:var(--panel-2);border-radius:8px;}
.prof-bar select{background:var(--panel);border:1px solid var(--line);border-radius:6px;padding:5px 8px;font-size:12px;color:var(--text);font-family:inherit;outline:none;flex:1;min-width:120px;}
.prof-bar select:focus{border-color:var(--brand);}
.sec{border:1px solid var(--line);border-radius:8px;margin-bottom:8px;}
.sec>summary{cursor:pointer;padding:8px 12px;font-size:12px;font-weight:700;list-style:none;display:flex;align-items:center;gap:6px;}
.sec>summary::-webkit-details-marker{display:none;}
.sec>summary::before{content:'▸';color:var(--muted);transition:.15s;font-size:10px;}
.sec[open]>summary::before{content:'▾';}
.sec>summary:hover{color:var(--brand);}
.sec-body{padding:8px 12px 4px;display:grid;grid-template-columns:1fr 1fr 1fr;gap:8px;}
@media(max-width:600px){.sec-body{grid-template-columns:1fr 1fr;}.slice-modal{max-width:100%;}}
.sec-body .slice-field{margin-bottom:8px;}
.sec-tag{font-size:10px;color:var(--muted);background:var(--panel);padding:1px 6px;border-radius:4px;border:1px solid var(--line);margin-left:auto;font-weight:400;}
/* ── CAD(STL 확인) 모달 ── */
.cad-modal{max-width:520px;}
.cad-field select{width:100%;box-sizing:border-box;background:var(--panel-2);border:1px solid var(--line);border-radius:6px;padding:6px 8px;font-size:12px;color:var(--text);font-family:inherit;outline:none;}
.cad-view-btns{display:flex;gap:4px;flex-wrap:wrap;margin:8px 0;}
.cad-view-btn{background:var(--panel-2);border:1px solid var(--line);border-radius:6px;padding:4px 9px;font-size:11px;color:var(--muted);cursor:pointer;font-family:inherit;}
.cad-view-btn.active{border-color:var(--brand);color:var(--brand);}
.cad-preview{width:100%;aspect-ratio:1/1;background:#1a1a1a;border-radius:8px;display:flex;align-items:center;justify-content:center;overflow:hidden;margin-bottom:8px;}
.cad-preview img{width:100%;height:100%;object-fit:contain;}
.cad-preview .cad-placeholder{color:#666;font-size:11px;}
.cad-info{font-size:11px;color:var(--muted);margin-bottom:10px;line-height:1.7;}
.cad-hole-toggle{background:none;border:1px dashed var(--line);border-radius:6px;padding:5px 10px;font-size:11px;color:var(--muted);cursor:pointer;font-family:inherit;width:100%;text-align:left;margin-top:6px;}
.cad-hole-toggle:hover{border-color:var(--brand);color:var(--brand);}
.cad-hole-form{display:none;margin-top:8px;padding-top:8px;border-top:1px solid var(--line);}
.cad-hole-form.show{display:block;}
</style>
</head>
<body>
<header class="hdr">
<a href="/" style="text-decoration:none;font-size:13px;color:var(--muted)">← 홈</a>
<h1>🖨️ K2 콤보</h1>
<span class="hdr-sp"></span>
<button class="hdr-btn" onclick="openSliceModal()">🔪 슬라이싱</button>
<button class="hdr-btn" onclick="openCadModal()">🔧 STL 확인</button>
<button class="hdr-btn" onclick="reconnectCam()">📷 카메라 재연결</button>
<button class="hdr-btn" onclick="reloadFrame()">↺ 새로고침</button>
<a class="hdr-btn" href="https://print.applecherry.net/" target="_blank">↗ 새 탭</a>
<button class="hdr-btn" onclick="toggleTheme()">🌙</button>
</header>
<div class="body-wrap">
<div class="cam-bar" id="cam-bar">
<video id="k2-cam" autoplay muted playsinline></video>
<span class="cam-status" id="cam-status">연결 중...</span>
</div>
<div class="frame-wrap">
<iframe id="k2-frame" src="https://print.applecherry.net/" title="Fluidd"></iframe>
<div class="err" id="k2-err">
<div>Fluidd를 불러오지 못했습니다 (print.applecherry.net).</div>
<div><a href="https://print.applecherry.net/" target="_blank">새 탭에서 직접 열기 ↗</a></div>
</div>
</div>
</div>
<div class="slice-overlay" id="slice-overlay">
<div class="slice-modal">
<button class="slice-close" onclick="closeSliceModal()">✕</button>
<h2>🔧 슬라이싱 프로파일 에디터</h2>
<div class="prof-bar">
<span style="font-size:11px;color:var(--muted)">프로파일</span>
<select id="prof-select"><option value="">— 기본(베이스) —</option></select>
<button class="slice-btn ghost" onclick="profLoadSelected()">불러오기</button>
<button class="slice-btn ghost" onclick="profSaveAs()">저장</button>
<button class="slice-btn ghost" onclick="profDeleteSelected()" style="color:#ef4444;border-color:#ef4444">삭제</button>
<button class="slice-btn ghost" onclick="profResetBase()">기본값</button>
</div>
<div id="prof-sections"></div>
<div style="margin-top:12px;border-top:1px solid var(--line);padding-top:12px">
<div class="slice-row">
<div class="slice-field">
<label>STL 파일</label>
<input type="file" id="slice-file" accept=".stl">
</div>
<div class="slice-field" style="max-width:120px">
<label>수량(복제)</label>
<input type="number" id="slice-qty" value="1" min="1" max="20">
</div>
</div>
<button class="slice-btn" id="slice-run-btn" style="width:100%" onclick="runSlice()">슬라이싱 + K2 업로드</button>
<div class="slice-err" id="slice-err"></div>
<div class="slice-result" id="slice-result"></div>
</div>
</div>
</div>
<div class="slice-overlay" id="cad-overlay">
<div class="slice-modal cad-modal">
<button class="slice-close" onclick="closeCadModal()">✕</button>
<h2>🔧 STL 확인</h2>
<div class="cad-field">
<label>파일</label>
<select id="cad-file-select" onchange="onCadFileChange()"></select>
</div>
<div class="cad-view-btns" id="cad-view-btns">
<button class="cad-view-btn active" data-view="iso" onclick="cadSetView('iso')">비스듬히</button>
<button class="cad-view-btn" data-view="top" onclick="cadSetView('top')">위</button>
<button class="cad-view-btn" data-view="bottom" onclick="cadSetView('bottom')">아래</button>
<button class="cad-view-btn" data-view="front" onclick="cadSetView('front')">앞</button>
<button class="cad-view-btn" data-view="back" onclick="cadSetView('back')">뒤</button>
<button class="cad-view-btn" data-view="left" onclick="cadSetView('left')">왼쪽</button>
<button class="cad-view-btn" data-view="right" onclick="cadSetView('right')">오른쪽</button>
</div>
<div class="cad-preview" id="cad-preview"><span class="cad-placeholder" id="cad-placeholder">파일을 선택하세요</span><img id="cad-preview-img" style="display:none"></div>
<div class="cad-info" id="cad-info"></div>
<button class="cad-hole-toggle" onclick="toggleHoleForm()">🕳️ 구멍 뚫기 ▾</button>
<div class="cad-hole-form" id="cad-hole-form">
<div class="slice-row">
<div class="slice-field"><label>X</label><input type="number" id="hole-x" value="0" step="0.1"></div>
<div class="slice-field"><label>Y</label><input type="number" id="hole-y" value="0" step="0.1"></div>
<div class="slice-field"><label>Z</label><input type="number" id="hole-z" value="0" step="0.1"></div>
</div>
<div class="slice-row">
<div class="slice-field"><label>지름(mm)</label><input type="number" id="hole-diameter" value="2" step="0.1" min="0.1"></div>
<div class="slice-field"><label>길이(mm)</label><input type="number" id="hole-depth" value="20" step="0.5"></div>
<div class="slice-field"><label>방향축</label>
<select id="hole-axis"><option value="z">Z</option><option value="x">X</option><option value="y">Y</option></select>
</div>
</div>
<div class="slice-field"><label>결과 파일명</label><input type="text" id="hole-output" placeholder="예: esp32_top_with_hole.stl"></div>
<button class="slice-btn" onclick="runAddHole()">구멍 뚫어서 새 파일로 저장</button>
</div>
<div class="slice-err" id="cad-err"></div>
<div class="slice-result" id="cad-result"></div>
</div>
</div>
<script>
function toggleTheme(){const d=document.documentElement;const n=d.getAttribute('data-theme')==='dark'?'light':'dark';d.setAttribute('data-theme',n);try{localStorage.setItem('homeclaw_theme',n);}catch{}}
function reloadFrame(){const f=document.getElementById('k2-frame');f.src=f.src;}
let loaded=false;
document.getElementById('k2-frame').addEventListener('load',()=>{loaded=true;});
setTimeout(()=>{ if(!loaded) document.getElementById('k2-err').classList.add('show'); },8000);
// ── K2 콤보 내장 카메라(자체 WebRTC 시그널링, 프린터 펌웨어가 직접 서빙) ──
// 프린터 자체는 :8000/call/webrtc_local 로 SDP offer를 POST하면 answer를 돌려주는
// 평문 HTTP 시그널링(Moonraker webcam 목록엔 안 잡힘 — 크리얼리티 자체 프로토콜).
// 홈클로는 https://ai.applecherry.net 로 서빙되므로 브라우저에서 직접 http://로
// fetch(POST)하면 mixed-content로 막힘("주의 요함" + 카메라 검은 화면, 2026-09-18
// 발견) — 그래서 게이트웨이의 /api/k2/webrtc-signal(같은 오리진, HTTPS)을 거쳐
// 서버가 대신 그 요청을 해준다.
function authH(){const t=(function(){try{return sessionStorage.getItem('smallclaw_token')||localStorage.getItem('smallclaw_token')||'';}catch{return '';}})();return t?{'Authorization':'Bearer '+t}:{};}
let camPC=null;
function setStatus(t){const s=document.getElementById('cam-status'); if(s) s.textContent=t;}
function connectCam(){
if(camPC){ try{camPC.close();}catch(e){} }
setStatus('연결 중...');
// STUN을 빼면(host candidate만) 이 프린터의 ICE 응답이 실제로는 안 옴(DTLS
// failed, bytesReceived 0) — 크리얼리티 데모 코드 그대로 STUN 유지해야 정상
// 동작함(2026-09-18 확인, 원인 불명이나 재현됨).
const pc=new RTCPeerConnection({iceServers:[{urls:'stun:stun.l.google.com:19302'}]});
camPC=pc;
pc.ontrack=(ev)=>{
const v=document.getElementById('k2-cam');
v.srcObject=ev.streams[0];
setStatus('실시간');
};
pc.oniceconnectionstatechange=()=>{
if(pc===camPC && (pc.iceConnectionState==='failed'||pc.iceConnectionState==='disconnected')){
setStatus('끊김');
}
};
// 원래는 ev.candidate===null(ICE gathering 완전 종료)까지 기다린 뒤에만 오퍼를
// 보냈는데, 이 네트워크에서 그 완전종료 신호가 20~30초씩 걸림(가상/VPN 인터페이스들을
// 하나씩 훑고 타임아웃되는 걸로 보임) — 정작 이 프린터가 필요로 하는 STUN srflx
// 후보 자체는 보통 1~2초 안에 잡힘. 그래서 완전종료를 기다리지 않고 짧은 타임아웃
// 뒤에 그때까지 모인 후보로 바로 보낸다(2026-09-19, 20초+ 연결지연 확인 후 수정).
let offerSent=false;
function sendOfferOnce(){
if(offerSent || pc!==camPC) 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' && pc===camPC){
pc.setRemoteDescription(new RTCSessionDescription(res));
}
}).catch(()=>{ if(pc===camPC) setStatus('연결 실패'); });
}
pc.onicecandidate=(ev)=>{
if(ev.candidate===null) sendOfferOnce(); // 빨리 끝나면 그대로 씀
};
pc.addTransceiver('video',{direction:'sendrecv'});
pc.createOffer().then(d=>pc.setLocalDescription(d)).then(()=>{
setTimeout(sendOfferOnce, 1500); // 안 끝나면 1.5초 뒤 그때까지 모인 후보로 진행
}).catch(()=>{ if(pc===camPC) setStatus('연결 실패'); });
}
function reconnectCam(){ connectCam(); }
connectCam();
// ── 슬라이싱 프로파일 에디터 ───────────────────────────────────────────────────
// 2026-09-20: 단순 6파라미터 모달을 풀 프로파일 에디터로 교체. 서버는 --load <ini>
// 모드(/api/k2/profile/* + /api/k2/slice?profile=). 베이스값은 /api/k2/profile/base에서
// 받아 필드에 채우고, 편집값은 __current 임시 프로파일로 저장 뒤 슬라이싱.
// 소재 온도 프리셋(서버 MATERIAL_PRESETS와 동일값).
const MATERIAL_TEMP_PRESETS={
PLA:{temperature:220,first_layer_temperature:220,bed_temperature:50,first_layer_bed_temperature:50,filament_type:'PLA'},
PETG:{temperature:235,first_layer_temperature:235,bed_temperature:75,first_layer_bed_temperature:75,filament_type:'PETG'},
};
// 속도 프리셋 — 보통(PrusaSlicer 기본 근접) / 속성(K2 실측, FAST_SPEED_ARGS와 동일).
const SPEED_PRESETS={
normal:{perimeter_speed:60,external_perimeter_speed:50,infill_speed:80,solid_infill_speed:80,top_solid_infill_speed:40,travel_speed:130,bridge_speed:30,first_layer_speed:30},
fast:{perimeter_speed:150,external_perimeter_speed:100,infill_speed:200,solid_infill_speed:150,top_solid_infill_speed:100,travel_speed:300,bridge_speed:30,first_layer_speed:30},
};
const SPEED_KEYS=Object.keys(SPEED_PRESETS.normal);
// 파라미터 스키마 — 섹션별. iniKey는 PrusaSlicer ini 키. virtual 필드는 ini에 안 쓰이고
// 다른 필드들을 일괄 설정하는 프리셋 선택자. pct=true면 UI 숫자에 % 붙여 ini값 생성.
const PROF_SCHEMA=[
{title:'기본',open:true,fields:[
{key:'layer_height',label:'레이어 높이(mm)',type:'number',min:0.08,max:0.4,step:0.02},
{key:'first_layer_height',label:'첫 레이어 높이(mm)',type:'number',min:0.1,max:0.6,step:0.05},
{key:'perimeters',label:'벽 수',type:'number',min:1,max:8,step:1},
{key:'fill_density',label:'인필(%)',type:'number',min:0,max:100,step:5,pct:true},
{key:'fill_pattern',label:'인필 패턴',type:'select',opts:[['grid','그리드'],['gyroid','자이로이드'],['cubic','큐빅'],['triangles','삼각'],['honeycomb','허니콤'],['rectilinear','평행'],['monotonic','모노토닉']]},
{key:'top_solid_layers',label:'윗면 층 수',type:'number',min:1,max:10,step:1},
{key:'bottom_solid_layers',label:'바닥 층 수',type:'number',min:1,max:10,step:1},
{key:'__speed_preset',label:'속도 프리셋',type:'select',virtual:true,opts:[['normal','보통'],['fast','속성(약 40% 단축)'],['custom','사용자정의']]},
]},
{title:'소재·온도',fields:[
{key:'__material',label:'소재 프리셋',type:'select',virtual:true,opts:[['PLA','PLA'],['PETG','PETG']]},
{key:'filament_type',label:'필라멘트 종류',type:'select',opts:[['PLA','PLA'],['PETG','PETG'],['ABS','ABS'],['TPU','TPU']]},
{key:'temperature',label:'노즐 온도(℃)',type:'number',min:150,max:290,step:1},
{key:'first_layer_temperature',label:'첫 층 노즐 온도(℃)',type:'number',min:150,max:290,step:1},
{key:'bed_temperature',label:'베드 온도(℃)',type:'number',min:0,max:120,step:1},
{key:'first_layer_bed_temperature',label:'첫 층 베드 온도(℃)',type:'number',min:0,max:120,step:1},
]},
{title:'서포트',fields:[
{key:'support_material',label:'서포트 사용',type:'check'},
{key:'support_material_style',label:'서포트 스타일',type:'select',opts:[['grid','그리드'],['organic','유기(트리)'],['tree','트리(구형)'],['snake','스네이크']]},
{key:'support_material_threshold',label:'오버행 임계각(°)',type:'number',min:0,max:90,step:1},
{key:'support_material_buildplate_only',label:'바닥면 전용',type:'check'},
{key:'support_material_spacing',label:'서포트 간격(mm)',type:'number',min:0.5,max:10,step:0.5},
{key:'support_material_interface_layers',label:'접촉면 층 수',type:'number',min:0,max:5,step:1},
{key:'support_material_pattern',label:'서포트 패턴',type:'select',opts:[['rectilinear','평행'],['grid','그리드'],['honeycomb','허니콤'],['triangles','삼각']]},
]},
{title:'브림/래프트/스커트',fields:[
{key:'brim_width',label:'브림 폭(mm)',type:'number',min:0,max:20,step:0.5},
{key:'brim_type',label:'브림 종류',type:'select',opts:[['outer_only','바깥만'],['inner_only','안쪽만'],['outer_and_inner','양쪽'],['no_brim','없음']]},
{key:'raft_layers',label:'래프트 층 수',type:'number',min:0,max:10,step:1},
{key:'skirts',label:'스커트 수',type:'number',min:0,max:5,step:1},
{key:'skirt_distance',label:'스커트 거리(mm)',type:'number',min:0,max:20,step:1},
]},
{title:'속도(상세, mm/s)',fields:[
{key:'perimeter_speed',label:'외벽 속도',type:'number',min:10,max:300,step:5},
{key:'external_perimeter_speed',label:'가장 바깥벽',type:'number',min:10,max:200,step:5},
{key:'infill_speed',label:'인필 속도',type:'number',min:10,max:300,step:5},
{key:'solid_infill_speed',label:'솔리드 인필',type:'number',min:10,max:300,step:5},
{key:'top_solid_infill_speed',label:'윗면 인필',type:'number',min:10,max:200,step:5},
{key:'travel_speed',label:'이동 속도',type:'number',min:50,max:500,step:10},
{key:'bridge_speed',label:'브릿지 속도',type:'number',min:10,max:80,step:5},
{key:'first_layer_speed',label:'첫 층 속도',type:'number',min:5,max:80,step:1},
]},
{title:'팬/냉각',fields:[
{key:'cooling',label:'층간 냉각',type:'check'},
{key:'fan_below_layer_time',label:'팬 켜기 임계(초)',type:'number',min:0,max:120,step:1},
{key:'min_fan_speed',label:'최소 팬(%)',type:'number',min:0,max:100,step:5,pct:true},
{key:'max_fan_speed',label:'최대 팬(%)',type:'number',min:0,max:100,step:5,pct:true},
{key:'disable_fan_first_layers',label:'처음 N층 팬 끔',type:'number',min:0,max:10,step:1},
]},
{title:'표면 품질',fields:[
{key:'ironing',label:'아이어닝(다림질)',type:'check'},
{key:'ironing_type',label:'아이어닝 대상',type:'select',opts:[['top','윗면만'],['all','모든 솔리드'],['top_and_bottom','위아래'],['no','없음']]},
{key:'seam_position',label:'심(이음) 위치',type:'select',opts:[['aligned','정렬'],['rear','뒤'],['random','무작위'],['nearest','가장 가까움']]},
{key:'fuzzy_skin',label:'퍼지 스킨',type:'select',opts:[['none','없음'],['external','외부'],['all','전부']]},
{key:'elefant_foot_compensation',label:'코끼리발 보정(mm)',type:'number',min:0,max:0.5,step:0.05},
{key:'xy_size_compensation',label:'XY 크기 보정(mm)',type:'number',min:-1,max:1,step:0.05},
]},
{title:'리트랙션',fields:[
{key:'retract_length',label:'리트랙트 길이(mm)',type:'number',min:0,max:8,step:0.1},
{key:'retract_speed',label:'리트랙트 속도(mm/s)',type:'number',min:10,max:120,step:5},
{key:'retract_lift',label:'리트랙트 Z들기(mm)',type:'number',min:0,max:3,step:0.1},
{key:'retract_before_travel',label:'이동 전 리트랙트(mm)',type:'number',min:0,max:10,step:0.5},
]},
{title:'고급',fields:[
{key:'perimeter_generator',label:'외벽 생성기',type:'select',opts:[['arachne','아라크네(가변폭)'],['classic','클래식']]},
{key:'thick_bridges',label:'두꺼운 브릿지',type:'check'},
{key:'ensure_vertical_shell_thickness',label:'수직껍질 두께 보장',type:'select',opts:[['enabled','켜기'],['disabled','끄기']]},
{key:'variable_layer_height',label:'적응형 층높이',type:'check'},
]},
];
let profBaseValues={}; // /api/k2/profile/base 파싱값
let profLoaded=false; // base 로드 + 섹션 렌더링 완료 여부
function openSliceModal(){
document.getElementById('slice-overlay').classList.add('show');
document.getElementById('slice-err').classList.remove('show');
document.getElementById('slice-result').classList.remove('show');
if(!profLoaded){ profInit(); } else { profRefreshList(); }
}
function closeSliceModal(){ document.getElementById('slice-overlay').classList.remove('show'); }
function fmtDurationSec(sec){
const h=Math.floor(sec/3600),m=Math.round((sec%3600)/60);
if(h>0)return h+'시간 '+m+'분';
return m+'분';
}
function fieldId(key){ return 'prof-'+key; }
function fieldInputHtml(f){
const id=fieldId(f.key);
if(f.type==='check'){
return `<label class="slice-check"><input type="checkbox" id="${id}" data-key="${f.key}"> ${f.label}</label>`;
}
let ctrl='';
if(f.type==='select'){
const opts=f.opts.map(([v,l])=>`<option value="${v}">${l}</option>`).join('');
ctrl=`<select id="${id}" data-key="${f.key}">${opts}</select>`;
}else{
const rng=`min="${f.min??''}" max="${f.max??''}" step="${f.step??''}"`;
ctrl=`<input type="number" id="${id}" data-key="${f.key}" ${rng}>`;
}
return `<div class="slice-field"><label>${f.label}</label>${ctrl}</div>`;
}
function renderSections(){
const root=document.getElementById('prof-sections');
root.innerHTML=PROF_SCHEMA.map(s=>{
const body=s.fields.map(fieldInputHtml).join('');
return `<details class="sec"${s.open?' open':''}><summary>${s.title}</summary><div class="sec-body">${body}</div></details>`;
}).join('');
// 속도 프리셋 변경 → 속도 필드 일괄 설정; 속도 필드 수동 변경 → preset=custom.
const presetEl=document.getElementById(fieldId('__speed_preset'));
if(presetEl) presetEl.addEventListener('change',()=>{
const v=presetEl.value; if(v==='custom')return;
const p=SPEED_PRESETS[v]; if(!p)return;
for(const k of SPEED_KEYS){ const el=document.getElementById(fieldId(k)); if(el) el.value=p[k]; }
});
for(const k of SPEED_KEYS){
const el=document.getElementById(fieldId(k));
if(el) el.addEventListener('input',()=>{
const pe=document.getElementById(fieldId('__speed_preset')); if(pe) pe.value='custom';
});
}
// 소재 프리셋 → 온도 4종 + filament_type 설정.
const matEl=document.getElementById(fieldId('__material'));
if(matEl) matEl.addEventListener('change',()=>{
const p=MATERIAL_TEMP_PRESETS[matEl.value]; if(!p)return;
for(const k of Object.keys(p)){ const el=document.getElementById(fieldId(k)); if(!el)continue;
el.value=p[k];
}
});
}
function setFieldValue(key,raw){
const f=findField(key); if(!f)return;
const el=document.getElementById(fieldId(key)); if(!el)return;
if(f.type==='check'){ el.checked=String(raw)==='1'||String(raw).toLowerCase()==='true'; }
else if(f.pct){ el.value=parseFloat(String(raw))||0; }
else if(f.type==='number'){ el.value=parseFloat(String(raw)); }
else { el.value=String(raw); }
}
function findField(key){
for(const s of PROF_SCHEMA) for(const f of s.fields) if(f.key===key) return f;
return null;
}
// 편집값 → ini 포맷 문자열. virtual 필드는 제외.
function getIniValue(f){
const el=document.getElementById(fieldId(f.key)); if(!el)return null;
if(f.virtual)return null;
if(f.type==='check') return el.checked?'1':'0';
if(f.pct) return (parseFloat(el.value)||0)+'%';
return String(el.value);
}
// 스키마 순회하며 base와 다른 값만 오버라이드 맵으로 수집.
function collectOverrides(){
const ov={};
for(const s of PROF_SCHEMA) for(const f of s.fields){
if(f.virtual)continue;
const v=getIniValue(f); if(v==null)continue;
if(String(profBaseValues[f.key]??'')!==String(v)) ov[f.key]=v;
}
return ov;
}
// ini 값 맵(또는 base)을 필드에 채움. virtual 프리셋 필드도 추정 채움.
function applyValues(values){
for(const s of PROF_SCHEMA) for(const f of s.fields){
if(f.virtual)continue;
if(f.key in values) setFieldValue(f.key,values[f.key]);
}
// 속도 프리셋 추정: 8개 값이 어느 프리셋과 일치하는지.
const pe=document.getElementById(fieldId('__speed_preset'));
if(pe){
let matched='custom';
for(const [name,p] of Object.entries(SPEED_PRESETS)){
if(SPEED_KEYS.every(k=>String(document.getElementById(fieldId(k))?.value)===String(p[k]))){ matched=name; break; }
}
pe.value=matched;
}
// 소재 프리셋 추정: filament_type 기준.
const me=document.getElementById(fieldId('__material'));
if(me){ const ft=document.getElementById(fieldId('filament_type'))?.value||'PLA'; me.value=MATERIAL_TEMP_PRESETS[ft]?ft:'PLA'; }
}
async function profInit(){
renderSections();
try{
const r=await fetch('/api/k2/profile/base',{headers:authH()});
const d=await r.json(); if(!r.ok) throw new Error(d.error||('HTTP '+r.status));
profBaseValues=d.values||{};
applyValues(profBaseValues);
profLoaded=true;
profRefreshList();
}catch(e){
document.getElementById('slice-err').textContent='베이스 프로파일 로드 실패: '+e.message;
document.getElementById('slice-err').classList.add('show');
}
}
async function profRefreshList(){
const sel=document.getElementById('prof-select');
try{
const r=await fetch('/api/k2/profiles',{headers:authH()});
const d=await r.json(); const list=d.profiles||[];
const cur=sel.value;
sel.innerHTML='<option value="">— 기본(베이스) —</option>'+list.map(n=>`<option value="${n}">${n}</option>`).join('');
if(cur) sel.value=cur;
}catch(e){ /* 무시 */ }
}
function profResetBase(){
applyValues(profBaseValues);
document.getElementById('prof-select').value='';
document.getElementById('slice-err').classList.remove('show');
}
async function profLoadSelected(){
const name=document.getElementById('prof-select').value;
const errEl=document.getElementById('slice-err');
errEl.classList.remove('show');
if(!name){ applyValues(profBaseValues); return; }
try{
const r=await fetch('/api/k2/profiles/'+encodeURIComponent(name),{headers:authH()});
const d=await r.json(); if(!r.ok) throw new Error(d.error||('HTTP '+r.status));
applyValues(d.values||{});
}catch(e){
errEl.textContent='불러오기 실패: '+e.message; errEl.classList.add('show');
}
}
async function profSaveAs(){
const name=prompt('저장할 프로파일 이름(영문/숫자/밑줄/하이픈):');
if(!name)return;
const errEl=document.getElementById('slice-err'); errEl.classList.remove('show');
const ov=collectOverrides();
try{
const r=await fetch('/api/k2/profiles/'+encodeURIComponent(name),{method:'POST',headers:{'Content-Type':'application/json',...authH()},body:JSON.stringify({overrides:ov})});
const d=await r.json(); if(!r.ok) throw new Error(d.error||('HTTP '+r.status));
await profRefreshList();
document.getElementById('prof-select').value=name;
}catch(e){
errEl.textContent='저장 실패: '+e.message; errEl.classList.add('show');
}
}
async function profDeleteSelected(){
const name=document.getElementById('prof-select').value;
if(!name)return;
if(!confirm('프로파일 "'+name+'"을 삭제할까요?'))return;
const errEl=document.getElementById('slice-err'); errEl.classList.remove('show');
try{
const r=await fetch('/api/k2/profiles/'+encodeURIComponent(name),{method:'DELETE',headers:authH()});
const d=await r.json(); if(!r.ok) throw new Error(d.error||('HTTP '+r.status));
await profRefreshList();
}catch(e){
errEl.textContent='삭제 실패: '+e.message; errEl.classList.add('show');
}
}
async function runSlice(){
const fileInput=document.getElementById('slice-file');
const file=fileInput.files[0];
const errEl=document.getElementById('slice-err');
const resultEl=document.getElementById('slice-result');
errEl.classList.remove('show');
resultEl.classList.remove('show');
if(!file){ errEl.textContent='STL 파일을 선택하세요.'; errEl.classList.add('show'); return; }
const btn=document.getElementById('slice-run-btn');
btn.disabled=true; btn.textContent='슬라이싱 중... (몇 분 걸릴 수 있음)';
const qty=document.getElementById('slice-qty').value;
const ov=collectOverrides();
try{
// 1) 현재 편집값을 __current 임시 프로파일로 저장.
const sv=await fetch('/api/k2/profiles/__current',{method:'POST',headers:{'Content-Type':'application/json',...authH()},body:JSON.stringify({overrides:ov})});
const sd=await sv.json(); if(!sv.ok||!sd.success) throw new Error(sd.error||('HTTP '+sv.status));
// 2) 그 프로파일로 슬라이싱.
const qs=new URLSearchParams({profile:'__current',qty});
const buf=await file.arrayBuffer();
const r=await fetch('/api/k2/slice?'+qs.toString(),{
method:'POST',
headers:{'Content-Type':'application/octet-stream', ...authH()},
body:buf,
});
const d=await r.json();
if(!r.ok||!d.success){ throw new Error(d.error||('HTTP '+r.status)); }
const timeStr=d.printTimeSec!=null?fmtDurationSec(d.printTimeSec):'알 수 없음';
resultEl.innerHTML=
'✅ K2에 업로드 완료: <b>'+d.gcodeFilename+'</b><br>'+
'예상 출력시간: <b>'+timeStr+'</b> (수량 '+d.qty+'개 · 소재 '+d.material+')<br>'+
'<button class="start-btn" onclick="startSlicedPrint(\''+d.gcodeFilename.replace(/'/g,"\\'")+'\')">🖨️ 출력 시작</button>'+
'<div style="margin-top:6px;font-size:11px;color:var(--muted)">⚠️ 시작 후 K2 터치스크린에서 "필라멘트 삽입" 프롬프트가 뜨면 직접 눌러야 급지됩니다(원격 시작은 이 확인을 대신 못 눌러줌).</div>';
resultEl.classList.add('show');
}catch(e){
errEl.textContent='실패: '+e.message;
errEl.classList.add('show');
}finally{
btn.disabled=false; btn.textContent='슬라이싱 + K2 업로드';
}
}
async function startSlicedPrint(filename){
if(!confirm('"'+filename+'" 출력을 지금 시작할까요? (재료/시간이 소요되는 물리적 동작입니다)'))return;
try{
const r=await fetch('/api/k2/print-start',{method:'POST',headers:{'Content-Type':'application/json', ...authH()},body:JSON.stringify({filename})});
const d=await r.json();
if(!r.ok||!d.success) throw new Error(d.error||('HTTP '+r.status));
alert('출력을 시작했습니다.\n\n⚠️ K2 터치스크린을 확인하세요 — 원격으로 시작하면 "필라멘트 삽입" 확인 프롬프트가 떠서 직접 눌러줘야 실제 급지가 됩니다(안 누르면 압출 안 되고 히터가 자동으로 꺼짐).');
closeSliceModal();
}catch(e){
alert('출력 시작 실패: '+e.message);
}
}
// ── CAD(STL 확인) 모달 ──────────────────────────────────────────────────────
let cadCurrentFile=null;
let cadCurrentView='iso';
async function openCadModal(){
document.getElementById('cad-overlay').classList.add('show');
document.getElementById('cad-err').classList.remove('show');
document.getElementById('cad-result').classList.remove('show');
await cadLoadFileList();
}
function closeCadModal(){ document.getElementById('cad-overlay').classList.remove('show'); }
async function cadLoadFileList(){
const sel=document.getElementById('cad-file-select');
sel.innerHTML='<option value="">불러오는 중...</option>';
try{
const r=await fetch('/api/cad/list',{headers:authH()});
const d=await r.json();
const files=d.files||[];
if(!files.length){ sel.innerHTML='<option value="">워크스페이스에 STL 파일이 없습니다</option>'; return; }
sel.innerHTML=files.map(f=>`<option value="${f.replace(/"/g,'&quot;')}">${f}</option>`).join('');
if(cadCurrentFile && files.includes(cadCurrentFile)) sel.value=cadCurrentFile;
onCadFileChange();
}catch(e){
sel.innerHTML='<option value="">목록을 불러오지 못했습니다</option>';
}
}
function onCadFileChange(){
cadCurrentFile=document.getElementById('cad-file-select').value;
document.getElementById('cad-err').classList.remove('show');
document.getElementById('cad-result').classList.remove('show');
if(!cadCurrentFile){
document.getElementById('cad-info').textContent='';
document.getElementById('cad-preview-img').style.display='none';
document.getElementById('cad-placeholder').style.display='flex';
document.getElementById('cad-placeholder').textContent='파일을 선택하세요';
return;
}
cadLoadInfo();
cadRenderPreview();
}
async function cadLoadInfo(){
const infoEl=document.getElementById('cad-info');
infoEl.textContent='치수 확인 중...';
try{
const r=await fetch('/api/cad/info?path='+encodeURIComponent(cadCurrentFile),{headers:authH()});
const d=await r.json();
if(!r.ok) throw new Error(d.error||('HTTP '+r.status));
const b=d.bbox;
const size=[b.max[0]-b.min[0], b.max[1]-b.min[1], b.max[2]-b.min[2]].map(n=>n.toFixed(1));
infoEl.innerHTML='크기: <b>'+size.join(' × ')+' mm</b> · 삼각형 '+b.triangleCount+'개<br>'+
'X['+b.min[0].toFixed(1)+', '+b.max[0].toFixed(1)+'] Y['+b.min[1].toFixed(1)+', '+b.max[1].toFixed(1)+'] Z['+b.min[2].toFixed(1)+', '+b.max[2].toFixed(1)+']';
}catch(e){
infoEl.textContent='치수 조회 실패: '+e.message;
}
}
function cadSetView(view){
cadCurrentView=view;
document.querySelectorAll('.cad-view-btn').forEach(b=>b.classList.toggle('active', b.dataset.view===view));
cadRenderPreview();
}
async function cadRenderPreview(){
if(!cadCurrentFile)return;
const img=document.getElementById('cad-preview-img');
const placeholder=document.getElementById('cad-placeholder');
placeholder.style.display='flex'; placeholder.textContent='렌더링 중...';
img.style.display='none';
try{
const url='/api/cad/render?path='+encodeURIComponent(cadCurrentFile)+'&view='+cadCurrentView;
const r=await fetch(url,{headers:authH()});
if(!r.ok){ const d=await r.json().catch(()=>({})); throw new Error(d.error||('HTTP '+r.status)); }
const blob=await r.blob();
img.src=URL.createObjectURL(blob);
img.style.display='block';
placeholder.style.display='none';
}catch(e){
placeholder.textContent='렌더링 실패: '+e.message;
}
}
function toggleHoleForm(){
document.getElementById('cad-hole-form').classList.toggle('show');
}
async function runAddHole(){
const errEl=document.getElementById('cad-err');
const resultEl=document.getElementById('cad-result');
errEl.classList.remove('show'); resultEl.classList.remove('show');
if(!cadCurrentFile){ errEl.textContent='STL 파일을 먼저 선택하세요.'; errEl.classList.add('show'); return; }
const outputName=document.getElementById('hole-output').value.trim();
if(!outputName){ errEl.textContent='결과 파일명을 입력하세요.'; errEl.classList.add('show'); return; }
const dir=cadCurrentFile.includes('/')?cadCurrentFile.slice(0,cadCurrentFile.lastIndexOf('/')+1):'';
const body={
path:cadCurrentFile,
output_path:dir+outputName,
x:Number(document.getElementById('hole-x').value),
y:Number(document.getElementById('hole-y').value),
z:Number(document.getElementById('hole-z').value),
diameter:Number(document.getElementById('hole-diameter').value),
depth:Number(document.getElementById('hole-depth').value),
axis:document.getElementById('hole-axis').value,
};
try{
const r=await fetch('/api/cad/add-hole',{method:'POST',headers:{'Content-Type':'application/json',...authH()},body:JSON.stringify(body)});
const d=await r.json();
if(!r.ok||!d.success) throw new Error(d.error||('HTTP '+r.status));
resultEl.innerHTML='✅ 저장됨: <b>'+d.output_path+'</b>';
resultEl.classList.add('show');
await cadLoadFileList();
}catch(e){
errEl.textContent='구멍 뚫기 실패: '+e.message;
errEl.classList.add('show');
}
}
</script>
</body>
</html>