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:
+109
-21
@@ -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);
|
||||
|
||||
@@ -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
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user