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