feat: k2-app Cura 스타일 3D 슬라이싱 GUI (Three.js 툴패스 레이어뷰)

폼 기반 프로필 에디터를 CuraEngine GUI처럼 풀뷰포트 3D 뷰로 교체.
Three.js r128 UMD 벤더(/vendor/three/)로 260³ 빌드 플레이트 위 STL을
3D로 띄우고, 슬라이스 후 gcode를 파싱해 ;TYPE:별 툴패스를 레이어별로 시각화.
슬라이스↔업로드 흐름을 Cura식으로 분리(slice≠전송) — 슬라이스만 하고
툴패스 검토 뒤 명시적으로 업로드/출력 시작.

- web-ui/js/k2/slicer-view.js: 3D 씬 + STL 로드 + gcode 툴패스 파서 + 레이어 슬라이더
  + 프로파일 패널(스키마·오버라이드·저장) 이전. ;LAYER_CHANGE 마커로 레이어 경계
  감지(Z변화 감지는 start_gcode 프라이밍 Z3→Z0.2→Z1이 가짜 층을 만드는 걸 회피).
- routes-k2.ts: /api/k2/slice 를 upload=0 기본(슬라이스만)으로 변경,
  POST /api/k2/upload·GET /api/k2/gcode/:filename 신규.
  gcode 라우트의 res.sendFile에 {dotfiles:'allow'} 추가 — 워크스페이스가
  .smallclaw(도트파일 디렉토리) 아래라 send 라이브러리 기본값 'ignore'가
  404 "Not Found"로 씹어버리는 걸 해결.
