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:
@@ -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)}` });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user