feat: k2-app 웹 UI에 STL 슬라이싱 기능 추가

로컬 PC의 STL 파일을 채팅 없이 K2 앱에서 바로 업로드→슬라이싱→K2 업로드까지
할 수 있는 모달 추가. print3d_model 채팅도구와 로직을 공유하도록
k2-slicer-core.ts로 공통 부분(프린터 프로파일, PrusaSlicer 실행, Moonraker
업로드, 시간 파싱)을 분리했다.

- k2-slicer-core.ts: print3d.ts에서 추출한 공용 슬라이싱 로직 + 오늘 실측한
  FAST_SPEED_ARGS(퍼리미터/인필/트래블 속도업, 브릿지는 유지)
- routes-k2.ts: POST /api/k2/slice(STL 원본 바이트 바디+쿼리스트링 설정,
  --duplicate로 동일 파츠 복제 지원 — 서로 다른 파츠 합치기(--merge)는 이
  헤드리스 환경에서 세그폴트 나서 미지원), POST /api/k2/print-start(Moonraker
  프록시, mixed-content 회피)
- k2-app.html: 헤더에 "🔪 슬라이싱" 버튼 → 모달(파일업로드/수량/소재/레이어
  높이/인필/속도/서포트) → 결과+출력시작 버튼. 실제 파일 업로드로
  슬라이싱→K2업로드까지 브라우저에서 확인함(출력 시작은 실제 프린트 중이라
  테스트 안 함)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