- k2-slicer-core.ts: parsePrusaFilament(mm/cm3) 추가.
- k2-app.html: 슬라이싱 모달→#slicer-overlay 컨테이너로 교체, Three.js 스크립트 로드.
  CAD 모달·카메라·Fluidd iframe은 그대로 유지.

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
kim
2026-09-20 21:08:23 +09:00
co-authored by Claude Code
parent 5920540689
commit 8fbf581ca1
7 changed files with 2281 additions and 393 deletions
+57 -4
View File
@@ -5,7 +5,7 @@ import { getUserWorkspace } from '../../config/config';
import { isPathInsideDir } from '../../tools/image';
import {
PRUSA_SLICER_BIN, runPrusaSlicer, uploadToMoonraker,
parsePrusaTime,
parsePrusaTime, parsePrusaFilament,
K2_BASE_INI, parseIni, buildProfileIni, profileSliceArgs,
} from '../../tools/k2-slicer-core';
@@ -34,6 +34,15 @@ function profilePath(workspacePath: string, name: string): string {
function validProfileName(name: string): boolean {
return PROFILE_NAME_RE.test(name) && name !== '__current';
}
// 슬라이싱 결과 gcode 파일명 검증 — print3d/ 디렉토리 안의 단일 파일명만 허용(슬래시/.. 거부).
const GCODE_NAME_RE = /^[A-Za-z0-9_\-]+\.gcode$/;
function gcodePath(workspacePath: string, filename: string): string {
if (!GCODE_NAME_RE.test(filename)) throw new Error('잘못된 gcode 파일명.');
const p = path.join(workspacePath, 'print3d', filename);
const dir = path.join(workspacePath, 'print3d');
if (!isPathInsideDir(dir, p)) throw new Error('경로가 print3d 디렉토리 밖입니다.');
return p;
}
export function registerK2Routes(app: Express): void {
// Moonraker 상태(print_stats/virtual_sdcard)를 대시보드 위젯용으로 요약해서 반환 —
@@ -208,20 +217,64 @@ export function registerK2Routes(app: Express): void {
return res.status(500).json({ error: '슬라이싱은 끝났는데 G-code 파일이 비정상입니다.' });
}
const printTimeSec = parsePrusaTime(fs.readFileSync(gcodePath, 'utf8'));
const gcodeText = fs.readFileSync(gcodePath, 'utf8');
const printTimeSec = parsePrusaTime(gcodeText);
const filament = parsePrusaFilament(gcodeText);
const up = await uploadToMoonraker(gcodePath, gcodeFilename);
if (!up.ok) return res.status(502).json({ error: `${up.detail} (슬라이싱은 성공, G-code는 서버에 있음)` });
// upload=1일 때만 Moonraker에 업로드(하위호환). 기본은 슬라이스만 — Cura 스타일 GUI가
// 툴패스 미리보기를 보여준 뒤 사용자가 명시적으로 업로드 버튼을 누르도록 흐름 분리.
const doUpload = req.query.upload === '1' || req.query.upload === 'true';
if (doUpload) {
const up = await uploadToMoonraker(gcodePath, gcodeFilename);
if (!up.ok) return res.status(502).json({ error: `${up.detail} (슬라이싱은 성공, G-code는 서버에 있음)` });
}
res.json({
success: true,
gcodeFilename,
printTimeSec,
filamentMm: filament.mm,
filamentCm3: filament.cm3,
uploaded: doUpload,
material: materialKey,
qty,
});
});
// 생성된 gcode를 Moonraker에 업로드(슬라이스와 분리된 명시적 전송 단계).
app.post('/api/k2/upload', express.json(), async (req: Request, res: Response) => {
const user = (req as any).user;
if (!user) return res.status(401).json({ error: 'Unauthorized' });
const filename = String(req.body?.filename || '').trim();
if (!filename) return res.status(400).json({ error: 'filename이 필요합니다.' });
try {
const p = gcodePath(getUserWorkspace(user.username), filename);
if (!fs.existsSync(p)) return res.status(404).json({ error: 'gcode 파일이 없습니다.' });
const up = await uploadToMoonraker(p, filename);
if (!up.ok) return res.status(502).json({ error: up.detail });
res.json({ success: true, filename });
} catch (e: any) {
res.status(400).json({ error: String(e?.message || e) });
}
});
// 슬라이싱 결과 gcode를 브라우저로 스트리밍 — Cura 스타일 툴패스 미리보기용.
app.get('/api/k2/gcode/:filename', (req: Request, res: Response) => {
const user = (req as any).user;
if (!user) return res.status(401).json({ error: 'Unauthorized' });
const filename = String(req.params.filename || '');
try {
const p = gcodePath(getUserWorkspace(user.username), filename);
if (!fs.existsSync(p)) return res.status(404).json({ error: 'gcode 파일이 없습니다.' });
// 워크스페이스 경로가 /srv/homeclaw/.smallclaw/... 인데, send 라이브러리(res.sendFile
// 베이스)의 dotfiles 기본값이 'ignore'라 .smallclaw 같은 도트파일 디렉토리 아래 파일을
// 404 "Not Found"로 씹어버림(2026-09-20 확인). 명시적으로 'allow'로 풀어야 스트리밍됨.
res.type('text/plain').sendFile(p, { dotfiles: 'allow' });
} catch (e: any) {
res.status(400).json({ error: String(e?.message || e) });
}
});
// 웹 UI에서 출력 시작 — Moonraker가 LAN 평문 HTTP라 브라우저가 직접 못 치므로(mixed-content) 프록시.
app.post('/api/k2/print-start', express.json(), async (req: Request, res: Response) => {
const user = (req as any).user;
+11
View File
@@ -56,6 +56,17 @@ export function parsePrusaTime(text: string): number | null {
return h * 3600 + min * 60 + s;
}
// PrusaSlicer 푸터의 필라멘트 사용량 파싱 — "; filament used [mm] = 2693.41" / "[cm3] = 6.48".
// [g]은 filament_density/cost 설정이 없으면 0.00이라 신뢰할 수 없어 mm/cm3만 뽑는다.
export function parsePrusaFilament(text: string): { mm: number | null; cm3: number | null } {
const mmM = text.match(/filament used \[mm\] = ([\d.]+)/);
const cmM = text.match(/filament used \[cm3\] = ([\d.]+)/);
return {
mm: mmM ? Number(mmM[1]) : null,
cm3: cmM ? Number(cmM[1]) : null,
};
}
export function runPrusaSlicer(args: string[], timeoutMs: number): Promise<{ ok: true } | { ok: false; detail: string }> {
return new Promise((resolve) => {
// DISPLAY가 실려있으면 GUI 초기화를 시도할 수 있어 명시적으로 제거하고 완전 헤드리스로 강제.
+13 -389
View File
@@ -30,10 +30,10 @@ body{background:var(--bg);color:var(--text);font-family:system-ui,sans-serif;dis
.err.show{display:flex;}
.err a{color:var(--brand);}
/* ── 슬라이싱 프로파일 에디터 모달 ── */
/* ── 슬라이싱/CAD 공통 모달 클래스 ── */
.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{width:100%;max-width:520px;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;}
@@ -44,28 +44,15 @@ body{background:var(--bg);color:var(--text);font-family:system-ui,sans-serif;dis
.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;}
/* ── 3D 슬라이서(Cura 스타일) 풀뷰포트 오버레이 — 실제 스타일은 slicer-view.js가 주입 ── */
#slicer-overlay{display:none;position:fixed;inset:0;z-index:55;}
#slicer-overlay.show{display:block;}
/* ── CAD(STL 확인) 모달 ── */
.cad-modal{max-width:520px;}
@@ -88,7 +75,7 @@ body{background:var(--bg);color:var(--text);font-family:system-ui,sans-serif;dis
<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="openSlicer()">🔪 슬라이싱</button>
<button class="hdr-btn" onclick="openCadModal()">🔧 STL 확인</button>
<button class="hdr-btn" onclick="reconnectCam()">📷 카메라 재연결</button>
<button class="hdr-btn" onclick="reloadFrame()">↺ 새로고침</button>
@@ -110,36 +97,7 @@ body{background:var(--bg);color:var(--text);font-family:system-ui,sans-serif;dis
</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 id="slicer-overlay"></div>
<div class="slice-overlay" id="cad-overlay">
<div class="slice-modal cad-modal">
@@ -248,345 +206,6 @@ function connectCam(){
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';
@@ -706,5 +325,10 @@ async function runAddHole(){
}
}
</script>
<!-- Three.js r128 UMD + 로더(Cura 스타일 3D 슬라이서 GUI용) — /vendor/three/ 정적 서빙 -->
<script src="/vendor/three/three.min.js"></script>
<script src="/vendor/three/STLLoader.js"></script>
<script src="/vendor/three/OrbitControls.js"></script>
<script type="module" src="/js/k2/slicer-view.js"></script>
</body>
</html>
+778
View File
@@ -0,0 +1,778 @@
// K2 콤보 Cura 스타일 3D 슬라이싱 GUI — 2026-09-20.
// Three.js(r128 UMD, /vendor/three/)로 260³ 빌드 플레이트 위 STL을 3D로 띄우고, 슬라이스 후
// gcode를 파싱해 툴패스를 레이어별로 시각화한다. 프로파일 설정(스키마·오버라이드·저장)은
// 이전 폼 에디터에서 이 모듈로 이전. 슬라이스↔업로드 흐름 분리(Cura식 slice≠전송).
// 백엔드: /api/k2/profile/* · /api/k2/slice?upload=0 · /api/k2/gcode/:filename · /api/k2/upload.
(function () {
'use strict';
const THREE = window.THREE;
if (!THREE || !THREE.STLLoader || !THREE.OrbitControls) {
console.error('[slicer-view] Three.js/vendor 로더가 로드되지 않았습니다.');
return;
}
// ── 상수 ──────────────────────────────────────────────────────────────
const BED = 260; // K2 콤보 빌드 플레이트
const PRINT_H = 260; // 최대 출력 높이
// ;TYPE:별 툴패스 색 (Cura 느낌)
const TYPE_COLORS = {
'External perimeter': 0xff3b30,
'Overhang perimeter': 0xff6b35,
'Perimeter': 0xff9500,
'Gap fill': 0xffcc00,
'Internal infill': 0xffcc00,
'Solid infill': 0xb0e020,
'Top solid infill': 0x34c759,
'Bridge infill': 0x5ac8fa,
'Support material': 0x0a84ff,
'Support material interface': 0x66d9ff,
'Skirt/Brim': 0xbf5af2,
'Ironing': 0xffffff,
'Custom': 0xaaaaaa,
};
const TRAVEL_COLOR = 0x555555;
const DEFAULT_COLOR = 0xffffff;
// 소재 온도 프리셋(서버 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' },
};
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);
// 파라미터 스키마 — 9개 섹션. virtual 필드는 ini에 안 쓰이는 프리셋 선택자. pct=true면 % 포맷.
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 overlayEl, vpEl, panelEl, barEl;
let scene, camera, renderer, controls, raycaster;
let stlMesh = null;
let toolpathGroup = null;
let layerObjects = []; // THREE.LineSegments per layer
let layerZs = []; // Z(mm) per layer
let curGcodeFile = null;
let profBaseValues = {};
let profLoaded = false;
let singleLayerMode = false;
function authH() {
let t = '';
try { t = sessionStorage.getItem('smallclaw_token') || localStorage.getItem('smallclaw_token') || ''; } catch (e) { }
return t ? { Authorization: 'Bearer ' + t } : {};
}
// ── Three.js 씬 초기화 ────────────────────────────────────────────────
function initScene() {
scene = new THREE.Scene();
scene.background = new THREE.Color(0x1a1a1a);
camera = new THREE.PerspectiveCamera(45, 1, 1, 4000);
camera.position.set(BED * 1.1, BED * 0.9, BED * 1.1);
renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setPixelRatio(window.devicePixelRatio);
vpEl.appendChild(renderer.domElement);
controls = new THREE.OrbitControls(camera, renderer.domElement);
controls.target.set(BED / 2, 0, BED / 2);
controls.enableDamping = true;
controls.dampingFactor = 0.12;
controls.maxPolarAngle = Math.PI / 2 - 0.02;
// 조명
scene.add(new THREE.AmbientLight(0xffffff, 0.55));
const key = new THREE.DirectionalLight(0xffffff, 0.7);
key.position.set(200, 400, 200);
scene.add(key);
const fill = new THREE.DirectionalLight(0xffffff, 0.35);
fill.position.set(-200, 200, -150);
scene.add(fill);
// 빌드 플레이트
const plate = new THREE.Mesh(
new THREE.BoxGeometry(BED, 2, BED),
new THREE.MeshStandardMaterial({ color: 0x2a2a2a, roughness: 0.9, metalness: 0.1 })
);
plate.position.set(BED / 2, -1, BED / 2);
scene.add(plate);
// 그리드(10mm 분할)
const grid = new THREE.GridHelper(BED, BED / 10, 0x555555, 0x333333);
grid.position.set(BED / 2, 0.01, BED / 2);
scene.add(grid);
// 플레이트 테두리(축 표시)
const edges = new THREE.LineSegments(
new THREE.EdgesGeometry(new THREE.BoxGeometry(BED, 0.1, BED)),
new THREE.LineBasicMaterial({ color: 0xf97316 })
);
edges.position.set(BED / 2, 0.05, BED / 2);
scene.add(edges);
raycaster = new THREE.Raycaster();
resize();
animate();
}
function resize() {
if (!renderer || !vpEl) return;
const w = vpEl.clientWidth, h = vpEl.clientHeight;
if (w < 2 || h < 2) return;
renderer.setSize(w, h, false);
camera.aspect = w / h;
camera.updateProjectionMatrix();
}
let rafId = null;
function animate() {
rafId = requestAnimationFrame(animate);
controls.update();
renderer.render(scene, camera);
}
// ── STL 로드 ──────────────────────────────────────────────────────────
function loadStlArrayBuffer(buf, name) {
const loader = new THREE.STLLoader();
let geo;
try { geo = loader.parse(buf); }
catch (e) { return { ok: false, detail: 'STL 파싱 실패: ' + e.message }; }
if (!geo || !geo.attributes.position || geo.attributes.position.count < 3) {
return { ok: false, detail: 'STL에 삼각형이 없습니다.' };
}
// STL은 Z-up, three는 Y-up → X축 -90° 회전.
geo.rotateX(-Math.PI / 2);
geo.computeBoundingBox();
const bb = geo.boundingBox;
const cx = (bb.min.x + bb.max.x) / 2;
const cz = (bb.min.z + bb.max.z) / 2;
geo.translate(-cx, -bb.min.y, -cz); // 플레이트 위(바닥=y0), XZ 중앙 정렬
geo.computeVertexNormals();
if (stlMesh) { scene.remove(stlMesh); stlMesh.geometry.dispose(); stlMesh.material.dispose(); }
const mat = new THREE.MeshStandardMaterial({ color: 0x4a90d9, roughness: 0.6, metalness: 0.05, flatShading: false, transparent: true, opacity: 0.55 });
stlMesh = new THREE.Mesh(geo, mat);
// 플레이트 중앙(BED/2)에 배치.
stlMesh.position.set(BED / 2, 0, BED / 2);
scene.add(stlMesh);
// 툴패스가 있으면 STL은 반투명(겹침 강조). 없으면 불투명.
stlMesh.material.opacity = toolpathGroup ? 0.18 : 0.9;
fitCameraToPlate();
const size = { x: bb.max.x - bb.min.x, y: bb.max.y - bb.min.y, z: bb.max.z - bb.min.z };
return { ok: true, size, name };
}
function fitCameraToPlate() {
const tx = BED / 2, ty = 40, tz = BED / 2;
controls.target.set(tx, ty, tz);
const d = Math.max(BED, 120) * 1.25;
camera.position.set(tx + d, d * 0.85, tz + d);
camera.updateProjectionMatrix();
}
// ── gcode 툴패스 파싱 + 렌더 ──────────────────────────────────────────
// G0/G1 이동을 레이어별 세그먼트로 변환. ;TYPE:별 색. 절대/상대 E(M82/M83) 모두 대응.
// 레이어 경계는 ;LAYER_CHANGE 마커(PrusaSlicer가 항상 내보냄)로 잡는다 — Z 변화 감지를
// 쓰면 start_gcode 프라이밍 시퀀스의 Z3→Z0.2→Z1 이동이 가짜 층 3개를 만들어버림.
function parseGcodeToolpath(text) {
const lines = text.split('\n');
const layers = []; // [{z, segs:[{x1,y1,z1,x2,y2,z2,color}]}]
let x = 0, y = 0, z = 0, e = 0, curType = '', relativeE = false;
let curLayer = -1, curZ = null;
const EPS = 1e-4;
const hasLayerMarkers = /^;LAYER_CHANGE/m.test(text);
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
// 주석줄(; 시작) — ;LAYER_CHANGE / ;TYPE: 추출.
if (line.charCodeAt(0) === 59) { // ';'
if (/^;LAYER_CHANGE/.test(line)) {
curLayer++;
layers.push({ z: z, segs: [] }); // z는 마커 직전 값(아래 Z이동에서 갱신)
}
const t = line.match(/^;TYPE:(.+)/);
if (t) curType = t[1].trim();
continue;
}
// G0/G1만 세그먼트화. M82/M83은 E모드 추적. 그 외 G/M은 무시.
if (line[0] !== 'G') {
if (line[0] === 'M') {
if (/^M82\b/.test(line)) relativeE = false;
else if (/^M83\b/.test(line)) relativeE = true;
}
continue;
}
const m = line.match(/^G([01])\b(.*)/);
if (!m) continue; // G90/G91/G92 등 — 단순화 위해 무시(PrusaSlicer는 절대 좌표·G92 E0)
const gcode = m[1];
const rest = m[2];
const px = x, py = y, pz = z;
let nx = x, ny = y, nz = z, ne = e;
const mx = rest.match(/X(-?[\d.]+)/);
const my = rest.match(/Y(-?[\d.]+)/);
const mz = rest.match(/Z(-?[\d.]+)/);
const me = rest.match(/E(-?[\d.]+)/);
if (mx) nx = parseFloat(mx[1]);
if (my) ny = parseFloat(my[1]);
if (mz) nz = parseFloat(mz[1]);
if (me) ne = parseFloat(me[1]);
// 폴백(;LAYER_CHANGE 없는 gcode용): Z 변화로 레이어 감지.
if (!hasLayerMarkers && mz && Math.abs(nz - curZ) > EPS) {
curZ = nz;
curLayer++;
layers.push({ z: nz, segs: [] });
}
// 첫 세그먼트 전(프라이밍 등) 레이어 0 지연 생성.
if (curLayer < 0) { curLayer = 0; layers.push({ z: nz, segs: [] }); curZ = nz; }
// 마커 모드에서 현재 층 Z를 실제 Z이동값으로 갱신(마커 시점엔 옛 Z).
if (hasLayerMarkers && mz) layers[curLayer].z = nz;
// 압출 여부: G0=이동(비압출). G1에서 E가 증가(절대) 또는 E>0(상대)면 압출.
let extruding = false;
if (gcode === '1' && me) {
extruding = relativeE ? (ne > EPS) : (ne > e + EPS);
}
if (px !== nx || py !== ny || pz !== nz) {
const color = extruding ? (TYPE_COLORS[curType] ?? DEFAULT_COLOR) : TRAVEL_COLOR;
layers[curLayer].segs.push({ x1: px, y1: pz, z1: py, x2: nx, y2: nz, z2: ny, color });
// 주의: gcode 좌표계는 X/Y 평면, Z가 높이. three는 Y가 높이. y1=pz(높이), z1=py(평면Y).
}
x = nx; y = ny; z = nz; e = ne;
}
return layers;
}
function renderToolpath(layers) {
if (toolpathGroup) { scene.remove(toolpathGroup); disposeGroup(toolpathGroup); }
toolpathGroup = new THREE.Group();
layerObjects = [];
layerZs = [];
for (let li = 0; li < layers.length; li++) {
const L = layers[li];
layerZs.push(L.z);
const segs = L.segs;
if (!segs.length) { layerObjects.push(null); continue; }
const pos = new Float32Array(segs.length * 6);
const col = new Float32Array(segs.length * 6);
for (let s = 0; s < segs.length; s++) {
const sg = segs[s];
const o = s * 6;
// gcode (X,Y,Z) → three (X, Z, Y) 매핑으로 STL과 정렬(위 parseGcode에서 이미 적용).
// 여기선 이미 y1=높이, z1=평면Y 로 넣어둠. 플레이트 중앙 오프셋 더함.
pos[o] = sg.x1 + BED / 2; pos[o + 1] = sg.y1; pos[o + 2] = sg.z1 + BED / 2;
pos[o + 3] = sg.x2 + BED / 2; pos[o + 4] = sg.y2; pos[o + 5] = sg.z2 + BED / 2;
const c = new THREE.Color(sg.color);
col[o] = c.r; col[o + 1] = c.g; col[o + 2] = c.b;
col[o + 3] = c.r; col[o + 4] = c.g; col[o + 5] = c.b;
}
const g = new THREE.BufferGeometry();
g.setAttribute('position', new THREE.BufferAttribute(pos, 3));
g.setAttribute('color', new THREE.BufferAttribute(col, 3));
const m = new THREE.LineBasicMaterial({ vertexColors: true, linewidth: 1 });
const ls = new THREE.LineSegments(g, m);
ls.visible = false;
toolpathGroup.add(ls);
layerObjects.push(ls);
}
scene.add(toolpathGroup);
// STL 반투명화(툴패스 겹침)
if (stlMesh) stlMesh.material.opacity = 0.18;
}
function disposeGroup(g) {
g.traverse((o) => {
if (o.geometry) o.geometry.dispose();
if (o.material) o.material.dispose();
});
}
// ── 레이어 슬라이더 ───────────────────────────────────────────────────
function applyLayerVisibility(n) {
// singleLayerMode: n층만. 아니면 0..n층.
for (let i = 0; i < layerObjects.length; i++) {
const ls = layerObjects[i];
if (!ls) continue;
ls.visible = singleLayerMode ? (i === n) : (i <= n);
}
}
function setLayerCount(n) {
const slider = barEl.querySelector('#sv-layer-slider');
const lbl = barEl.querySelector('#sv-layer-label');
if (!slider) return;
slider.max = Math.max(0, n - 1);
slider.value = n - 1;
lbl.textContent = n > 0 ? `층 ${n}/${n} · Z=${layerZs[n - 1].toFixed(2)}mm` : '—';
applyLayerVisibility(n - 1);
}
// ── 프로파일 패널 ─────────────────────────────────────────────────────
function fieldId(key) { return 'sv-prof-' + key; }
function fieldInputHtml(f) {
const id = fieldId(f.key);
if (f.type === 'check') {
return `<label class="sv-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="sv-field"><label>${f.label}</label>${ctrl}</div>`;
}
function renderProfilePanel() {
const root = panelEl.querySelector('#sv-prof-sections');
root.innerHTML = PROF_SCHEMA.map((s) => {
const body = s.fields.map(fieldInputHtml).join('');
return `<details class="sv-sec"${s.open ? ' open' : ''}><summary>${s.title}</summary><div class="sv-sec-body">${body}</div></details>`;
}).join('');
// 속도 프리셋 연동
const pe = panelEl.querySelector('#' + fieldId('__speed_preset'));
if (pe) pe.addEventListener('change', () => {
const v = pe.value; if (v === 'custom') return;
const p = SPEED_PRESETS[v]; if (!p) return;
for (const k of SPEED_KEYS) { const el = panelEl.querySelector('#' + fieldId(k)); if (el) el.value = p[k]; }
});
for (const k of SPEED_KEYS) {
const el = panelEl.querySelector('#' + fieldId(k));
if (el) el.addEventListener('input', () => { if (pe) pe.value = 'custom'; });
}
// 소재 프리셋 연동
const me = panelEl.querySelector('#' + fieldId('__material'));
if (me) me.addEventListener('change', () => {
const p = MATERIAL_TEMP_PRESETS[me.value]; if (!p) return;
for (const k of Object.keys(p)) { const el = panelEl.querySelector('#' + fieldId(k)); if (!el) continue; el.value = p[k]; }
});
}
function findField(key) {
for (const s of PROF_SCHEMA) for (const f of s.fields) if (f.key === key) return f;
return null;
}
function setFieldValue(key, raw) {
const f = findField(key); if (!f) return;
const el = panelEl.querySelector('#' + 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 getIniValue(f) {
const el = panelEl.querySelector('#' + 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);
}
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;
}
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]);
}
const pe = panelEl.querySelector('#' + fieldId('__speed_preset'));
if (pe) {
let matched = 'custom';
for (const [name, p] of Object.entries(SPEED_PRESETS)) {
if (SPEED_KEYS.every((k) => String(panelEl.querySelector('#' + fieldId(k))?.value) === String(p[k]))) { matched = name; break; }
}
pe.value = matched;
}
const me = panelEl.querySelector('#' + fieldId('__material'));
if (me) { const ft = panelEl.querySelector('#' + fieldId('filament_type'))?.value || 'PLA'; me.value = MATERIAL_TEMP_PRESETS[ft] ? ft : 'PLA'; }
}
async function profInit() {
renderProfilePanel();
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) { setErr('베이스 프로파일 로드 실패: ' + e.message); }
}
async function profRefreshList() {
const sel = panelEl.querySelector('#sv-prof-select');
if (!sel) return;
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) { }
}
async function profLoadSelected() {
const name = panelEl.querySelector('#sv-prof-select').value;
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) { setErr('불러오기 실패: ' + e.message); }
}
async function profSaveAs() {
const name = prompt('저장할 프로파일 이름(영문/숫자/밑줄/하이픈):');
if (!name) return;
try {
const r = await fetch('/api/k2/profiles/' + encodeURIComponent(name), { method: 'POST', headers: { 'Content-Type': 'application/json', ...authH() }, body: JSON.stringify({ overrides: collectOverrides() }) });
const d = await r.json(); if (!r.ok) throw new Error(d.error || ('HTTP ' + r.status));
await profRefreshList();
panelEl.querySelector('#sv-prof-select').value = name;
} catch (e) { setErr('저장 실패: ' + e.message); }
}
async function profDeleteSelected() {
const name = panelEl.querySelector('#sv-prof-select').value;
if (!name) return;
if (!confirm('프로파일 "' + name + '"을 삭제할까요?')) return;
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) { setErr('삭제 실패: ' + e.message); }
}
function profResetBase() {
applyValues(profBaseValues);
panelEl.querySelector('#sv-prof-select').value = '';
}
// ── 슬라이스/업로드/출력 흐름 ─────────────────────────────────────────
function fmtDuration(sec) {
if (sec == null) return '알 수 없음';
const h = Math.floor(sec / 3600), m = Math.round((sec % 3600) / 60);
return h > 0 ? h + '시간 ' + m + '분' : m + '분';
}
function setErr(msg) {
const el = barEl.querySelector('#sv-err');
el.textContent = msg; el.classList.toggle('show', !!msg);
}
function setInfo(html) {
const el = barEl.querySelector('#sv-info');
el.innerHTML = html; el.classList.toggle('show', !!html);
}
async function runSlice() {
setErr(''); setInfo('');
const fileInput = panelEl.querySelector('#sv-file');
const file = fileInput.files[0];
if (!file) { setErr('STL 파일을 선택하세요.'); return; }
const btn = barEl.querySelector('#sv-slice-btn');
btn.disabled = true; btn.textContent = '슬라이싱 중...';
try {
// 1) STL을 3D 뷰에 로드(즉시 미리보기).
const buf = await file.arrayBuffer();
const lr = loadStlArrayBuffer(buf, file.name);
if (!lr.ok) { setErr(lr.detail); return; }
// 2) 현재 편집값을 __current 프로파일로 저장.
const sv = await fetch('/api/k2/profiles/__current', { method: 'POST', headers: { 'Content-Type': 'application/json', ...authH() }, body: JSON.stringify({ overrides: collectOverrides() }) });
const sd = await sv.json(); if (!sv.ok || !sd.success) throw new Error(sd.error || ('HTTP ' + sv.status));
// 3) 슬라이스(업로드 안 함).
const qty = panelEl.querySelector('#sv-qty').value || 1;
const qs = new URLSearchParams({ profile: '__current', qty, upload: '0' });
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));
curGcodeFile = d.gcodeFilename;
// 4) gcode 받아서 툴패스 렌더.
const gr = await fetch('/api/k2/gcode/' + encodeURIComponent(curGcodeFile), { headers: authH() });
if (!gr.ok) throw new Error('gcode 불러오기 실패: HTTP ' + gr.status);
const gtext = await gr.text();
const layers = parseGcodeToolpath(gtext);
renderToolpath(layers);
setLayerCount(layers.length);
// 5) 정보 표시 + 업로드/출력 버튼 활성화.
const filamentStr = d.filamentMm != null ? `${d.filamentMm.toFixed(0)}mm` : '—';
setInfo(
`✅ 슬라이스 완료: <b>${curGcodeFile}</b><br>` +
`예상 시간 <b>${fmtDuration(d.printTimeSec)}</b> · 필라멘트 <b>${filamentStr}</b> · <b>${layers.length}</b>층 · 수량 ${d.qty} · 소재 ${d.material}<br>` +
`<button class="sv-btn green" id="sv-upload-btn" onclick="window.__slicerUpload()">📤 K2 업로드</button> ` +
`<button class="sv-btn" id="sv-print-btn" onclick="window.__slicerPrint()" disabled>🖨️ 출력 시작</button>`
);
} catch (e) {
setErr('실패: ' + e.message);
} finally {
btn.disabled = false; btn.textContent = '🔪 슬라이싱';
}
}
async function uploadGcode() {
if (!curGcodeFile) return;
setErr('');
try {
const r = await fetch('/api/k2/upload', { method: 'POST', headers: { 'Content-Type': 'application/json', ...authH() }, body: JSON.stringify({ filename: curGcodeFile }) });
const d = await r.json();
if (!r.ok || !d.success) throw new Error(d.error || ('HTTP ' + r.status));
setInfo(prevInfoWithUploaded());
barEl.querySelector('#sv-print-btn').disabled = false;
} catch (e) { setErr('업로드 실패: ' + e.message); }
}
function prevInfoWithUploaded() {
const info = barEl.querySelector('#sv-info');
let html = info.innerHTML;
if (!html.includes('K2에 업로드됨')) html = html.replace('📤 K2 업로드</button>', '✅ K2에 업로드됨</button>');
return html;
}
async function startPrint() {
if (!curGcodeFile) return;
if (!confirm('"' + curGcodeFile + '" 출력을 지금 시작할까요? (재료/시간이 소요되는 물리적 동작)')) return;
try {
const r = await fetch('/api/k2/print-start', { method: 'POST', headers: { 'Content-Type': 'application/json', ...authH() }, body: JSON.stringify({ filename: curGcodeFile }) });
const d = await r.json();
if (!r.ok || !d.success) throw new Error(d.error || ('HTTP ' + r.status));
alert('출력을 시작했습니다.\n\n⚠️ K2 터치스크린을 확인하세요 — "필라멘트 삽입" 프롬프트를 직접 눌러야 급지됩니다.');
} catch (e) { alert('출력 시작 실패: ' + e.message); }
}
// ── 오버레이 DOM 구축 ─────────────────────────────────────────────────
function buildOverlay() {
overlayEl = document.getElementById('slicer-overlay');
overlayEl.innerHTML = `
<div class="sv-shell">
<div class="vp" id="sv-vp"></div>
<aside class="sv-panel" id="sv-panel">
<div class="sv-panel-hdr">
<h2>🔧 슬라이싱 프로파일</h2>
<button class="sv-x" onclick="window.closeSlicer()">✕</button>
</div>
<div class="sv-prof-bar">
<select id="sv-prof-select"><option value="">— 기본(베이스) —</option></select>
<button class="sv-mini" onclick="window.__profLoad()">불러오기</button>
<button class="sv-mini" onclick="window.__profSave()">저장</button>
<button class="sv-mini danger" onclick="window.__profDel()">삭제</button>
<button class="sv-mini" onclick="window.__profReset()">기본값</button>
</div>
<div id="sv-prof-sections" class="sv-prof-sections"></div>
<div class="sv-file-bar">
<label>STL 파일</label>
<input type="file" id="sv-file" accept=".stl">
<label>수량</label>
<input type="number" id="sv-qty" value="1" min="1" max="20">
</div>
</aside>
<div class="sv-bar" id="sv-bar">
<div class="sv-bar-left">
<button class="sv-btn primary" id="sv-slice-btn" onclick="window.__slicerSlice()">🔪 슬라이싱</button>
<label class="sv-mode"><input type="checkbox" id="sv-single-layer" onchange="window.__slicerSingleMode(this.checked)"> 단층 보기</label>
</div>
<div class="sv-bar-center">
<input type="range" id="sv-layer-slider" min="0" max="0" value="0" oninput="window.__slicerLayer(this.value)">
<span id="sv-layer-label">—</span>
</div>
<div class="sv-bar-right">
<div id="sv-info" class="sv-info"></div>
<div id="sv-err" class="sv-err"></div>
</div>
</div>
</div>
`;
vpEl = overlayEl.querySelector('#sv-vp');
panelEl = overlayEl.querySelector('#sv-panel');
barEl = overlayEl.querySelector('#sv-bar');
}
function injectStyles() {
if (document.getElementById('sv-styles')) return;
const st = document.createElement('style');
st.id = 'sv-styles';
st.textContent = `
.sv-shell{position:absolute;inset:0;display:grid;grid-template-columns:1fr 300px;grid-template-rows:1fr auto;background:var(--bg,#1a1a1a);z-index:60;}
.sv-shell .vp{grid-column:1;grid-row:1;min-height:0;position:relative;background:#1a1a1a;}
.sv-shell .vp canvas{display:block;}
.sv-panel{grid-column:2;grid-row:1/3;background:var(--panel,#222);border-left:1px solid var(--line,#333);overflow-y:auto;padding:10px;display:flex;flex-direction:column;gap:8px;}
.sv-panel-hdr{display:flex;align-items:center;justify-content:space-between;}
.sv-panel-hdr h2{font-size:13px;margin:0;}
.sv-x{background:none;border:none;font-size:16px;color:var(--muted,#888);cursor:pointer;}
.sv-prof-bar{display:flex;gap:4px;flex-wrap:wrap;align-items:center;}
.sv-prof-bar select{flex:1;min-width:90px;background:var(--panel-2,#2a2a2a);border:1px solid var(--line,#333);border-radius:6px;padding:5px 6px;font-size:11px;color:var(--text,#eee);font-family:inherit;}
.sv-mini{background:none;border:1px solid var(--line,#333);border-radius:6px;padding:4px 8px;font-size:11px;color:var(--muted,#aaa);cursor:pointer;font-family:inherit;}
.sv-mini:hover{border-color:#f97316;color:#f97316;}
.sv-mini.danger{color:#ef4444;border-color:#ef4444;}
.sv-prof-sections{display:flex;flex-direction:column;gap:6px;}
.sv-sec{border:1px solid var(--line,#333);border-radius:8px;}
.sv-sec>summary{cursor:pointer;padding:6px 10px;font-size:12px;font-weight:700;list-style:none;display:flex;align-items:center;gap:6px;}
.sv-sec>summary::-webkit-details-marker{display:none;}
.sv-sec>summary::before{content:'▸';color:var(--muted,#888);font-size:10px;}
.sv-sec[open]>summary::before{content:'▾';}
.sv-sec-body{padding:6px 10px;display:grid;grid-template-columns:1fr 1fr;gap:6px;}
.sv-field{margin-bottom:4px;}
.sv-field label{display:block;font-size:10px;color:var(--muted,#888);margin-bottom:2px;}
.sv-field input,.sv-field select{width:100%;box-sizing:border-box;background:var(--panel-2,#2a2a2a);border:1px solid var(--line,#333);border-radius:5px;padding:4px 6px;font-size:11px;color:var(--text,#eee);font-family:inherit;outline:none;}
.sv-field input:focus,.sv-field select:focus{border-color:#f97316;}
.sv-check{display:flex;align-items:center;gap:5px;font-size:11px;color:var(--text,#eee);grid-column:span 2;}
.sv-file-bar{border-top:1px solid var(--line,#333);padding-top:8px;display:flex;flex-direction:column;gap:4px;}
.sv-file-bar label{font-size:10px;color:var(--muted,#888);}
.sv-file-bar input[type=file]{font-size:11px;color:var(--text,#eee);}
.sv-file-bar input[type=number]{width:70px;background:var(--panel-2,#2a2a2a);border:1px solid var(--line,#333);border-radius:5px;padding:4px 6px;font-size:11px;color:var(--text,#eee);font-family:inherit;}
.sv-bar{grid-column:1;grid-row:2;background:var(--panel,#222);border-top:1px solid var(--line,#333);padding:8px 12px;display:flex;align-items:center;gap:12px;flex-wrap:wrap;}
.sv-bar-left{display:flex;align-items:center;gap:8px;}
.sv-bar-center{flex:1;display:flex;align-items:center;gap:8px;min-width:200px;}
.sv-bar-center input[type=range]{flex:1;}
.sv-bar-center #sv-layer-label{font-size:11px;color:var(--muted,#aaa);white-space:nowrap;}
.sv-bar-right{flex:1;min-width:200px;}
.sv-btn{border:none;border-radius:7px;padding:7px 14px;font-size:12px;color:#fff;cursor:pointer;font-family:inherit;font-weight:700;}
.sv-btn.primary{background:#f97316;}
.sv-btn.primary:disabled{opacity:.5;cursor:not-allowed;}
.sv-btn.green{background:#16a34a;}
.sv-btn.green:disabled{opacity:.5;}
.sv-btn:disabled{opacity:.5;cursor:not-allowed;}
.sv-mode{font-size:11px;color:var(--muted,#aaa);display:flex;align-items:center;gap:4px;}
.sv-info{font-size:11px;line-height:1.6;color:var(--text,#eee);}
.sv-info b{color:#f97316;}
.sv-err{font-size:11px;color:#ef4444;display:none;margin-top:4px;}
.sv-err.show{display:block;}
@media(max-width:760px){.sv-shell{grid-template-columns:1fr;grid-template-rows:1fr auto auto;}.sv-panel{grid-column:1;grid-row:3;border-left:none;border-top:1px solid var(--line,#333);max-height:40vh;}}
`;
document.head.appendChild(st);
}
// ── 글로벌 진입점 ─────────────────────────────────────────────────────
let inited = false;
async function openSlicer() {
injectStyles();
buildOverlay();
document.getElementById('slicer-overlay').classList.add('show');
if (!inited) {
initScene();
await profInit();
inited = true;
// 창 크기 변화 대응
const ro = new ResizeObserver(() => resize());
ro.observe(vpEl);
} else {
resize();
}
}
function closeSlicer() {
document.getElementById('slicer-overlay').classList.remove('show');
}
// 슬라이더/버튼 콜백을 글로벌로 노출(인라인 onclick 호환).
window.openSlicer = openSlicer;
window.closeSlicer = closeSlicer;
window.__slicerSlice = runSlice;
window.__slicerUpload = uploadGcode;
window.__slicerPrint = startPrint;
window.__slicerLayer = (v) => {
const n = Number(v);
const lbl = barEl.querySelector('#sv-layer-label');
if (layerZs.length) lbl.textContent = `층 ${n + 1}/${layerZs.length} · Z=${(layerZs[n] ?? 0).toFixed(2)}mm`;
applyLayerVisibility(n);
};
window.__slicerSingleMode = (on) => { singleLayerMode = on; applyLayerVisibility(Number(barEl.querySelector('#sv-layer-slider').value)); };
window.__profLoad = profLoadSelected;
window.__profSave = profSaveAs;
window.__profDel = profDeleteSelected;
window.__profReset = profResetBase;
})();
File diff suppressed because it is too large Load Diff
+371
View File
@@ -0,0 +1,371 @@
( function () {
/**
* Description: A THREE loader for STL ASCII files, as created by Solidworks and other CAD programs.
*
* Supports both binary and ASCII encoded files, with automatic detection of type.
*
* The loader returns a non-indexed buffer geometry.
*
* Limitations:
* Binary decoding supports "Magics" color format (http://en.wikipedia.org/wiki/STL_(file_format)#Color_in_binary_STL).
* There is perhaps some question as to how valid it is to always assume little-endian-ness.
* ASCII decoding assumes file is UTF-8.
*
* Usage:
* const loader = new STLLoader();
* loader.load( './models/stl/slotted_disk.stl', function ( geometry ) {
* scene.add( new THREE.Mesh( geometry ) );
* });
*
* For binary STLs geometry might contain colors for vertices. To use it:
* // use the same code to load STL as above
* if (geometry.hasColors) {
* material = new THREE.MeshPhongMaterial({ opacity: geometry.alpha, vertexColors: true });
* } else { .... }
* const mesh = new THREE.Mesh( geometry, material );
*
* For ASCII STLs containing multiple solids, each solid is assigned to a different group.
* Groups can be used to assign a different color by defining an array of materials with the same length of
* geometry.groups and passing it to the Mesh constructor:
*
* const mesh = new THREE.Mesh( geometry, material );
*
* For example:
*
* const materials = [];
* const nGeometryGroups = geometry.groups.length;
*
* const colorMap = ...; // Some logic to index colors.
*
* for (let i = 0; i < nGeometryGroups; i++) {
*
* const material = new THREE.MeshPhongMaterial({
* color: colorMap[i],
* wireframe: false
* });
*
* }
*
* materials.push(material);
* const mesh = new THREE.Mesh(geometry, materials);
*/
class STLLoader extends THREE.Loader {
constructor( manager ) {
super( manager );
}
load( url, onLoad, onProgress, onError ) {
const scope = this;
const loader = new THREE.FileLoader( this.manager );
loader.setPath( this.path );
loader.setResponseType( 'arraybuffer' );
loader.setRequestHeader( this.requestHeader );
loader.setWithCredentials( this.withCredentials );
loader.load( url, function ( text ) {
try {
onLoad( scope.parse( text ) );
} catch ( e ) {
if ( onError ) {
onError( e );
} else {
console.error( e );
}
scope.manager.itemError( url );
}
}, onProgress, onError );
}
parse( data ) {
function isBinary( data ) {
const reader = new DataView( data );
const face_size = 32 / 8 * 3 + 32 / 8 * 3 * 3 + 16 / 8;
const n_faces = reader.getUint32( 80, true );
const expect = 80 + 32 / 8 + n_faces * face_size;
if ( expect === reader.byteLength ) {
return true;
} // An ASCII STL data must begin with 'solid ' as the first six bytes.
// However, ASCII STLs lacking the SPACE after the 'd' are known to be
// plentiful. So, check the first 5 bytes for 'solid'.
// Several encodings, such as UTF-8, precede the text with up to 5 bytes:
// https://en.wikipedia.org/wiki/Byte_order_mark#Byte_order_marks_by_encoding
// Search for "solid" to start anywhere after those prefixes.
// US-ASCII ordinal values for 's', 'o', 'l', 'i', 'd'
const solid = [ 115, 111, 108, 105, 100 ];
for ( let off = 0; off < 5; off ++ ) {
// If "solid" text is matched to the current offset, declare it to be an ASCII STL.
if ( matchDataViewAt( solid, reader, off ) ) return false;
} // Couldn't find "solid" text at the beginning; it is binary STL.
return true;
}
function matchDataViewAt( query, reader, offset ) {
// Check if each byte in query matches the corresponding byte from the current offset
for ( let i = 0, il = query.length; i < il; i ++ ) {
if ( query[ i ] !== reader.getUint8( offset + i, false ) ) return false;
}
return true;
}
function parseBinary( data ) {
const reader = new DataView( data );
const faces = reader.getUint32( 80, true );
let r,
g,
b,
hasColors = false,
colors;
let defaultR, defaultG, defaultB, alpha; // process STL header
// check for default color in header ("COLOR=rgba" sequence).
for ( let index = 0; index < 80 - 10; index ++ ) {
if ( reader.getUint32( index, false ) == 0x434F4C4F
/*COLO*/
&& reader.getUint8( index + 4 ) == 0x52
/*'R'*/
&& reader.getUint8( index + 5 ) == 0x3D
/*'='*/
) {
hasColors = true;
colors = new Float32Array( faces * 3 * 3 );
defaultR = reader.getUint8( index + 6 ) / 255;
defaultG = reader.getUint8( index + 7 ) / 255;
defaultB = reader.getUint8( index + 8 ) / 255;
alpha = reader.getUint8( index + 9 ) / 255;
}
}
const dataOffset = 84;
const faceLength = 12 * 4 + 2;
const geometry = new THREE.BufferGeometry();
const vertices = new Float32Array( faces * 3 * 3 );
const normals = new Float32Array( faces * 3 * 3 );
for ( let face = 0; face < faces; face ++ ) {
const start = dataOffset + face * faceLength;
const normalX = reader.getFloat32( start, true );
const normalY = reader.getFloat32( start + 4, true );
const normalZ = reader.getFloat32( start + 8, true );
if ( hasColors ) {
const packedColor = reader.getUint16( start + 48, true );
if ( ( packedColor & 0x8000 ) === 0 ) {
// facet has its own unique color
r = ( packedColor & 0x1F ) / 31;
g = ( packedColor >> 5 & 0x1F ) / 31;
b = ( packedColor >> 10 & 0x1F ) / 31;
} else {
r = defaultR;
g = defaultG;
b = defaultB;
}
}
for ( let i = 1; i <= 3; i ++ ) {
const vertexstart = start + i * 12;
const componentIdx = face * 3 * 3 + ( i - 1 ) * 3;
vertices[ componentIdx ] = reader.getFloat32( vertexstart, true );
vertices[ componentIdx + 1 ] = reader.getFloat32( vertexstart + 4, true );
vertices[ componentIdx + 2 ] = reader.getFloat32( vertexstart + 8, true );
normals[ componentIdx ] = normalX;
normals[ componentIdx + 1 ] = normalY;
normals[ componentIdx + 2 ] = normalZ;
if ( hasColors ) {
colors[ componentIdx ] = r;
colors[ componentIdx + 1 ] = g;
colors[ componentIdx + 2 ] = b;
}
}
}
geometry.setAttribute( 'position', new THREE.BufferAttribute( vertices, 3 ) );
geometry.setAttribute( 'normal', new THREE.BufferAttribute( normals, 3 ) );
if ( hasColors ) {
geometry.setAttribute( 'color', new THREE.BufferAttribute( colors, 3 ) );
geometry.hasColors = true;
geometry.alpha = alpha;
}
return geometry;
}
function parseASCII( data ) {
const geometry = new THREE.BufferGeometry();
const patternSolid = /solid([\s\S]*?)endsolid/g;
const patternFace = /facet([\s\S]*?)endfacet/g;
let faceCounter = 0;
const patternFloat = /[\s]+([+-]?(?:\d*)(?:\.\d*)?(?:[eE][+-]?\d+)?)/.source;
const patternVertex = new RegExp( 'vertex' + patternFloat + patternFloat + patternFloat, 'g' );
const patternNormal = new RegExp( 'normal' + patternFloat + patternFloat + patternFloat, 'g' );
const vertices = [];
const normals = [];
const normal = new THREE.Vector3();
let result;
let groupCount = 0;
let startVertex = 0;
let endVertex = 0;
while ( ( result = patternSolid.exec( data ) ) !== null ) {
startVertex = endVertex;
const solid = result[ 0 ];
while ( ( result = patternFace.exec( solid ) ) !== null ) {
let vertexCountPerFace = 0;
let normalCountPerFace = 0;
const text = result[ 0 ];
while ( ( result = patternNormal.exec( text ) ) !== null ) {
normal.x = parseFloat( result[ 1 ] );
normal.y = parseFloat( result[ 2 ] );
normal.z = parseFloat( result[ 3 ] );
normalCountPerFace ++;
}
while ( ( result = patternVertex.exec( text ) ) !== null ) {
vertices.push( parseFloat( result[ 1 ] ), parseFloat( result[ 2 ] ), parseFloat( result[ 3 ] ) );
normals.push( normal.x, normal.y, normal.z );
vertexCountPerFace ++;
endVertex ++;
} // every face have to own ONE valid normal
if ( normalCountPerFace !== 1 ) {
console.error( 'THREE.STLLoader: Something isn\'t right with the normal of face number ' + faceCounter );
} // each face have to own THREE valid vertices
if ( vertexCountPerFace !== 3 ) {
console.error( 'THREE.STLLoader: Something isn\'t right with the vertices of face number ' + faceCounter );
}
faceCounter ++;
}
const start = startVertex;
const count = endVertex - startVertex;
geometry.addGroup( start, count, groupCount );
groupCount ++;
}
geometry.setAttribute( 'position', new THREE.Float32BufferAttribute( vertices, 3 ) );
geometry.setAttribute( 'normal', new THREE.Float32BufferAttribute( normals, 3 ) );
return geometry;
}
function ensureString( buffer ) {
if ( typeof buffer !== 'string' ) {
return THREE.LoaderUtils.decodeText( new Uint8Array( buffer ) );
}
return buffer;
}
function ensureBinary( buffer ) {
if ( typeof buffer === 'string' ) {
const array_buffer = new Uint8Array( buffer.length );
for ( let i = 0; i < buffer.length; i ++ ) {
array_buffer[ i ] = buffer.charCodeAt( i ) & 0xff; // implicitly assumes little-endian
}
return array_buffer.buffer || array_buffer;
} else {
return buffer;
}
} // start
const binData = ensureBinary( data );
return isBinary( binData ) ? parseBinary( binData ) : parseASCII( ensureString( data ) );
}
}
THREE.STLLoader = STLLoader;
} )();
File diff suppressed because one or more lines are too long