feat: k2-app 슬라이싱 풀 프로필 에디터 추가

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

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

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

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
kim
2026-09-20 20:47:31 +09:00
co-authored by Claude Code
parent 65d9325f8b
commit 5920540689
3 changed files with 627 additions and 71 deletions
+109 -21
View File
@@ -2,9 +2,11 @@ import express, { Express, Request, Response } from 'express';
import fs from 'fs';
import path from 'path';
import { getUserWorkspace } from '../../config/config';
import { isPathInsideDir } from '../../tools/image';
import {
PRUSA_SLICER_BIN, MATERIAL_PRESETS, buildBaseArgs, runPrusaSlicer, uploadToMoonraker,
parsePrusaTime, FAST_SPEED_ARGS,
PRUSA_SLICER_BIN, runPrusaSlicer, uploadToMoonraker,
parsePrusaTime,
K2_BASE_INI, parseIni, buildProfileIni, profileSliceArgs,
} from '../../tools/k2-slicer-core';
// K2 콤보(Creality) 프린터 내장 카메라는 자체 WebRTC 시그널링을 평문 HTTP로만
@@ -18,6 +20,21 @@ const K2_CAM_PORT = 8000;
const K2_MOONRAKER_HOST = '192.168.0.114';
const K2_MOONRAKER_PORT = 7125;
// 저장 프로파일 디렉토리(<workspace>/print3d/profiles/)와 이름 검증. `__current`는
// 에디터가 임시로 현재 편집값을 저장하는 세션 프로파일.
const PROFILE_NAME_RE = /^[A-Za-z0-9_-]{1,64}$/;
function profilesDir(workspacePath: string): string {
return path.join(workspacePath, 'print3d', 'profiles');
}
function profilePath(workspacePath: string, name: string): string {
const p = path.join(profilesDir(workspacePath), `${name}.ini`);
if (!isPathInsideDir(profilesDir(workspacePath), p)) throw new Error('경로가 프로파일 디렉토리 밖입니다.');
return p;
}
function validProfileName(name: string): boolean {
return PROFILE_NAME_RE.test(name) && name !== '__current';
}
export function registerK2Routes(app: Express): void {
// Moonraker 상태(print_stats/virtual_sdcard)를 대시보드 위젯용으로 요약해서 반환 —
// 브라우저가 K2 LAN IP를 직접 못 치니(HTTPS 페이지에서 mixed-content) 서버가 대신 조회.
@@ -65,11 +82,82 @@ export function registerK2Routes(app: Express): void {
}
});
// 웹 UI(k2-app.html)의 "슬라이싱" 패널 — STL을 로컬 PC에서 직접 업로드해서 슬라이싱+K2
// 업로드까지 한번에 한다(print3d_model 채팅도구와 로직 공유, k2-slicer-core.ts). 이
// 리포는 별도 multipart 파서 라이브러리가 없어서(routes-doctor-cases.ts처럼 직접
// 파싱하는 대신), 설정값은 쿼리스트링으로 받고 바디는 STL 원본 바이트 그대로 받는다
// (WOPI 콜백과 같은 express.raw({type:()=>true}) 패턴).
// ── 슬라이싱 프로파일 라우트(풀 에디터용) ──────────────────────────────────
// 베이스 프로파일값(에디터 초기 채움용). start_gcode/end_gcode는 canonical 강제값이라
// 클라이언트가 받아도 편집에 노출하지 않는다.
app.get('/api/k2/profile/base', (req: Request, res: Response) => {
if (!(req as any).user) return res.status(401).json({ error: 'Unauthorized' });
res.json({ values: parseIni(K2_BASE_INI) });
});
// 저장 프로파일 목록.
app.get('/api/k2/profiles', (req: Request, res: Response) => {
const user = (req as any).user;
if (!user) return res.status(401).json({ error: 'Unauthorized' });
try {
const dir = profilesDir(getUserWorkspace(user.username));
const names: string[] = [];
if (fs.existsSync(dir)) {
for (const f of fs.readdirSync(dir)) {
if (f.endsWith('.ini')) names.push(f.slice(0, -4));
}
}
res.json({ profiles: names.filter((n) => n !== '__current').sort() });
} catch (e: any) {
res.status(500).json({ error: String(e?.message || e) });
}
});
// 저장 프로파일 불러오기(머지된 ini 파싱값).
app.get('/api/k2/profiles/:name', (req: Request, res: Response) => {
const user = (req as any).user;
if (!user) return res.status(401).json({ error: 'Unauthorized' });
const name = String(req.params.name || '');
if (!validProfileName(name) && name !== '__current') return res.status(400).json({ error: '잘못된 프로파일 이름.' });
try {
const p = profilePath(getUserWorkspace(user.username), name);
if (!fs.existsSync(p)) return res.status(404).json({ error: '프로파일이 없습니다.' });
res.json({ values: parseIni(fs.readFileSync(p, 'utf8')) });
} catch (e: any) {
res.status(500).json({ error: String(e?.message || e) });
}
});
// 프로파일 저장(오버라이드 맵 → 머지 ini 파일). __current도 허용.
app.post('/api/k2/profiles/:name', express.json({ limit: '256kb' }), (req: Request, res: Response) => {
const user = (req as any).user;
if (!user) return res.status(401).json({ error: 'Unauthorized' });
const name = String(req.params.name || '');
if (!validProfileName(name) && name !== '__current') return res.status(400).json({ error: '잘못된 프로파일 이름(영문/숫자/밑줄/하이픈, 최대 64자).' });
const overrides = (req.body && typeof req.body === 'object' && req.body.overrides && typeof req.body.overrides === 'object') ? req.body.overrides : null;
if (!overrides) return res.status(400).json({ error: 'overrides 객체가 필요합니다.' });
try {
const dir = profilesDir(getUserWorkspace(user.username));
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(profilePath(getUserWorkspace(user.username), name), buildProfileIni(overrides), 'utf8');
res.json({ success: true, name });
} catch (e: any) {
res.status(500).json({ error: String(e?.message || e) });
}
});
app.delete('/api/k2/profiles/:name', (req: Request, res: Response) => {
const user = (req as any).user;
if (!user) return res.status(401).json({ error: 'Unauthorized' });
const name = String(req.params.name || '');
if (!validProfileName(name)) return res.status(400).json({ error: '잘못된 프로파일 이름.' });
try {
const p = profilePath(getUserWorkspace(user.username), name);
if (fs.existsSync(p)) fs.unlinkSync(p);
res.json({ success: true });
} catch (e: any) {
res.status(500).json({ error: String(e?.message || e) });
}
});
// 웹 UI(k2-app.html) 풀 프로파일 에디터의 슬라이싱 — 저장된 프로파일(--load <ini>)로
// 슬라이싱+K2 업로드. 바디는 STL 원본 바이트, 설정은 ?profile=<name>&qty=<N> 쿼리로.
// (print3d_model 채팅도구는 buildBaseArgs 하드코딩 경로를 그대로 쓴다 — 동작 변경 회피.)
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' });
@@ -82,20 +170,24 @@ export function registerK2Routes(app: Express): void {
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';
const profileName = String(req.query.profile || '__current');
if (!validProfileName(profileName) && profileName !== '__current') {
return res.status(400).json({ error: '잘못된 프로파일 이름.' });
}
// 소재는 프로파일 ini의 filament_type/temperature 값에서 읽는다(에디터에서 설정).
// 응답 표시용 라벨만 여기서 뽑는다.
// 여러 파츠를 한 판에 합치는 --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 profPath = profilePath(workspacePath, profileName);
if (!fs.existsSync(profPath)) {
return res.status(400).json({ error: `프로파일 '${profileName}'이 없습니다. 먼저 저장하거나 기본값으로 슬라이싱하세요.` });
}
const profValues = parseIni(fs.readFileSync(profPath, 'utf8'));
const materialKey = String(profValues.filament_type || 'PLA').toUpperCase();
const outDir = path.join(workspacePath, 'print3d');
fs.mkdirSync(outDir, { recursive: true });
const stamp = Date.now();
@@ -106,12 +198,8 @@ export function registerK2Routes(app: Express): void {
const gcodePath = path.join(outDir, gcodeFilename);
const psArgs = [
...buildBaseArgs({ material, layerHeight, infillPercent, wallCount }),
...(support ? ['--support-material'] : []),
...(speed === 'fast' ? FAST_SPEED_ARGS : []),
...profileSliceArgs(profPath, gcodePath, stlPath),
...(qty > 1 ? ['--duplicate', String(qty), '--duplicate-distance', '20'] : []),
'-o', gcodePath,
stlPath,
];
const slice = await runPrusaSlicer(psArgs, 5 * 60_000);
+200
View File
@@ -132,3 +132,203 @@ export const FAST_SPEED_ARGS = [
'--infill-speed', '200', '--solid-infill-speed', '150', '--top-solid-infill-speed', '100',
'--travel-speed', '300', '--bridge-speed', '30',
];
// ── 프로파일 기반 슬라이싱(--load <ini>) ───────────────────────────────────────
// 2026-09-20 추가. 위 buildBaseArgs 하드코딩 방식은 노출 파라미터가 한정되는 구조적
// 한계가 있어, 웹 UI의 풀 프로필 에디터(k2-app.html)는 PrusaSlicer의 --load <ini>
// 모드로 전환. 베이스 ini를 TS 문자열로 임베드(빌드 에셋 복사 이슈 회피).
//
// 주의: k2_config.ini(워크스페이스) 원본의 start_gcode는 M140/M104/START_PRINT 3줄이지만,
// buildStartGcode()에 있는 K2 프라이밍 시퀀스(G1 Z3, Y150, E15 라인 등 13줄)가 실제
// 출력에 필수다. 그래서 K2_START_GCODE_PLACEHOLDER에 프라이밍 전체를 placeholder형
//([first_layer_temperature] 등)으로 박아두고, buildProfileIni가 매번 start_gcode/end_gcode를
// 이 canonical 값으로 강제 덮어쓴다 — 에디터에서 start_gcode 편집 불가(너무 고급).
// K2_START_GCODE_PLACEHOLDER의 후행 M104 S는 실제 출력 온도([temperature])를 따름
// (first_layer 온도는 START_PRINT 매크로에 넘김). 원래 buildStartGcode는 first_layer==
// temperature로 같은 값을 썼으므로 동등.
const K2_START_GCODE_PLACEHOLDER = [
'M140 S0',
'M104 S0',
'START_PRINT EXTRUDER_TEMP=[first_layer_temperature] BED_TEMP=[first_layer_bed_temperature]',
'T0',
'M104 S[temperature]',
'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');
// 큐레이션된 베이스 프로파일 — 약 60개 핵심 키. PrusaSlicer 기본값에 맡겨도 되는
// machine_max_* 등은 제외. 기본값은 "보통" 속도·서포트 끔(buildBaseArgs 경로와 동작 일치)
// 으로 잡고, "속성" 프리셋은 에디터 오버라이드로 적용.
export const K2_BASE_INI = `# K2 콤보 베이스 슬라이싱 프로파일 (homeclaw 큐레이션, PrusaSlicer 2.9.4)
bed_shape = 0x0,260x0,260x260,0x260
max_print_height = 260
nozzle_diameter = 0.4
filament_diameter = 1.75
filament_type = PLA
gcode_flavor = klipper
extrusion_multiplier = 1
# 레이어/벽/인필
layer_height = 0.2
first_layer_height = 0.2
perimeters = 2
fill_density = 15%
fill_pattern = grid
top_fill_pattern = monotonic
bottom_fill_pattern = monotonic
top_solid_layers = 3
bottom_solid_layers = 3
fill_angle = 45
infill_overlap = 25%
# 온도(PLA 기본)
temperature = 220
first_layer_temperature = 220
bed_temperature = 50
first_layer_bed_temperature = 50
# 속도(보통)
perimeter_speed = 60
external_perimeter_speed = 50
small_perimeter_speed = 25
infill_speed = 80
solid_infill_speed = 80
top_solid_infill_speed = 40
travel_speed = 130
bridge_speed = 30
first_layer_speed = 30
gap_fill_speed = 20
max_print_speed = 80
# 서포트(끔)
support_material = 0
support_material_pattern = rectilinear
support_material_style = grid
support_material_threshold = 0
support_material_buildplate_only = 0
support_material_spacing = 2.5
support_material_interface_layers = 3
support_material_speed = 60
support_material_xy_spacing = 50%
support_material_with_sheath = 1
support_material_closing_radius = 2
overhangs = 1
# 브림/래프트/스커트
brim_width = 0
brim_type = outer_only
brim_separation = 0
raft_layers = 0
raft_expansion = 1.5
skirts = 1
skirt_distance = 6
skirt_height = 1
min_skirt_length = 0
# 팬/냉각
cooling = 1
fan_below_layer_time = 60
min_fan_speed = 35
max_fan_speed = 100
disable_fan_first_layers = 3
full_fan_speed_layer = 0
fan_always_on = 0
bridge_fan_speed = 100
slowdown_below_layer_time = 5
min_print_speed = 10
# 표면 품질
ironing = 0
ironing_type = top
ironing_flowrate = 15%
ironing_spacing = 0.1
ironing_speed = 15
seam_position = aligned
fuzzy_skin = none
fuzzy_skin_thickness = 0.3
fuzzy_skin_point_dist = 0.8
elefant_foot_compensation = 0
xy_size_compensation = 0
thick_bridges = 1
ensure_vertical_shell_thickness = enabled
thin_walls = 1
gap_fill_enabled = 1
# 리트랙션
retract_length = 0.8
retract_speed = 40
retract_lift = 0
retract_before_travel = 2
retract_layer_change = 0
deretract_speed = 0
# 고급
perimeter_generator = arachne
variable_layer_height = 1
spiral_vase = 0
single_extruder_multi_material = 0
wipe = 0
complete_objects = 0
extra_perimeters = 1
avoid_crossing_perimeters = 0
# G-code(start/end은 buildProfileIni가 canonical값으로 강제 덮어쓰므로 여기 값은 무시됨)
start_gcode = ${K2_START_GCODE_PLACEHOLDER}
end_gcode = END_PRINT
before_layer_gcode =
layer_gcode =
color_change_gcode = M600
pause_print_gcode = M601
template_custom_gcode =
output_filename_format = [input_filename_base].gcode
binary_gcode = 0
gcode_comments = 0
`;
// ini 텍스트를 key→value 맵으로 파싱(# 주석·빈 줄 무시).
export function parseIni(text: string): Record<string, string> {
const out: Record<string, string> = {};
for (const line of text.split('\n')) {
const t = line.trim();
if (!t || t.startsWith('#') || t.startsWith(';')) continue;
const eq = t.indexOf('=');
if (eq < 0) continue;
const k = t.slice(0, eq).trim();
const v = t.slice(eq + 1).trim();
if (k) out[k] = v;
}
return out;
}
// 베이스 ini에 오버라이드를 머지한 ini 텍스트 생성. start_gcode/end_gcode는 항상
// canonical값으로 강제(에디터가 건드릴 수 없는 키). 오버라이드 값은 이미 ini 포맷
// 문자열(퍼센트·1/0 등 호출부가 포맷팅)이어야 한다.
export function buildProfileIni(overrides: Record<string, string>): string {
const base = parseIni(K2_BASE_INI);
for (const [k, v] of Object.entries(overrides)) {
if (!k) continue;
// start_gcode/end_gcode 편집은 에디터에 노출되지 않지만, 만약 들어오면 무시.
if (k === 'start_gcode' || k === 'end_gcode') continue;
base[k] = v;
}
// canonical G-code 강제 주입.
base.start_gcode = K2_START_GCODE_PLACEHOLDER;
base.end_gcode = END_GCODE;
// key를 알파벳순으로 정렬해 deterministic한 출력(디버그·diff 용이).
return Object.keys(base).sort().map((k) => `${k} = ${base[k]}`).join('\n') + '\n';
}
// --load <profile.ini> 방식 슬라이싱의 CLI 인자. 중복(--duplicate)은 호출부가 덧붙임.
export function profileSliceArgs(profileIniPath: string, gcodePath: string, stlPath: string): string[] {
return ['--load', profileIniPath, '--export-gcode', '-o', gcodePath, stlPath];
}
+318 -50
View File
@@ -30,20 +30,22 @@ body{background:var(--bg);color:var(--text);font-family:system-ui,sans-serif;dis
.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{width:100%;max-width:720px;max-height:92vh;overflow-y:auto;background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:16px;}
.slice-modal h2{font-size:14px;margin:0 0 12px;}
.slice-field{margin-bottom:10px;}
.slice-field label{display:block;font-size:11px;color:var(--muted);margin-bottom:3px;}
.slice-field input[type=file],.slice-field input[type=number],.slice-field 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[type=file],.slice-field input[type=number],.slice-field input[type=text],.slice-field select{width:100%;box-sizing:border-box;background:var(--panel-2);border:1px solid var(--line);border-radius:6px;padding:6px 8px;font-size:12px;color:var(--text);font-family:inherit;outline:none;}
.slice-field input:focus,.slice-field select:focus{border-color:var(--brand);}
.slice-row{display:flex;gap:8px;}
.slice-row .slice-field{flex:1;}
.slice-check{display:flex;align-items:center;gap:6px;font-size:12px;color:var(--text);margin-bottom: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-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;}
@@ -51,6 +53,19 @@ body{background:var(--bg);color:var(--text);font-family:system-ui,sans-serif;dis
.slice-err.show{display:block;}
.start-btn{width:100%;margin-top:8px;background:#16a34a;border:none;border-radius:8px;padding:9px;font-size:13px;color:#fff;cursor:pointer;font-family:inherit;font-weight:700;}
.start-btn:disabled{opacity:.5;cursor:not-allowed;}
.prof-bar{display:flex;align-items:center;gap:6px;flex-wrap:wrap;margin-bottom:12px;padding:10px;background:var(--panel-2);border-radius:8px;}
.prof-bar select{background:var(--panel);border:1px solid var(--line);border-radius:6px;padding:5px 8px;font-size:12px;color:var(--text);font-family:inherit;outline:none;flex:1;min-width:120px;}
.prof-bar select:focus{border-color:var(--brand);}
.sec{border:1px solid var(--line);border-radius:8px;margin-bottom:8px;}
.sec>summary{cursor:pointer;padding:8px 12px;font-size:12px;font-weight:700;list-style:none;display:flex;align-items:center;gap:6px;}
.sec>summary::-webkit-details-marker{display:none;}
.sec>summary::before{content:'▸';color:var(--muted);transition:.15s;font-size:10px;}
.sec[open]>summary::before{content:'▾';}
.sec>summary:hover{color:var(--brand);}
.sec-body{padding:8px 12px 4px;display:grid;grid-template-columns:1fr 1fr 1fr;gap:8px;}
@media(max-width:600px){.sec-body{grid-template-columns:1fr 1fr;}.slice-modal{max-width:100%;}}
.sec-body .slice-field{margin-bottom:8px;}
.sec-tag{font-size:10px;color:var(--muted);background:var(--panel);padding:1px 6px;border-radius:4px;border:1px solid var(--line);margin-left:auto;font-weight:400;}
/* ── CAD(STL 확인) 모달 ── */
.cad-modal{max-width:520px;}
@@ -98,45 +113,31 @@ body{background:var(--bg);color:var(--text);font-family:system-ui,sans-serif;dis
<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">
<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 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 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 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>
@@ -247,11 +248,107 @@ 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'); }
@@ -261,6 +358,178 @@ function fmtDurationSec(sec){
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];
@@ -274,15 +543,14 @@ async function runSlice(){
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});
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',
@@ -294,7 +562,7 @@ async function runSlice(){
const timeStr=d.printTimeSec!=null?fmtDurationSec(d.printTimeSec):'알 수 없음';
resultEl.innerHTML=
'✅ K2에 업로드 완료: <b>'+d.gcodeFilename+'</b><br>'+
'예상 출력시간: <b>'+timeStr+'</b> (수량 '+d.qty+'개)<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');