kim
2026-09-20 17:13:13 +09:00
co-authored by Claude Sonnet 5
parent 9dfef8b568
commit 164ce6dcc1
4 changed files with 376 additions and 126 deletions
+96
View File
@@ -1,4 +1,11 @@
import express, { Express, Request, Response } from 'express';
import fs from 'fs';
import path from 'path';
import { getUserWorkspace } from '../../config/config';
import {
PRUSA_SLICER_BIN, MATERIAL_PRESETS, buildBaseArgs, runPrusaSlicer, uploadToMoonraker,
parsePrusaTime, FAST_SPEED_ARGS,
} from '../../tools/k2-slicer-core';
// K2 콤보(Creality) 프린터 내장 카메라는 자체 WebRTC 시그널링을 평문 HTTP로만
// 서빙한다(:8000/call/webrtc_local). 홈클로 자체는 https://ai.applecherry.net로
@@ -57,4 +64,93 @@ export function registerK2Routes(app: Express): void {
res.status(502).json({ error: String(e?.message || e) });
}
});
// 웹 UI(k2-app.html)의 "슬라이싱" 패널 — STL을 로컬 PC에서 직접 업로드해서 슬라이싱+K2
// 업로드까지 한번에 한다(print3d_model 채팅도구와 로직 공유, k2-slicer-core.ts). 이
// 리포는 별도 multipart 파서 라이브러리가 없어서(routes-doctor-cases.ts처럼 직접
// 파싱하는 대신), 설정값은 쿼리스트링으로 받고 바디는 STL 원본 바이트 그대로 받는다
// (WOPI 콜백과 같은 express.raw({type:()=>true}) 패턴).
app.post('/api/k2/slice', express.raw({ type: () => true, limit: '100mb' }), async (req: Request, res: Response) => {
const user = (req as any).user;
if (!user) return res.status(401).json({ error: 'Unauthorized' });
const buf: Buffer = req.body;
if (!Buffer.isBuffer(buf) || buf.length < 100) {
return res.status(400).json({ error: 'STL 파일이 비어있거나 너무 작습니다.' });
}
if (!fs.existsSync(PRUSA_SLICER_BIN)) {
return res.status(500).json({ error: `PrusaSlicer가 설치돼있지 않습니다(${PRUSA_SLICER_BIN}).` });
}
const materialKey = String(req.query.material || 'PLA').toUpperCase();
const material = MATERIAL_PRESETS[materialKey];
if (!material) return res.status(400).json({ error: `지원하지 않는 소재: ${materialKey}. PLA 또는 PETG.` });
const layerHeight = Number.isFinite(Number(req.query.layer_height)) ? Number(req.query.layer_height) : 0.2;
const infillPercent = Number.isFinite(Number(req.query.infill_percent)) ? Number(req.query.infill_percent) : 15;
const wallCount = 2;
const speed = req.query.speed === 'fast' ? 'fast' : 'normal';
const support = req.query.support === '1' || req.query.support === 'true';
// 여러 파츠를 한 판에 합치는 --merge는 이 헤드리스 환경에서 세그폴트가 나서(2026-09-20
// 확인) 지원 안 함 — 같은 STL 반복 출력만 --duplicate로 지원(크래시 없음, 검증됨).
const qty = Math.max(1, Math.min(20, Number.isFinite(Number(req.query.qty)) ? Math.round(Number(req.query.qty)) : 1));
const workspacePath = getUserWorkspace(user.username);
const outDir = path.join(workspacePath, 'print3d');
fs.mkdirSync(outDir, { recursive: true });
const stamp = Date.now();
const stlPath = path.join(outDir, `k2ui_${stamp}.stl`);
fs.writeFileSync(stlPath, buf);
const gcodeFilename = `k2ui_${stamp}.gcode`;
const gcodePath = path.join(outDir, gcodeFilename);
const psArgs = [
...buildBaseArgs({ material, layerHeight, infillPercent, wallCount }),
...(support ? ['--support-material'] : []),
...(speed === 'fast' ? FAST_SPEED_ARGS : []),
...(qty > 1 ? ['--duplicate', String(qty), '--duplicate-distance', '20'] : []),
'-o', gcodePath,
stlPath,
];
const slice = await runPrusaSlicer(psArgs, 5 * 60_000);
if (!slice.ok) return res.status(500).json({ error: `슬라이싱 실패: ${slice.detail}` });
if (!fs.existsSync(gcodePath) || fs.statSync(gcodePath).size < 100) {
return res.status(500).json({ error: '슬라이싱은 끝났는데 G-code 파일이 비정상입니다.' });
}
const printTimeSec = parsePrusaTime(fs.readFileSync(gcodePath, 'utf8'));
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,
material: materialKey,
qty,
});
});
// 웹 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;
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 r = await fetch(`http://${K2_MOONRAKER_HOST}:${K2_MOONRAKER_PORT}/printer/print/start`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ filename }),
signal: AbortSignal.timeout(15_000),
});
if (!r.ok) return res.status(502).json({ error: `출력 시작 실패: HTTP ${r.status} ${await r.text().catch(() => '')}` });
res.json({ success: true });
} catch (e: any) {
res.status(502).json({ error: `출력 시작 실패: ${String(e?.message || e)}` });
}
});
}
+134
View File
@@ -0,0 +1,134 @@
import { spawn } from 'child_process';
import fs from 'fs';
// K2 콤보 슬라이싱 공통 로직 — print3d_model 채팅도구(print3d.ts)와 k2-app.html의
// 웹 UI 슬라이싱 기능(routes-k2.ts)이 공유한다. 프린터 프로파일 값의 출처/PrusaSlicer
// 선택 이유는 print3d.ts 헤더 주석 참고.
export const K2_HOST = '192.168.0.114';
export const K2_MOONRAKER_PORT = 7125;
export const PRUSA_SLICER_BIN = '/usr/bin/prusa-slicer';
export interface MaterialPreset { nozzleTemp: number; bedTemp: number; verified: boolean; }
// PLA는 K2 실제 슬라이싱 샘플에서 그대로 확인된 값(검증됨). PETG는 검증 안 된 일반적인 추정치.
export const MATERIAL_PRESETS: Record<string, MaterialPreset> = {
PLA: { nozzleTemp: 220, bedTemp: 50, verified: true },
PETG: { nozzleTemp: 235, bedTemp: 75, verified: false },
};
export function buildStartGcode(nozzleTemp: number, bedTemp: number): string {
// K2 실제 샘플 machine_start_gcode 그대로(OrcaSlicer/PrusaSlicer식 대괄호 플레이스홀더
// 자리에 실제 값을 직접 박아넣음).
return [
'M140 S0',
'M104 S0',
`START_PRINT EXTRUDER_TEMP=${nozzleTemp} BED_TEMP=${bedTemp}`,
'T0',
`M104 S${nozzleTemp}`,
'M204 S2000',
'G1 Z3 F600',
'M83',
'G1 Y150 F12000',
'G1 X0 F12000',
'G1 Z0.2 F600',
'G1 X0 Y150 F6000',
'G1 X0 Y0 E15 F6000',
'G1 X150 Y0 E15 F6000',
'G92 E0',
'G1 Z1 F600',
].join('\n');
}
export const END_GCODE = 'END_PRINT';
export function fmtDuration(sec: number): string {
const h = Math.floor(sec / 3600);
const m = Math.round((sec % 3600) / 60);
if (h > 0) return `${h}시간 ${m}분`;
return `${m}분`;
}
// PrusaSlicer가 파일 끝에 남기는 "estimated printing time (normal mode) = 1h 5m 30s" 형식을 초로 변환.
export function parsePrusaTime(text: string): number | null {
const m = text.match(/estimated printing time \(normal mode\) = (?:(\d+)h\s*)?(?:(\d+)m\s*)?(?:(\d+)s)?/);
if (!m) return null;
const h = Number(m[1] || 0), min = Number(m[2] || 0), s = Number(m[3] || 0);
if (!h && !min && !s) return null;
return h * 3600 + min * 60 + s;
}
export function runPrusaSlicer(args: string[], timeoutMs: number): Promise<{ ok: true } | { ok: false; detail: string }> {
return new Promise((resolve) => {
// DISPLAY가 실려있으면 GUI 초기화를 시도할 수 있어 명시적으로 제거하고 완전 헤드리스로 강제.
const env = { ...process.env };
delete env.DISPLAY;
const proc = spawn(PRUSA_SLICER_BIN, args, { stdio: ['ignore', 'pipe', 'pipe'], env });
let stderr = '';
proc.stderr?.on('data', (d: Buffer) => { stderr += d.toString('utf8'); if (stderr.length > 4000) stderr = stderr.slice(-4000); });
const killTimer = setTimeout(() => { try { proc.kill('SIGKILL'); } catch { /* noop */ } }, timeoutMs);
proc.on('error', (e) => { clearTimeout(killTimer); resolve({ ok: false, detail: String(e.message || e) }); });
proc.on('close', (code) => {
clearTimeout(killTimer);
if (code === 0) resolve({ ok: true });
else resolve({ ok: false, detail: stderr.split('\n').filter(Boolean).slice(-10).join(' ') || `prusa-slicer exit ${code}` });
});
});
}
export async function uploadToMoonraker(filePath: string, filename: string): Promise<{ ok: true } | { ok: false; detail: string }> {
try {
const buf = fs.readFileSync(filePath);
const form = new FormData();
form.append('root', 'gcodes');
form.append('file', new Blob([buf]), filename);
const res = await fetch(`http://${K2_HOST}:${K2_MOONRAKER_PORT}/server/files/upload`, {
method: 'POST',
body: form,
signal: AbortSignal.timeout(30_000),
});
if (!res.ok) return { ok: false, detail: `Moonraker 업로드 실패: HTTP ${res.status} ${await res.text().catch(() => '')}` };
return { ok: true };
} catch (e: any) {
return { ok: false, detail: `Moonraker 업로드 실패: ${String(e?.message || e)} (K2가 꺼져있거나 네트워크에 없을 수 있음)` };
}
}
// 슬라이서 CLI 공통 인자 — 베드/노즐/리트랙션 등 K2 고정값과 소재/레이어/인필/시작·종료
// 지코드까지, prepare/웹 슬라이싱 둘 다 필요한 부분. duplicate(복제 수)와 속도 프리셋,
// 서포트 여부는 호출부에서 덧붙인다.
export function buildBaseArgs(opts: {
material: MaterialPreset;
layerHeight: number;
infillPercent: number;
wallCount: number;
}): string[] {
const { material, layerHeight, infillPercent, wallCount } = opts;
const startGcode = buildStartGcode(material.nozzleTemp, material.bedTemp);
return [
'--export-gcode',
'--gcode-flavor', 'klipper',
// K2 콤보 실측값(3DBench_PLA_22m.gcode 헤더 주석에서 확인) — 빌드볼륨/속도/리트랙션.
'--bed-shape', '0x0,260x0,260x260,0x260',
'--max-print-height', '260',
'--nozzle-diameter', '0.4',
'--filament-diameter', '1.75',
'--retract-length', '0.8', '--retract-speed', '40',
'--layer-height', String(layerHeight), '--first-layer-height', String(layerHeight),
'--perimeters', String(wallCount),
'--fill-density', `${infillPercent}%`, '--fill-pattern', 'grid',
'--skirts', '1',
'--temperature', String(material.nozzleTemp), '--first-layer-temperature', String(material.nozzleTemp),
'--bed-temperature', String(material.bedTemp), '--first-layer-bed-temperature', String(material.bedTemp),
'--start-gcode', startGcode,
'--end-gcode', END_GCODE,
];
}
// 2026-09-20 실측: perimeter/infill/travel 기본 속도(60/80/130mm/s)가 K2의 실제 하드웨어
// 한계(machine_max_feedrate 800mm/s)에 비해 너무 보수적이라, 같은 파츠 슬라이싱시간이
// 39분→24분으로 줄어드는 걸 확인한 값. bridge만 그대로 둠(처짐 방지, 이 값을 올리면
// 브릿지/오버행 품질이 눈에 띄게 나빠짐).
export const FAST_SPEED_ARGS = [
'--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',
];
+7 -126
View File
@@ -1,82 +1,19 @@
import { spawn } from 'child_process';
import fs from 'fs';
import path from 'path';
import { ToolResult } from '../types.js';
import { getWorkspacePath } from '../config/paths.js';
import {
K2_HOST, K2_MOONRAKER_PORT, PRUSA_SLICER_BIN, MATERIAL_PRESETS,
buildBaseArgs, runPrusaSlicer, uploadToMoonraker, parsePrusaTime, fmtDuration,
} from './k2-slicer-core.js';
// K2 콤보(Creality, Klipper+Moonraker)로 STL을 찾아서 바로 슬라이싱→업로드까지 자동화.
// 2026-09-20: STL URL을 받아 슬라이싱하고 K2의 Moonraker(192.168.0.114:7125)에 업로드한다.
// 실제 출력 시작은 반드시 confirm:true 재호출을 거쳐야 함(schedule_job의 확인 패턴과 동일)
// — 재료/시간 낭비 위험이 있는 물리적 동작이라 자동시작 금지.
//
// 슬라이서 = PrusaSlicer(apt prusa-slicer, 클로서버 설치, "with GUI support" 빌드지만
// DISPLAY 없이도 --export-gcode CLI는 완전히 헤드리스로 동작 확인됨).
// 처음엔 CuraEngine(apt cura-engine)을 썼으나 예상 출력시간이 항상 6666초로 나오는 버그가
// 있었음 — CuraEngine 소스 확인 결과 CLI 백엔드(CommandLine::isSequential()==true)가
// 슬라이싱 "시작 전"에 헤더를 미리 써버리고 끝난 뒤 실제값으로 되돌아가 덮어쓰는 코드가
// 없어서 항상 하드코딩된 더미값만 남는 구조적 한계였음(확률적 버그 아님). PrusaSlicer는
// 슬라이싱이 끝난 뒤 파일 끝부분에 실제 "estimated printing time"을 직접 써주므로 이 문제가
// 없다.
//
// 프린터 프로파일 값은 추측이 아니라 K2에 이미 올라가 있던 Creality Print 슬라이싱 샘플
// (server/files/gcodes/3DBench_PLA_22m.gcode)의 실제 헤더/설정 주석에서 그대로 뽑아온 것:
// nozzle_diameter=0.4, filament_diameter=1.75, nozzle_temperature=220(PLA),
// first_layer_bed_temperature=50, retraction_length=0.8/speed=40,
// machine_max_speed_x/y=800·z=5·e=50, machine_start_gcode가 Klipper 매크로
// START_PRINT/END_PRINT를 호출하는 방식(Marlin 순정 G-code가 아님 — 이 프린터는 그 매크로
// 안에서 오토레벨링 등을 처리하므로 반드시 이 매크로를 통해야 함).
const K2_HOST = '192.168.0.114';
const K2_MOONRAKER_PORT = 7125;
const PRUSA_SLICER_BIN = '/usr/bin/prusa-slicer';
interface MaterialPreset { nozzleTemp: number; bedTemp: number; verified: boolean; }
// PLA는 K2 실제 슬라이싱 샘플에서 그대로 확인된 값(검증됨). PETG는 검증 안 된 일반적인
// 추정치 — 품질 문제 생기면 조정 필요하다고 도구 설명에 명시해둠.
const MATERIAL_PRESETS: Record<string, MaterialPreset> = {
PLA: { nozzleTemp: 220, bedTemp: 50, verified: true },
PETG: { nozzleTemp: 235, bedTemp: 75, verified: false },
};
function buildStartGcode(nozzleTemp: number, bedTemp: number): string {
// K2 실제 샘플 machine_start_gcode 그대로(OrcaSlicer/PrusaSlicer식 대괄호 플레이스홀더
// 자리에 실제 값을 직접 박아넣음).
return [
'M140 S0',
'M104 S0',
`START_PRINT EXTRUDER_TEMP=${nozzleTemp} BED_TEMP=${bedTemp}`,
'T0',
`M104 S${nozzleTemp}`,
'M204 S2000',
'G1 Z3 F600',
'M83',
'G1 Y150 F12000',
'G1 X0 F12000',
'G1 Z0.2 F600',
'G1 X0 Y150 F6000',
'G1 X0 Y0 E15 F6000',
'G1 X150 Y0 E15 F6000',
'G92 E0',
'G1 Z1 F600',
].join('\n');
}
const END_GCODE = 'END_PRINT';
function fmtDuration(sec: number): string {
const h = Math.floor(sec / 3600);
const m = Math.round((sec % 3600) / 60);
if (h > 0) return `${h}시간 ${m}분`;
return `${m}분`;
}
// PrusaSlicer가 파일 끝에 남기는 "estimated printing time (normal mode) = 1h 5m 30s" 형식을 초로 변환.
function parsePrusaTime(text: string): number | null {
const m = text.match(/estimated printing time \(normal mode\) = (?:(\d+)h\s*)?(?:(\d+)m\s*)?(?:(\d+)s)?/);
if (!m) return null;
const h = Number(m[1] || 0), min = Number(m[2] || 0), s = Number(m[3] || 0);
if (!h && !min && !s) return null;
return h * 3600 + min * 60 + s;
}
// 슬라이서 선택 이유·프린터 프로파일 값 출처·PrusaSlicer CLI 세부사항은 k2-slicer-core.ts
// 참고(웹 UI 슬라이싱 라우트 routes-k2.ts와 이 파일이 그 모듈을 공유한다).
async function downloadStl(url: string, outPath: string): Promise<{ ok: true } | { ok: false; detail: string }> {
let u: URL;
@@ -101,42 +38,6 @@ async function downloadStl(url: string, outPath: string): Promise<{ ok: true } |
return { ok: true };
}
function runPrusaSlicer(args: string[], timeoutMs: number): Promise<{ ok: true } | { ok: false; detail: string }> {
return new Promise((resolve) => {
// DISPLAY가 실려있으면 GUI 초기화를 시도할 수 있어 명시적으로 제거하고 완전 헤드리스로 강제.
const env = { ...process.env };
delete env.DISPLAY;
const proc = spawn(PRUSA_SLICER_BIN, args, { stdio: ['ignore', 'pipe', 'pipe'], env });
let stderr = '';
proc.stderr?.on('data', (d: Buffer) => { stderr += d.toString('utf8'); if (stderr.length > 4000) stderr = stderr.slice(-4000); });
const killTimer = setTimeout(() => { try { proc.kill('SIGKILL'); } catch { /* noop */ } }, timeoutMs);
proc.on('error', (e) => { clearTimeout(killTimer); resolve({ ok: false, detail: String(e.message || e) }); });
proc.on('close', (code) => {
clearTimeout(killTimer);
if (code === 0) resolve({ ok: true });
else resolve({ ok: false, detail: stderr.split('\n').filter(Boolean).slice(-10).join(' ') || `prusa-slicer exit ${code}` });
});
});
}
async function uploadToMoonraker(filePath: string, filename: string): Promise<{ ok: true } | { ok: false; detail: string }> {
try {
const buf = fs.readFileSync(filePath);
const form = new FormData();
form.append('root', 'gcodes');
form.append('file', new Blob([buf]), filename);
const res = await fetch(`http://${K2_HOST}:${K2_MOONRAKER_PORT}/server/files/upload`, {
method: 'POST',
body: form,
signal: AbortSignal.timeout(30_000),
});
if (!res.ok) return { ok: false, detail: `Moonraker 업로드 실패: HTTP ${res.status} ${await res.text().catch(() => '')}` };
return { ok: true };
} catch (e: any) {
return { ok: false, detail: `Moonraker 업로드 실패: ${String(e?.message || e)} (K2가 꺼져있거나 네트워크에 없을 수 있음)` };
}
}
export const print3dModelTool = {
name: 'print3d_model',
description: 'STL 3D모델 URL을 받아 K2 콤보 프린터용으로 슬라이싱하고 프린터(Moonraker)에 업로드한다(action="prepare"), 이후 사용자가 확인하면 실제 출력을 시작한다(action="start", confirm=true 필수). STL 자체를 찾는 건 web_search로 먼저 하고, 확보한 직접 다운로드 URL을 여기 넘길 것. prepare는 예상 출력시간/필라멘트 사용량을 돌려주니 사용자에게 보여주고 "출력 시작할까요?" 확인부터 받은 뒤에만 start를 호출할 것 — 재료/시간이 드는 물리적 동작이라 사용자 승인 없이 절대 start 호출 금지.',
@@ -212,25 +113,9 @@ export const print3dModelTool = {
const gcodeFilename = `print3d_${stamp}.gcode`;
const gcodePath = path.join(outDir, gcodeFilename);
const startGcode = buildStartGcode(material.nozzleTemp, material.bedTemp);
const psArgs = [
'--export-gcode',
'--gcode-flavor', 'klipper',
// K2 콤보 실측값(3DBench_PLA_22m.gcode 헤더 주석에서 확인) — 빌드볼륨/속도/리트랙션.
'--bed-shape', '0x0,260x0,260x260,0x260',
'--max-print-height', '260',
'--nozzle-diameter', '0.4',
'--filament-diameter', '1.75',
'--retract-length', '0.8', '--retract-speed', '40',
'--layer-height', String(layerHeight), '--first-layer-height', String(layerHeight),
'--perimeters', String(wallCount),
'--fill-density', `${infillPercent}%`, '--fill-pattern', 'grid',
'--skirts', '1',
'--temperature', String(material.nozzleTemp), '--first-layer-temperature', String(material.nozzleTemp),
'--bed-temperature', String(material.bedTemp), '--first-layer-bed-temperature', String(material.bedTemp),
'--start-gcode', startGcode,
'--end-gcode', END_GCODE,
...buildBaseArgs({ material, layerHeight, infillPercent, wallCount }),
'-o', gcodePath,
stlPath,
];
@@ -241,10 +126,6 @@ export const print3dModelTool = {
return { success: false, error: '슬라이싱은 끝났는데 G-code 파일이 비정상입니다.' };
}
// PrusaSlicer는 슬라이싱이 끝난 뒤 파일 마지막 부분(전체 설정값 덤프보다 앞)에 실제
// 예상 출력시간을 직접 써준다 — CuraEngine처럼 시작 전에 더미값을 박아넣고 끝나지 않는
// 구조가 아니다. 뒤따르는 설정 덤프가 수백 줄로 커서 tail을 너무 짧게 자르면 이 줄을
// 놓치므로 파일 전체에서 찾는다.
const printTimeSec = parsePrusaTime(fs.readFileSync(gcodePath, 'utf8'));
const up = await uploadToMoonraker(gcodePath, gcodeFilename);
+139
View File
@@ -29,6 +29,28 @@ body{background:var(--bg);color:var(--text);font-family:system-ui,sans-serif;dis
.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:380px;max-height:90vh;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 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:10px;}
.slice-btn{width:100%;background:var(--brand);border:none;border-radius:8px;padding:9px;font-size:13px;color:#fff;cursor:pointer;font-family:inherit;font-weight:700;}
.slice-btn:disabled{opacity:.5;cursor:not-allowed;}
.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;}
</style>
</head>
<body>
@@ -36,6 +58,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="reconnectCam()">📷 카메라 재연결</button>
<button class="hdr-btn" onclick="reloadFrame()">↺ 새로고침</button>
<a class="hdr-btn" href="https://print.applecherry.net/" target="_blank">↗ 새 탭</a>
@@ -56,6 +79,51 @@ 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>🔪 STL 슬라이싱 → K2 업로드</h2>
<div class="slice-field">
<label>STL 파일</label>
<input type="file" id="slice-file" accept=".stl">
</div>
<div class="slice-row">
<div class="slice-field">
<label>수량(같은 파츠 복제)</label>
<input type="number" id="slice-qty" value="1" min="1" max="20">
</div>
<div class="slice-field">
<label>소재</label>
<select id="slice-material">
<option value="PLA">PLA</option>
<option value="PETG">PETG</option>
</select>
</div>
</div>
<div class="slice-row">
<div class="slice-field">
<label>레이어 높이(mm)</label>
<input type="number" id="slice-layer" value="0.2" min="0.08" max="0.4" step="0.02">
</div>
<div class="slice-field">
<label>인필(%)</label>
<input type="number" id="slice-infill" value="15" min="0" max="100" step="5">
</div>
</div>
<div class="slice-field">
<label>속도</label>
<select id="slice-speed">
<option value="normal">보통</option>
<option value="fast">속성(약 40% 단축, 브릿지는 그대로)</option>
</select>
</div>
<label class="slice-check"><input type="checkbox" id="slice-support"> 서포트 사용(오버행/브릿지 있으면 켜기)</label>
<button class="slice-btn" id="slice-run-btn" onclick="runSlice()">슬라이싱 + K2 업로드</button>
<div class="slice-err" id="slice-err"></div>
<div class="slice-result" id="slice-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;}
@@ -121,6 +189,77 @@ function connectCam(){
}
function reconnectCam(){ connectCam(); }
connectCam();
// ── 슬라이싱 모달 ──────────────────────────────────────────────────────────────
function openSliceModal(){
document.getElementById('slice-overlay').classList.add('show');
document.getElementById('slice-err').classList.remove('show');
document.getElementById('slice-result').classList.remove('show');
}
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+'분';
}
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 material=document.getElementById('slice-material').value;
const layerHeight=document.getElementById('slice-layer').value;
const infill=document.getElementById('slice-infill').value;
const speed=document.getElementById('slice-speed').value;
const support=document.getElementById('slice-support').checked?'1':'0';
const qs=new URLSearchParams({qty,material,layer_height:layerHeight,infill_percent:infill,speed,support});
try{
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+'개)<br>'+
'<button class="start-btn" onclick="startSlicedPrint(\''+d.gcodeFilename.replace(/'/g,"\\'")+'\')">🖨️ 출력 시작</button>';
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('출력을 시작했습니다.');
closeSliceModal();
}catch(e){
alert('출력 시작 실패: '+e.message);
}
}
</script>
</body>
</html>