단순 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>
711 lines
39 KiB
HTML
711 lines
39 KiB
HTML
<!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,'"')}">${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>
|