three-slicer 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/README.md +342 -24
  2. package/components/README.md +1 -1
  3. package/components/dist/SettingsPanel.js +3 -4
  4. package/engine/index.d.ts +3 -1
  5. package/engine/src/data.js +12 -0
  6. package/engine/src/settings.js +1 -1
  7. package/engine/src/slicer.worker.js +2 -1
  8. package/engine/src/slicer_core.js +0 -0
  9. package/engine/src/slicer_core.mt.js +0 -0
  10. package/engine/src/toggle_eval.js +1 -2
  11. package/package.json +54 -26
  12. package/types/components.d.ts +19 -0
  13. package/types/data/config-schema.json.d.ts +55 -0
  14. package/types/data/invalidation-map.json.d.ts +15 -0
  15. package/types/data/toggle-rules.json.d.ts +20 -0
  16. package/types/data/ui-tree.json.d.ts +19 -0
  17. package/types/data.d.ts +17 -0
  18. package/types/gen_settings_types.mjs +70 -0
  19. package/types/settings-keys.d.ts +1704 -0
  20. package/types/settings.d.ts +19 -0
  21. package/types/toggle.d.ts +22 -0
  22. package/types/viewer-loaders.d.ts +33 -0
  23. package/types/viewer-toolpath.d.ts +105 -0
  24. package/types/viewer.d.ts +15 -0
  25. package/types/wasm.d.ts +5 -0
  26. package/types/worker.d.ts +3 -0
  27. package/viewer/README.md +1 -1
  28. package/viewer/dist/Viewport.js +674 -617
  29. package/viewer/dist/model_loaders.js +220 -108
  30. package/viewer/styles.css +2 -0
  31. package/data/config-schema-builddump.json +0 -12921
  32. package/viewer/src/Viewport.jsx +0 -1353
  33. package/viewer/src/assets/add.svg +0 -1
  34. package/viewer/src/assets/arrange.svg +0 -1
  35. package/viewer/src/assets/boolean.svg +0 -1
  36. package/viewer/src/assets/cut.svg +0 -1
  37. package/viewer/src/assets/delete.svg +0 -1
  38. package/viewer/src/assets/deleteall.svg +0 -1
  39. package/viewer/src/assets/duplicate.svg +0 -1
  40. package/viewer/src/assets/measure.svg +0 -1
  41. package/viewer/src/assets/mmu.svg +0 -1
  42. package/viewer/src/assets/move.svg +0 -1
  43. package/viewer/src/assets/negative.svg +0 -1
  44. package/viewer/src/assets/onbed.svg +0 -1
  45. package/viewer/src/assets/open.svg +0 -1
  46. package/viewer/src/assets/orient.svg +0 -1
  47. package/viewer/src/assets/paint.svg +0 -1
  48. package/viewer/src/assets/rotate.svg +0 -1
  49. package/viewer/src/assets/scale.svg +0 -1
  50. package/viewer/src/assets/seam.svg +0 -1
  51. package/viewer/src/assets/split.svg +0 -1
  52. package/viewer/src/assets/textmark.svg +0 -1
  53. package/viewer/src/assets/varlayer.svg +0 -1
  54. package/viewer/src/icons.js +0 -23
  55. package/viewer/src/make_worker.js +0 -9
  56. package/viewer/src/model_loaders.js +0 -178
  57. package/viewer/src/shadow_host.jsx +0 -30
  58. package/viewer/src/toolpath_gpu.js +0 -430
@@ -1,1353 +0,0 @@
1
- import React, { useEffect, useRef, useState } from 'react'
2
- import * as THREE from 'three'
3
- import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'
4
- import { TransformControls } from 'three/examples/jsm/controls/TransformControls.js'
5
- import { deriveKernelParams, settingRaw } from 'three-slicer/settings'
6
- import { makeSlicerWorker } from './make_worker.js'
7
- import ShadowHost from './shadow_host.jsx'
8
- import shadowCss from '../styles.css?inline' // Shadow DOM 격리 — 빌드 시 문자열로 내장
9
- import { buildSegmentData, makeToolpath, computeColors, roleRatios, VIEW_TYPES, DEFAULT_RANGES_COLORS, TYPE_COLOR } from './toolpath_gpu.js'
10
- import { loadModel, SUPPORTED_EXT, fileExt, splitConnectedComponents } from './model_loaders.js'
11
- // 27단계: 데스크톱 원본 툴바 아이콘 재사용(resources/images → assets, 동일 프로젝트 라이선스).
12
- import {
13
- moveIcon, rotateIcon, scaleIcon, paintIcon, openIcon, addIcon, deleteIcon, arrangeIcon, orientIcon,
14
- onbedIcon, duplicateIcon, splitIcon, deleteallIcon, cutIcon, booleanIcon, negativeIcon,
15
- seamIcon, mmuIcon, textmarkIcon, measureIcon, varlayerIcon,
16
- } from './icons.js'
17
-
18
- // 3D 뷰포트 + 브라우저 단독 슬라이싱(WASM, 트랙 C 4단계).
19
- // - 슬라이스 파라미터는 우측 편집 패널 설정값에서 유도(deriveKernelParams) — 중복 폼 없음.
20
- // - 멀티 오브젝트(누적 업로드 + TransformControls 변환 병합), 서포트/래프트/베드/패턴/냉각/아크/심.
21
- // - 툴패스: 24단계 — 원본 libvgcode 방식(GPU 인스턴싱, toolpath_gpu.js). CPU 지오메트리 빌더 폐기.
22
- // 좌표: 커널 z-up → toolpathGroup rotation.x=-90°(셰이더는 로컬 z-up 에서 계산, view_matrix 가 보정).
23
-
24
- // 모델 로딩(STL/OBJ/3MF/AMF/PLY)은 model_loaders.js 로 이관(26단계). 여기선 model→three 로컬 변환만.
25
- // model → three-local(R=RotX(-90°)), XZ 중심·minY=0
26
- function bakeLocal(modelPos) {
27
- const n = modelPos.length, p = new Float32Array(n)
28
- for (let i = 0; i < n; i += 3) { p[i] = modelPos[i]; p[i + 1] = modelPos[i + 2]; p[i + 2] = -modelPos[i + 1] }
29
- let minx = Infinity, miny = Infinity, minz = Infinity, maxx = -Infinity, maxy = -Infinity, maxz = -Infinity
30
- for (let i = 0; i < n; i += 3) { minx = Math.min(minx, p[i]); maxx = Math.max(maxx, p[i]); miny = Math.min(miny, p[i + 1]); maxy = Math.max(maxy, p[i + 1]); minz = Math.min(minz, p[i + 2]); maxz = Math.max(maxz, p[i + 2]) }
31
- const cx = (minx + maxx) / 2, cz = (minz + maxz) / 2
32
- for (let i = 0; i < n; i += 3) { p[i] -= cx; p[i + 1] -= miny; p[i + 2] -= cz }
33
- return { localPos: p, size: { w: maxx - minx, d: maxz - minz, h: maxy - miny } }
34
- }
35
-
36
- // 툴패스 색/지오메트리/셰이더는 toolpath_gpu.js(원본 libvgcode 포팅)로 이전 — CPU 리본 빌더 폐기(24단계).
37
- // 플레이트 간격 — 고정 mm. (원본은 폭 비례 1/5 이지만, 베드 크기와 무관하게 일정한 간격을 쓴다)
38
- const PLATE_GAP = 40
39
- const plateStep = (edge) => edge + PLATE_GAP
40
- // 플레이트 정방형 배치 — 원본 PartPlate.hpp compute_colum_count: cols ≈ ceil(sqrt(n)).
41
- // i 번째 플레이트 = (col=i%cols)*stepX, (row=i/cols)*stepZ (원본은 -Y 로 증가 → three 에선 +z).
42
- const MAX_PLATES = 9
43
- const plateCols = (count) => { const v = Math.sqrt(count), r = Math.round(v); return v > r ? r + 1 : r }
44
-
45
- export default function Viewport({ settings = {}, setSettings = () => {}, processPanel = null }) {
46
- const mountRef = useRef(null)
47
- const apiRef = useRef(null)
48
- const three = useRef({})
49
- const workerRef = useRef(null)
50
- const objectsRef = useRef([]) // [{id,name,mesh,localPos}]
51
- const layersDataRef = useRef(null) // 포커스(선택) 플레이트의 레이어 데이터 별칭
52
- const toolpathRef = useRef(null) // 24단계: makeToolpath() 컨트롤러 — 포커스 플레이트 별칭(슬라이더/트래블 대상)
53
- const segDataRef = useRef(null) // 25단계: buildSegmentData 결과 — 포커스 플레이트 별칭(뷰 타입 색 재계산용)
54
- const plateTpRef = useRef({}) // 플레이트별 툴패스 실체 {idx: {group, ctl, seg}} — 전 플레이트 동시 렌더
55
- const keyRef = useRef(null) // 단축키 핸들러(컴포넌트 스코프 — 최신 상태 캡처). 이펙트는 포워딩만.
56
- const clipboardRef = useRef(null) // 인앱 복사 버퍼(오브젝트 스냅샷) — OS 클립보드 미사용
57
- const showTravelRef = useRef(false)
58
- const viewTypeRef = useRef('feature') // 25단계: 뷰 타입(feature/speed/height/width/fan/temp)
59
- const layerLoRef = useRef(0) // 25단계: 이중 슬라이더 하한/상한(0-based 레이어)
60
- const layerHiRef = useRef(0)
61
- const canvasModeRef = useRef('prepare') // S2: 인터랙션 게이팅(preview 에선 기즈모/페인팅 비활성)
62
- const lineWidthRef = useRef(0.42) // 마지막 슬라이스의 line_width (기본 폭; 레이어 높이는 buildSegmentData 가 z 증분에서 유도)
63
- // 20단계: 수동 서포트 페인팅 (enforcer/blocker)
64
- const paintModeRef = useRef('off') // 'off' | 'enforcer' | 'blocker'
65
- const brushRadiusRef = useRef(5)
66
- const paintXformRef = useRef(null) // {cx,cy,minz} kernel 변환(오브젝트 STL bbox)
67
- const paintOverlayRef = useRef(null) // {enf: Mesh, blk: Mesh}
68
- const paintDrawingRef = useRef(false)
69
- // 29단계-2: 다중 플레이트 (S7 최소판). 플레이트 i 는 three-x 오프셋 PX_i = i*(bedW+GAP) 에 배치.
70
- const plateResultsRef = useRef({}) // {plateIdx: sliceResult} 캐시
71
- const plateOffsetsRef = useRef({}) // {plateIdx: {offX, offZ}} 툴패스 표시 오프셋(중심화 슬라이스 보정)
72
- const pendingSliceRef = useRef(null) // 프로미스 기반 슬라이스(전체 플레이트 순차용) + 30단계 워치독 타이머
73
- const streamAccumRef = useRef(null) // 30단계: 스트리밍 레이어 누적 {layers:[{z,paths,widths}], gcode:[chunk]}
74
- const downgradeRef = useRef(false) // 30단계: 다운그레이드(간소화) 재시도 중 — buildParams 가 인필 단순화+economy
75
- const selectedPlateRef = useRef(0)
76
- const plateCountRef = useRef(1)
77
- const placeXRef = useRef(0) // 선택 플레이트 내 오브젝트 배치 커서(플레이트 상대)
78
- const plateBWRef = useRef(200) // 플레이트(베드) 폭/깊이 — PX_i·멤버십 계산용
79
- const plateBDRef = useRef(200)
80
-
81
- const [ok, setOk] = useState(true)
82
- const [gmode, setGmode] = useState('translate')
83
- const [status, setStatus] = useState('초기화 중…')
84
- const [objects, setObjects] = useState([])
85
- const [triWarn, setTriWarn] = useState('')
86
- const [slicing, setSlicing] = useState(false)
87
- const [progress, setProgress] = useState(0)
88
- const [error, setError] = useState('')
89
- const [stats, setStats] = useState(null)
90
- const [overBed, setOverBed] = useState(false)
91
- const [layerCount, setLayerCount] = useState(0)
92
- const [segCount, setSegCount] = useState(0) // 24단계: 렌더된 세그먼트 수(인스턴스)
93
- const [plateCount, setPlateCount] = useState(1) // 29단계-2: 플레이트 수
94
- // 플레이트 i 의 three 월드 오프셋 (정방형 그리드 배치)
95
- function platePos(i) {
96
- const cols = plateCols(plateCountRef.current)
97
- return { x: (i % cols) * plateStep(plateBWRef.current), z: Math.floor(i / cols) * plateStep(plateBDRef.current) }
98
- }
99
- // 월드 (x,z) → 가장 가까운 플레이트 인덱스
100
- function plateOfXZ(wx, wz) {
101
- const n = plateCountRef.current, cols = plateCols(n)
102
- const col = Math.max(0, Math.min(cols - 1, Math.round(wx / plateStep(plateBWRef.current))))
103
- const row = Math.max(0, Math.round(wz / plateStep(plateBDRef.current)))
104
- return Math.max(0, Math.min(n - 1, row * cols + col))
105
- }
106
- const [selectedPlate, setSelectedPlate] = useState(0) // 선택 플레이트(0-based)
107
- const [sliceMenu, setSliceMenu] = useState(false) // [슬라이스 ▾] 드롭다운 열림
108
- const [showHelp, setShowHelp] = useState(false) // '?' 단축키 도움말 오버레이
109
- const [slicedPlateCount, setSlicedPlateCount] = useState(0) // 결과 보유 플레이트 수(전체 내보내기 노출용)
110
- const [ctxMenu, setCtxMenu] = useState(null) // 우클릭 컨텍스트 메뉴 {x, y, onObject}
111
- // 25단계 S6: 뷰 타입 + 이중 슬라이더 + 그라디언트 범례
112
- const [viewType, setViewType] = useState('feature')
113
- const [colorRange, setColorRange] = useState(null) // {min,max,label,unit,cont}
114
- const [layerLo, setLayerLo] = useState(0) // 0-based 하한
115
- const [layerHi, setLayerHi] = useState(0) // 0-based 상한
116
- const [singleLayer, setSingleLayer] = useState(false)
117
- const [roleLegend, setRoleLegend] = useState([]) // S6.3: 역할별 길이 비율
118
- const [canvasMode, setCanvasMode] = useState('prepare') // S2: 'prepare'(모델+기즈모+페인팅) | 'preview'(툴패스)
119
- const [dragOver, setDragOver] = useState(false) // 26단계 R4: 드래그앤드롭 하이라이트
120
- const fileInputRef = useRef(null)
121
- // 27단계 S4: 필라멘트(익스트루더) 색 — 오브젝트 메시/프라임타워 색에 반영. 기본 T1/T2.
122
- const [extruderColors, setExtruderColors] = useState(['#6aa0dc', '#e08a2b'])
123
- const extruderColorsRef = useRef(['#6aa0dc', '#e08a2b'])
124
- const [gcodeUrl, setGcodeUrl] = useState('')
125
- const [showTravel, setShowTravel] = useState(false)
126
- const [wipeTowerReal, setWipeTowerReal] = useState(false) // 12단계: 실 WipeTower.generate() (MM 전용)
127
- const [paintMode, setPaintModeState] = useState('off') // 20단계: 서포트 페인팅 모드
128
- const [brushRadius, setBrushRadius] = useState(5)
129
- const [paintCounts, setPaintCounts] = useState({ enf: 0, blk: 0 })
130
- // 30단계 OOM 사다리 UI: 절약 모드 완주 안내 + 다운그레이드 제안(간소화 재시도)
131
- const [sliceNotice, setSliceNotice] = useState('') // 예: "메모리 압박 — 절약 모드로 완주(프리뷰 없음)"
132
- const [downgradeOffer, setDowngradeOffer] = useState(null) // {scope} — 절약 모드도 실패 → 간소화 재시도 제안
133
-
134
- useEffect(() => {
135
- const mount = mountRef.current
136
- if (!mount) return
137
- const probe = document.createElement('canvas')
138
- if (!(probe.getContext('webgl2') || probe.getContext('webgl'))) {
139
- setOk(false); setStatus('이 환경에서 WebGL 컨텍스트를 만들 수 없습니다 (헤드리스/GPU 미지원).'); return
140
- }
141
- let w = mount.clientWidth || 800, h = mount.clientHeight || 480
142
- const renderer = new THREE.WebGLRenderer({ antialias: true })
143
- renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))
144
- renderer.setSize(w, h); renderer.setClearColor(0x161a1e, 1)
145
- renderer.domElement.setAttribute('data-webgl', renderer.getContext() ? 'ok' : 'fail')
146
- mount.appendChild(renderer.domElement)
147
-
148
- const scene = new THREE.Scene()
149
- // 22-fix(H3): 깊이 범위 대폭 축소(기존 0.1/6000 → 1/3000). far/near 비 60000→3000 로 24비트 깊이 정밀도 ~20배↑
150
- // → 서브표면 인필이 표면을 뚫는 z-fighting("대각선 거대 폴리곤") 제거. 실측 깊이해상도: 0.08mm@d974·0.13mm@d1500
151
- // (층높이 0.2mm 이내). logarithmicDepthBuffer 는 gl_FragDepth 로 early-Z 무효화 → 489k 오버드로에서 fps 3배 저하라 미채택.
152
- const camera = new THREE.PerspectiveCamera(50, w / h, 1, 3000)
153
- camera.position.set(210, 180, 260)
154
- scene.add(new THREE.HemisphereLight(0xffffff, 0x2a2f36, 1.0))
155
- const dir = new THREE.DirectionalLight(0xffffff, 1.1); dir.position.set(120, 220, 160); scene.add(dir)
156
-
157
- // 29단계-2: 베드(플레이트) 렌더는 apiRef.setPlates 가 관리(bed useEffect 가 초기화). 초기 단일 그리드 제거.
158
-
159
- // 26단계: 하드코딩 데모 메시(큐브/실린더/토러스) 제거 — 빈 씬 + 드롭 오버레이로 안내.
160
- const objectsGroup = new THREE.Group(); scene.add(objectsGroup)
161
- // 툴패스 루트(회전 없는 컨테이너, 모드 가시성 게이팅 대상). 플레이트별 서브그룹이
162
- // rotation.x=-90°(셰이더 로컬 z-up) + position(offX,0,offZ) 을 각자 가져 전 플레이트 동시 렌더.
163
- const toolpathGroup = new THREE.Group(); scene.add(toolpathGroup)
164
-
165
- const orbit = new OrbitControls(camera, renderer.domElement)
166
- orbit.target.set(0, 22, 0); orbit.enableDamping = false; orbit.update() // 관성 없음 — 데스크톱 슬라이서 관례(릴리즈 즉시 정지, 글라이드 꼬리 버벅임 원천 제거)
167
- orbit.rotateSpeed = 1.6; orbit.panSpeed = 1.6 // 마우스 회전·팬 반응 속도(기본 1.0 이 둔해 상향)
168
- const transform = new TransformControls(camera, renderer.domElement)
169
- transform.setMode('translate'); transform.setSize(0.8)
170
- // 29단계-1: 변환 커밋(드래그 종료)마다 바닥 재안착 — 데스크톱 GLCanvas3D::do_move/rotate/scale 의
171
- // "snaps object to buildplate"(ensure_on_bed) 실측. 이동·회전·스케일 모두, 커밋 시에만(회전 중 실시간 불필요).
172
- // 원본: flying(minZ>0)은 베드로 스냅, sinking(minZ<0)은 SINKING_Z_THRESHOLD 까지 유지. **차이(문서화)**: 우리 커널은
173
- // 음수 z 슬라이스 불가라 싱킹 미지원 → minZ≠0 이면 방향 무관 0 으로 스냅(위든 아래든). 월드 bbox minY(three 높이)→0.
174
- const _seatBox = new THREE.Box3()
175
- const seatMesh = (m) => { if (!m) return; m.updateMatrixWorld(true); _seatBox.setFromObject(m); const minY = _seatBox.min.y; if (Number.isFinite(minY) && Math.abs(minY) > 1e-4) { m.position.y -= minY; m.updateMatrixWorld(true) } }
176
- transform.addEventListener('dragging-changed', e => { orbit.enabled = !e.value; if (!e.value) seatMesh(transform.object); three.current.invalidate?.() })
177
- scene.add(transform)
178
-
179
- three.current = { scene, camera, renderer, orbit, transform, objectsGroup, toolpathGroup, plateBeds: [] }
180
- if (typeof window !== 'undefined') { window.__vpThree = three.current; window.__vpApi = () => apiRef.current } // dev/test aid
181
-
182
- // 렌더 온 디맨드(원본 데스크톱 관례) — 정지 화면을 매 프레임 다시 그리지 않아 지속 GPU 부하·서멀 스로틀 제거.
183
- // 씬이 바뀔 수 있는 모든 경로가 invalidate() 를 부른다: orbit/기즈모 change·포인터·키·리사이즈·React 재렌더.
184
- // ponytail: 놓친 경로 대비 500ms 하트비트 렌더(2fps) — 스테일 화면 방지 안전판.
185
- let renderPending = true
186
- const invalidate = () => { renderPending = true }
187
- three.current.invalidate = invalidate
188
- orbit.addEventListener('change', invalidate)
189
- transform.addEventListener('change', invalidate)
190
- renderer.domElement.addEventListener('pointermove', invalidate)
191
-
192
- const raycaster = new THREE.Raycaster(); const pointer = new THREE.Vector2()
193
- let hovered = null, selected = null, objIdCounter = 0 // 배치 커서는 placeXRef(플레이트 상대)
194
- const activeMeshes = () => objectsRef.current.map(o => o.mesh)
195
- const paint = () => { for (const m of activeMeshes()) m.material.emissive.setHex(m === selected ? 0x00ae42 : m === hovered ? 0x1f5c34 : 0x000000) }
196
- const statusText = () => objectsRef.current.length
197
- ? `오브젝트 ${objectsRef.current.length}개 · 선택: ${selected ? selected.userData.name : '—'} | M/R/S · 좌드래그 회전 · ? 단축키`
198
- : `호버: ${hovered ? hovered.userData.name : '—'} · 선택: ${selected ? selected.userData.name : '—'} | 좌드래그 회전 · ? 단축키`
199
- const toPointer = ev => { const r = renderer.domElement.getBoundingClientRect(); pointer.x = ((ev.clientX - r.left) / r.width) * 2 - 1; pointer.y = -((ev.clientY - r.top) / r.height) * 2 + 1 }
200
- const pick = () => { raycaster.setFromCamera(pointer, camera); const hits = raycaster.intersectObjects(activeMeshes(), false); return hits.length ? hits[0].object : null }
201
- // 20단계: 페인팅 — raycast 히트(faceIndex + 월드점)를 kernel 좌표로 변환해 worker 로 paint 명령.
202
- const pickHit = () => { raycaster.setFromCamera(pointer, camera); const hits = raycaster.intersectObjects(activeMeshes(), false); return hits.length ? hits[0] : null }
203
- const paintAt = ev => {
204
- const X = paintXformRef.current; if (!X) return
205
- toPointer(ev); const hit = pickHit(); if (!hit || hit.faceIndex == null) return
206
- const toK = v => [v.x - X.cx, -v.z - X.cy, v.y - X.minz] // viewer(Y-up) -> STL(Z-up) -> kernel
207
- const hk = toK(hit.point), ck = toK(camera.position)
208
- workerRef.current?.postMessage({ cmd:'paint', facet:hit.faceIndex, hx:hk[0],hy:hk[1],hz:hk[2],
209
- cx:ck[0],cy:ck[1],cz:ck[2], radius:brushRadiusRef.current, enforcer: paintModeRef.current === 'enforcer' })
210
- }
211
- // 커서 힌트: 페인트 모드 crosshair, 오브젝트 호버 pointer, 그 외 기본(카메라 조작).
212
- const applyCursor = () => {
213
- const el = renderer.domElement
214
- el.style.cursor = canvasModeRef.current === 'preview' ? ''
215
- : paintModeRef.current !== 'off' ? 'crosshair'
216
- : hovered ? 'pointer' : ''
217
- }
218
- const onMove = ev => {
219
- if (canvasModeRef.current === 'preview') return // S2: Preview 에선 호버/선택 없음
220
- if (paintModeRef.current !== 'off') { if (paintDrawingRef.current) paintAt(ev); return }
221
- if (transform.dragging || transform.axis) return; toPointer(ev); const hit = pick(); if (hit !== hovered) { hovered = hit; paint(); applyCursor(); setStatus(statusText()) } }
222
- const onDown = ev => {
223
- if (ev.button !== 0) return // 좌클릭만 — 우/휠 클릭은 OrbitControls 팬·줌 전용(선택·페인팅 오발 방지)
224
- if (canvasModeRef.current === 'preview') return // S2: Preview 에선 기즈모/페인팅 없음
225
- if (paintModeRef.current !== 'off') { paintDrawingRef.current = true; orbit.enabled = false; paintAt(ev); return }
226
- if (transform.dragging || transform.axis) return; toPointer(ev); const hit = pick(); if (hit) { selected = hit; transform.attach(hit) } else { selected = null; transform.detach() } paint(); setStatus(statusText()) }
227
- // 페인팅 종료는 window 에서 받는다 — 캔버스 밖에서 버튼을 떼도 paintDrawing/orbit.enabled 가 고착되지 않도록.
228
- const onUp = () => { if (paintDrawingRef.current) { paintDrawingRef.current = false; orbit.enabled = true } }
229
- // 더블클릭: 오브젝트 = 줌투, 빈 공간 = 선택 해제 (3D 앱 관례)
230
- const onDblClick = ev => {
231
- if (ev.button !== 0 || canvasModeRef.current === 'preview' || paintModeRef.current !== 'off') return
232
- toPointer(ev)
233
- if (pick()) frameObjects()
234
- else { selected = null; transform.detach(); paint(); setStatus(statusText()) }
235
- }
236
- // 휠: 페인트 모드에선 브러시 반경 조절(원본 GLGizmoPainterBase 관례) — 그 외엔 OrbitControls 줌 그대로.
237
- const onWheel = ev => {
238
- if (paintModeRef.current === 'off' || canvasModeRef.current === 'preview') return
239
- ev.preventDefault(); ev.stopPropagation()
240
- const v = Math.min(15, Math.max(1, brushRadiusRef.current + (ev.deltaY < 0 ? 0.5 : -0.5)))
241
- brushRadiusRef.current = v; setBrushRadius(v)
242
- }
243
- // 우클릭 컨텍스트 메뉴 — 눌린 지점의 오브젝트를 선택한 뒤 메뉴 좌표를 컴포넌트로 넘긴다.
244
- // (OrbitControls 가 contextmenu 를 preventDefault 하므로 기본 메뉴와 충돌 없음.)
245
- // 주의: 우클릭 팬 드래그와 구분 — pointerdown 위치에서 4px 이상 움직였으면 메뉴를 열지 않는다.
246
- let rmbDown = null
247
- const onRmbDown = ev => { if (ev.button === 2) rmbDown = { x: ev.clientX, y: ev.clientY } }
248
- const onCtxMenu = ev => {
249
- ev.preventDefault()
250
- if (canvasModeRef.current === 'preview' || paintModeRef.current !== 'off') return
251
- if (rmbDown && Math.hypot(ev.clientX - rmbDown.x, ev.clientY - rmbDown.y) > 4) return // 팬 드래그였음
252
- toPointer(ev); const hit = pick()
253
- if (hit) { selected = hit; transform.attach(hit) } else { selected = null; transform.detach() }
254
- paint(); setStatus(statusText())
255
- const r = renderer.domElement.getBoundingClientRect()
256
- setCtxMenu({ x: ev.clientX - r.left, y: ev.clientY - r.top, onObject: !!hit })
257
- }
258
- renderer.domElement.addEventListener('pointermove', onMove)
259
- renderer.domElement.addEventListener('pointerdown', onDown)
260
- renderer.domElement.addEventListener('pointerdown', onRmbDown)
261
- renderer.domElement.addEventListener('contextmenu', onCtxMenu)
262
- renderer.domElement.addEventListener('dblclick', onDblClick)
263
- renderer.domElement.addEventListener('wheel', onWheel, { passive: false, capture: true })
264
- window.addEventListener('pointerup', onUp)
265
- window.addEventListener('pointercancel', onUp)
266
-
267
- const setMode = m => { transform.setMode(m); setGmode(m) }
268
- const frameObjects = () => {
269
- const arr = objectsRef.current
270
- const box = new THREE.Box3()
271
- if (arr.length) arr.forEach(o => box.expandByObject(o.mesh)); else box.setFromCenterAndSize(new THREE.Vector3(0, 25, 0), new THREE.Vector3(100, 50, 100))
272
- const c = box.getCenter(new THREE.Vector3()), s = box.getSize(new THREE.Vector3())
273
- const d = Math.max(s.x, s.y, s.z, 20) * 1.9 + 40
274
- orbit.target.copy(c); camera.position.set(c.x + d * 0.7, c.y + d * 0.55 + s.y * 0.3, c.z + d); camera.updateProjectionMatrix(); orbit.update()
275
- }
276
-
277
- // 오브젝트 메시 등록 공통 경로 — addObject(신규 로드)와 spawnSnapshot(복제/붙여넣기)이 공유.
278
- // 26단계 R4 + 29단계-2: 선택 플레이트 위에 나란히 배치(placeXRef=플레이트 상대 커서, PX=플레이트 오프셋).
279
- // pos 를 주면 배치 커서 대신 그 위치에 놓는다(분리: 부품이 원래 있던 자리를 유지해야 한다).
280
- const spawnMesh = (name, localPos, rot = null, scale = null, pos = null) => {
281
- const geo = new THREE.BufferGeometry()
282
- geo.setAttribute('position', new THREE.Float32BufferAttribute(localPos, 3)); geo.computeVertexNormals()
283
- geo.computeBoundingBox()
284
- const col0 = extruderColorsRef.current[0] || '#6aa0dc' // T1 필라멘트 색 반영
285
- const mesh = new THREE.Mesh(geo, new THREE.MeshStandardMaterial({ color: new THREE.Color(col0), roughness: 0.6, metalness: 0.05, side: THREE.DoubleSide }))
286
- if (rot) mesh.rotation.copy(rot)
287
- if (scale) mesh.scale.copy(scale)
288
- if (pos) {
289
- mesh.position.copy(pos) // 분리 등: 원래 자리 유지(배치 커서 미사용)
290
- } else {
291
- const w = (geo.boundingBox.max.x - geo.boundingBox.min.x) * (scale ? Math.abs(scale.x) : 1)
292
- if (objectsRef.current.length === 0) placeXRef.current = 0
293
- const pp = platePos(selectedPlateRef.current)
294
- mesh.position.set(pp.x + placeXRef.current + w / 2, 0, pp.z)
295
- placeXRef.current += w + 8
296
- }
297
- mesh.userData = { name }
298
- objectsGroup.add(mesh)
299
- const id = ++objIdCounter
300
- objectsRef.current.push({ id, name, mesh, localPos, extruder: 1, visible: true }) // MM: 기본 익스트루더 1
301
- objectsGroup.visible = true
302
- setStatus(statusText()); frameObjects()
303
- return { id, name }
304
- }
305
-
306
- apiRef.current = {
307
- setMode,
308
- refreshCursor: () => applyCursor(), // 페인트 모드 전환 시 커서 힌트 동기화
309
- detachTransform: () => { selected = null; transform.detach(); paint() }, // 20단계: 페인팅 진입 시 기즈모 해제
310
- addObject: (name, modelPos) => spawnMesh(name, bakeLocal(modelPos).localPos),
311
- // ---- 단축키 지원 (복제/넛지/회전/줌투) ----
312
- getSnapshot: (id) => { // 복사/복제용 스냅샷 — localPos 는 불변이라 참조 공유
313
- const o = objectsRef.current.find(x => x.id === id); if (!o) return null
314
- return { name: o.name, localPos: o.localPos, rot: o.mesh.rotation.clone(), scale: o.mesh.scale.clone(), pos: o.mesh.position.clone() }
315
- },
316
- // keepPos=true 면 스냅샷의 원래 위치를 유지(분리). false(기본)면 배치 커서로 옆에 놓는다(복제/붙여넣기).
317
- spawnSnapshot: (snap, keepPos = false) => snap ? spawnMesh(snap.name, snap.localPos, snap.rot, snap.scale, keepPos ? (snap.pos || null) : null) : null,
318
- nudgeSelected: (dx, dz) => { if (!selected) return; selected.position.x += dx; selected.position.z += dz },
319
- rotateSelectedY: (rad) => { if (!selected) return; selected.rotation.y += rad },
320
- frame: () => frameObjects(), // Z: 전체 오브젝트로 줌
321
- frameBed: () => { // B: 선택 플레이트로 줌
322
- const pp = platePos(selectedPlateRef.current)
323
- const d = Math.max(plateBWRef.current, plateBDRef.current) * 1.1 + 40
324
- orbit.target.set(pp.x, 0, pp.z); camera.position.set(pp.x + d * 0.55, d * 0.8, pp.z + d); orbit.update()
325
- },
326
- removeObject: (id) => {
327
- const arr = objectsRef.current
328
- const k = arr.findIndex(o => o.id === id); if (k < 0) return
329
- const o = arr[k]
330
- if (selected === o.mesh) { selected = null; transform.detach() }
331
- if (hovered === o.mesh) hovered = null
332
- objectsGroup.remove(o.mesh); o.mesh.geometry.dispose(); o.mesh.material.dispose()
333
- arr.splice(k, 1)
334
- if (arr.length === 0) placeXRef.current = 0
335
- paint(); setStatus(statusText())
336
- },
337
- // MM: 익스트루더 오름차순으로 삼각형 정렬 병합 → 그룹0(ext1) 뒤 그룹1(ext2), split 반환.
338
- platePos: (i) => platePos(i), // 플레이트 i 의 three (x,z) 오프셋
339
- plateOfObject: (o) => { // 위치 기반 소속 = 가장 가까운 플레이트 중심
340
- o.mesh.updateMatrixWorld(true)
341
- const wp = new THREE.Vector3().setFromMatrixPosition(o.mesh.matrixWorld)
342
- return plateOfXZ(wp.x, wp.z)
343
- },
344
- // plateIdx!=null 이면 그 플레이트 소속 오브젝트만 + 좌표를 플레이트 로컬(three-x -= PX)로 변환(28단계 계약 유지).
345
- buildMergedSTL: (plateIdx = null) => {
346
- let arr = objectsRef.current.filter(o => o.visible !== false)
347
- if (plateIdx != null) arr = arr.filter(o => { o.mesh.updateMatrixWorld(true); const wp = new THREE.Vector3().setFromMatrixPosition(o.mesh.matrixWorld); return plateOfXZ(wp.x, wp.z) === plateIdx })
348
- if (!arr.length) return null
349
- const sorted = [...arr].sort((a, b) => (a.extruder || 1) - (b.extruder || 1))
350
- const usedExtruders = new Set(sorted.map(o => o.extruder || 1))
351
- const tmp = new THREE.Vector3(); const out = []
352
- let triCount = 0, split = 0
353
- for (const o of sorted) {
354
- if ((o.extruder || 1) >= 2 && split === 0) split = triCount // ext2 시작 경계
355
- o.mesh.updateMatrixWorld(true)
356
- const M = o.mesh.matrixWorld, lp = o.localPos
357
- for (let i = 0; i < lp.length; i += 3) { tmp.set(lp[i], lp[i + 1], lp[i + 2]).applyMatrix4(M); out.push(tmp.x, -tmp.z, tmp.y) } // Rinv → model(월드)
358
- triCount += lp.length / 9
359
- }
360
- // 29단계: 슬라이스 입력을 XY 원점 중심으로(대칭 좌표). 원본 데스크톱도 m_plate_origin 로 중심화 후 슬라이스.
361
- // 이유: 28단계 P2(재정렬 제거) 이후 일부 비대칭/음수 좌표(예: x[0,20]·y[-10,10])가 커널 스커트/인필 경로에서
362
- // memory OOB 를 유발(대칭 좌표는 무사 — golden 도 무사). 중심화로 회피 + 그만큼 툴패스를 오프셋해 화면 모델과 겹침 유지.
363
- let mnx = 1e18, mny = 1e18, mxx = -1e18, mxy = -1e18
364
- for (let i = 0; i < out.length; i += 3) { if (out[i] < mnx) mnx = out[i]; if (out[i] > mxx) mxx = out[i]; if (out[i + 1] < mny) mny = out[i + 1]; if (out[i + 1] > mxy) mxy = out[i + 1] }
365
- const Cmx = (mnx + mxx) / 2, Cmy = (mny + mxy) / 2 // 월드 콘텐츠 XY중심(플레이트 오프셋 PX 포함)
366
- for (let i = 0; i < out.length; i += 3) { out[i] -= Cmx; out[i + 1] -= Cmy }
367
- // 툴패스 표시 오프셋(three): 콘텐츠 월드중심 model(Cmx,Cmy) → three(x=Cmx, z=-Cmy). Cmx 가 플레이트 PX 를 이미 포함 → 해당 플레이트 위에 렌더.
368
- const offX3 = Cmx, offZ3 = -Cmy
369
- const buf = new ArrayBuffer(84 + triCount * 50), dvw = new DataView(buf)
370
- dvw.setUint32(80, triCount, true)
371
- let off = 84, vi = 0
372
- for (let t = 0; t < triCount; t++) {
373
- off += 12
374
- for (let k = 0; k < 3; k++) { dvw.setFloat32(off, out[vi++], true); dvw.setFloat32(off + 4, out[vi++], true); dvw.setFloat32(off + 8, out[vi++], true); off += 12 }
375
- dvw.setUint16(off, 0, true); off += 2
376
- }
377
- return { buf, split, extruders: usedExtruders.size, offX: offX3, offZ: offZ3 }
378
- },
379
- setObjectExtruder: (id, e) => { const o = objectsRef.current.find(x => x.id === id); if (o) { o.extruder = e; const c = extruderColorsRef.current[e - 1]; if (c) o.mesh.material.color.set(c) } },
380
- setObjectVisible: (id, v) => { const o = objectsRef.current.find(x => x.id === id); if (o) { o.visible = v; o.mesh.visible = v } }, // 27단계: 출력 토글(눈알)
381
- recolorObjects: () => { for (const o of objectsRef.current) { const c = extruderColorsRef.current[(o.extruder || 1) - 1]; if (c) o.mesh.material.color.set(c) } }, // 필라멘트 색 변경 반영
382
- selectedObjectId: () => selected ? (objectsRef.current.find(o => o.mesh === selected)?.id ?? null) : null, // 27단계: 뷰포트 툴바 "선택 삭제"
383
- selectObject: (id) => { // 33단계: 목록에서 클릭한 오브젝트를 선택(분리 등 선택 기반 동작용)
384
- const o = objectsRef.current.find(x => x.id === id); if (!o) return
385
- selected = o.mesh; transform.attach(o.mesh); paint(); setStatus(statusText())
386
- },
387
- // 28단계 P1: 바닥 안착 — 로컬 지오메트리는 bakeLocal 이 minZ→0(로드 시 1회). 기즈모 Z이동 후 재안착.
388
- // (원본 ensure_on_bed 의 싱킹 허용[allow_negative_z]은 범위 외 — 우리는 -min_z 안착만.)
389
- placeOnBed: () => { for (const o of objectsRef.current) o.mesh.position.y = 0; if (selected) transform.update?.() },
390
- onSliced: () => { objectsGroup.visible = false; transform.detach(); selected = null; paint() },
391
- showObjects: () => { objectsGroup.visible = true },
392
- // 29단계-2: N 플레이트 렌더 — 각 플레이트 = 그리드+테두리, PX_i 오프셋, 선택 플레이트 테두리 하이라이트.
393
- setPlates: (n, bw, bd, sel) => {
394
- const t = three.current
395
- plateBWRef.current = bw; plateBDRef.current = bd; plateCountRef.current = n; selectedPlateRef.current = sel
396
- for (const p of (t.plateBeds || [])) for (const m of [p.gridThin, p.gridBold, p.border]) { t.scene.remove(m); m.geometry.dispose(); m.material.dispose() }
397
- t.plateBeds = []
398
- const cols = plateCols(n), sx = plateStep(bw), sz = plateStep(bd)
399
- // 격자: 원본 Bed_2D 규칙 — 베드 사각형에 정확히 맞는 직사각 격자, 짧은 변 기준 셀
400
- // 간격(<600mm → 10mm …), 코너 원점에서 전개하며 5칸마다 굵은 선(main grid 50mm).
401
- const minEdge = Math.min(bw, bd)
402
- const cell = minEdge >= 6000 ? 100 : minEdge >= 1200 ? 50 : minEdge >= 600 ? 20 : 10
403
- const thin = [], bold = []
404
- const x0 = -bw / 2, z0 = -bd / 2
405
- for (let i = 0, x = x0; x <= bw / 2 + 1e-6; x = x0 + ++i * cell) (i % 5 ? thin : bold).push(x, 0, z0, x, 0, bd / 2)
406
- for (let j = 0, z = z0; z <= bd / 2 + 1e-6; z = z0 + ++j * cell) (j % 5 ? thin : bold).push(x0, 0, z, bw / 2, 0, z)
407
- const lineGeo = (a) => { const g = new THREE.BufferGeometry(); g.setAttribute('position', new THREE.Float32BufferAttribute(a, 3)); return g }
408
- for (let i = 0; i < n; i++) {
409
- const px = (i % cols) * sx, pz = Math.floor(i / cols) * sz
410
- const gt = new THREE.LineSegments(lineGeo(thin), new THREE.LineBasicMaterial({ color: 0x232a31 }))
411
- const gb = new THREE.LineSegments(lineGeo(bold), new THREE.LineBasicMaterial({ color: 0x39434d }))
412
- gt.position.set(px, 0, pz); gb.position.set(px, 0, pz); t.scene.add(gt); t.scene.add(gb)
413
- const sel_ = i === sel
414
- const b = new THREE.LineSegments(new THREE.EdgesGeometry(new THREE.PlaneGeometry(bw, bd)), new THREE.LineBasicMaterial({ color: sel_ ? 0x00ae42 : 0x4a5560, linewidth: sel_ ? 2 : 1 }))
415
- b.rotation.x = -Math.PI / 2; b.position.set(px, 0, pz); t.scene.add(b)
416
- t.plateBeds.push({ gridThin: gt, gridBold: gb, border: b })
417
- }
418
- },
419
- setBed: (bw, bd) => { apiRef.current?.setPlates(plateCountRef.current, bw, bd, selectedPlateRef.current) }, // 하위호환
420
- }
421
-
422
- // 단축키 본체는 컴포넌트 스코프(keyRef — 매 렌더 최신 상태/함수 캡처). 이펙트는 포워딩만.
423
- const onKey = e => { keyRef.current?.(e); invalidate() }
424
- window.addEventListener('keydown', onKey)
425
- const ro = new ResizeObserver(() => { w = mount.clientWidth || w; h = mount.clientHeight || h; camera.aspect = w / h; camera.updateProjectionMatrix(); renderer.setSize(w, h); invalidate() })
426
- ro.observe(mount)
427
- setStatus(statusText())
428
- let raf = 0, lastRender = 0
429
- const loop = (now = 0) => {
430
- raf = requestAnimationFrame(loop); orbit.update()
431
- if (renderPending || now - lastRender > 500) { renderPending = false; lastRender = now; renderer.render(scene, camera) }
432
- }
433
- loop()
434
-
435
- return () => {
436
- cancelAnimationFrame(raf)
437
- ro.disconnect(); window.removeEventListener('keydown', onKey)
438
- renderer.domElement.removeEventListener('pointermove', onMove); renderer.domElement.removeEventListener('pointerdown', onDown)
439
- renderer.domElement.removeEventListener('pointerdown', onRmbDown); renderer.domElement.removeEventListener('contextmenu', onCtxMenu)
440
- renderer.domElement.removeEventListener('dblclick', onDblClick); renderer.domElement.removeEventListener('wheel', onWheel, { capture: true })
441
- window.removeEventListener('pointerup', onUp); window.removeEventListener('pointercancel', onUp)
442
- apiRef.current = null
443
- transform.detach(); transform.dispose(); orbit.dispose()
444
- scene.traverse(o => { if (o.geometry) o.geometry.dispose(); if (o.material) o.material.dispose() })
445
- renderer.dispose()
446
- if (renderer.domElement.parentNode) renderer.domElement.parentNode.removeChild(renderer.domElement)
447
- }
448
- }, [])
449
-
450
- useEffect(() => () => { if (workerRef.current) { workerRef.current.terminate(); workerRef.current = null } }, [])
451
-
452
- // 워밍업: 마운트 직후 워커 생성 + 커널 로드(3.4MB 파싱·wasm 컴파일·mt pthread 풀)를 미리 수행.
453
- // 사용자가 모델을 고르고 배치하는 유휴 시간에 끝나므로 첫 슬라이스 클릭 시 체감 로딩이 사라진다.
454
- useEffect(() => { try { getWorker().postMessage({ cmd: 'warmup' }) } catch { /* 로드 실패는 첫 슬라이스에서 정식 보고 */ } }, [])
455
-
456
- // 베드 그리드를 설정(printable_area) 유도값으로 갱신
457
- const kp = deriveKernelParams(settings)
458
- useEffect(() => { apiRef.current?.setPlates(plateCount, kp.bed_width, kp.bed_depth, selectedPlate) }, [kp.bed_width, kp.bed_depth, plateCount, selectedPlate])
459
-
460
- // S2: Prepare|Preview 모드 — 그룹 가시성 + 인터랙션 게이팅
461
- useEffect(() => {
462
- canvasModeRef.current = canvasMode
463
- const t = three.current; if (!t.toolpathGroup) return
464
- const preview = canvasMode === 'preview'
465
- t.toolpathGroup.visible = preview
466
- if (t.objectsGroup) t.objectsGroup.visible = !preview && objectsRef.current.length > 0
467
- if (preview) { // Preview: 기즈모/페인팅 강제 해제
468
- apiRef.current?.detachTransform()
469
- if (paintModeRef.current !== 'off') setPaintMode('off')
470
- }
471
- }, [canvasMode])
472
-
473
- // ---- 툴패스 빌드 (24단계: 원본 libvgcode GPU 인스턴싱 / 플레이트별 동시 렌더) ----
474
- function disposePlateToolpath(idx) {
475
- const e = plateTpRef.current[idx]
476
- if (!e) return
477
- e.group.remove(e.ctl.mesh); e.group.remove(e.ctl.travLines); e.ctl.dispose()
478
- three.current.toolpathGroup?.remove(e.group)
479
- delete plateTpRef.current[idx]
480
- if (toolpathRef.current === e.ctl) { toolpathRef.current = null; segDataRef.current = null }
481
- }
482
- function clearToolpaths() {
483
- for (const k of Object.keys(plateTpRef.current)) disposePlateToolpath(Number(k))
484
- toolpathRef.current = null; segDataRef.current = null
485
- }
486
- // 플레이트 idx 의 툴패스 실체를 (재)구축 — 서브그룹이 자기 오프셋을 가져 다른 플레이트와 동시 표시.
487
- function buildPlateToolpath(idx, layers) {
488
- const { toolpathGroup } = three.current
489
- if (!toolpathGroup) return null
490
- disposePlateToolpath(idx)
491
- if (!layers || !layers.length) return null
492
- const seg = buildSegmentData(layers, lineWidthRef.current)
493
- if (import.meta.env?.DEV && seg.hasNaN) console.error('[toolpath] non-finite vertex data') // dev 회귀 감지
494
- const ctl = makeToolpath(THREE, seg)
495
- const off = plateOffsetsRef.current[idx] || { offX: 0, offZ: 0 }
496
- const group = new THREE.Group()
497
- group.rotation.x = -Math.PI / 2
498
- group.position.set(off.offX || 0, 0, off.offZ || 0)
499
- group.add(ctl.mesh); group.add(ctl.travLines)
500
- toolpathGroup.add(group)
501
- ctl.setTravelVisible(showTravelRef.current)
502
- ctl.setLayerRange(0, Math.max(0, layers.length - 1)) // 비포커스 기본: 전체 범위
503
- const cc = computeColors(seg, viewTypeRef.current, viewCtx()) // 현재 뷰 타입 색 적용
504
- ctl.setColors(cc.color)
505
- const entry = { group, ctl, seg, layers } // layers = 소스 참조(재슬라이스 감지용)
506
- plateTpRef.current[idx] = entry
507
- return entry
508
- }
509
- // 캐시된 모든 플레이트 결과의 툴패스 실체를 보장 — slice-all 후 전 플레이트 동시 조회.
510
- // 소스 레이어 참조가 바뀌었으면(재슬라이스) 스테일 실체를 재구축.
511
- function ensurePlateToolpaths() {
512
- for (const [k, r] of Object.entries(plateResultsRef.current)) {
513
- const idx = Number(k)
514
- if (!r || r.error || !r.layers || !r.layers.length) continue
515
- const e = plateTpRef.current[idx]
516
- if (!e || e.layers !== r.layers) buildPlateToolpath(idx, r.layers)
517
- }
518
- }
519
- // CPU 는 지오메트리를 만들지 않는다 — buildSegmentData 로 텍스처 스트림만 준비 → makeToolpath 로 인스턴싱 메시.
520
- // 177만+ 세그먼트도 O(1) 지오메트리(24정점 템플릿) + O(n) 텍스처로 단번에 렌더(청크/폴백 불필요).
521
- // 25단계: 뷰 타입 컬러링용 컨텍스트 — 속도/팬/온도는 커널 toolpath 에 없어 설정값에서 유도(커널 무변경).
522
- // 타입→피처 속도 매핑(외벽/인필 등 데스크톱 설정 그대로). settingRaw 로 스키마 값 직접 조회.
523
- function viewCtx() {
524
- const S = (k, def) => { const v = settingRaw(settings, k); const n = parseFloat(v); return Number.isFinite(n) ? n : def }
525
- const ow = S('outer_wall_speed', 60)
526
- return {
527
- speedByType: {
528
- 1: ow, 2: S('sparse_infill_speed', 40), 3: S('internal_solid_infill_speed', 45),
529
- 4: ow, 5: S('support_speed', 35), 6: S('support_speed', 35), 7: S('gap_infill_speed', 30),
530
- 8: ow, 9: S('bridge_speed', 25), 10: S('ironing_speed', 20), 11: ow,
531
- },
532
- firstLayerSpeed: S('initial_layer_speed', 30),
533
- closeFanLayers: S('close_fan_the_first_x_layers', 1),
534
- fanNormal: S('fan_max_speed', 100),
535
- tempNormal: S('nozzle_temperature', 210),
536
- tempFirst: S('nozzle_temperature_initial_layer', S('nozzle_temperature', 210)),
537
- }
538
- }
539
- // 현재 뷰 타입으로 color 텍스처 재계산 — 모든 플레이트에 적용, 범례/범위는 포커스 플레이트 기준.
540
- function applyViewColors() {
541
- const ctx = viewCtx()
542
- for (const e of Object.values(plateTpRef.current)) {
543
- const cc = computeColors(e.seg, viewTypeRef.current, ctx)
544
- e.ctl.setColors(cc.color)
545
- if (e.ctl === toolpathRef.current)
546
- setColorRange({ min: cc.min, max: cc.max, label: cc.label, unit: cc.unit, cont: cc.cont })
547
- }
548
- }
549
- // 포커스 플레이트(selectedPlateRef) 툴패스 재구축 + 별칭/통계 갱신. 다른 플레이트 실체는 유지.
550
- function rebuildToolpaths() {
551
- const idx = selectedPlateRef.current
552
- const data = layersDataRef.current || []
553
- if (!data.length) {
554
- disposePlateToolpath(idx)
555
- setSegCount(0); toolpathRef.current = null; segDataRef.current = null
556
- return
557
- }
558
- const entry = buildPlateToolpath(idx, data)
559
- if (!entry) { setSegCount(0); return }
560
- toolpathRef.current = entry.ctl
561
- segDataRef.current = entry.seg
562
- entry.ctl.setLayerRange(layerLoRef.current, layerHiRef.current)
563
- applyViewColors()
564
- setSegCount(entry.seg.nSeg)
565
- setRoleLegend(roleRatios(entry.seg.typeLengths)) // S6.3: 역할별 비율
566
- }
567
- function applyLayerRange() { toolpathRef.current?.setLayerRange(layerLoRef.current, layerHiRef.current) }
568
-
569
- // ---- 진행률: 시간 가중 매핑 + 서포트 실진행(SAB 폴링) ----
570
- // 구간별 실측 시간 비중(774k tri): PASS1 7% · 표면 6% · 서포트 48% · 방출 38% → 예산 15/5/40/40.
571
- const supSabRef = useRef(null) // { arr: Uint32Array(SAB, ptr, 1) } — mt 워커가 1회 공유
572
- const supPollRef = useRef(0)
573
- const stopSupPoll = () => { if (supPollRef.current) { clearInterval(supPollRef.current); supPollRef.current = 0 } }
574
- const mapProgress = (done, total) => {
575
- const N = total > 2 ? (total - 2) / 2 : 0
576
- if (!N) return total ? done / total : 0
577
- if (done <= N) return 0.15 * (done / N) // PASS1: 0→15%
578
- if (done === N + 1) return 0.20 // 표면 완료 → 서포트 진입
579
- if (done === N + 2) return 0.60 // 서포트 완료
580
- return 0.60 + 0.40 * ((done - N - 2) / N) // 방출: 60→100%
581
- }
582
- const startSupPoll = (N) => {
583
- const sab = supSabRef.current; if (!sab || supPollRef.current) return
584
- // 총 작업량 ≈ 3.5×N (top_contacts N + bottom ~N + trim N + base N 근사, 실측 보정 상수) — 95% 캡 후 완료 틱에서 스냅
585
- supPollRef.current = setInterval(() => {
586
- const ticks = sab.arr[0]
587
- setProgress(0.20 + 0.40 * Math.min(0.95, ticks / (3.5 * N)))
588
- }, 150)
589
- }
590
- function getWorker() {
591
- if (!workerRef.current) {
592
- const wk = makeSlicerWorker() // 정적 워커 패턴은 make_worker.js(비번들 원형 배포)에 격리
593
- wk.onmessage = (e) => {
594
- const d = e.data
595
- const pnd = pendingSliceRef.current
596
- if (d.type === 'progress') { // 30단계: 워치독 리셋(+단계 기록) + 가중 매핑 + 서포트 폴링 제어
597
- const N = d.total > 2 ? (d.total - 2) / 2 : 0
598
- if (N && d.done === N + 1) startSupPoll(N)
599
- if (N && d.done >= N + 2) stopSupPoll()
600
- setProgress(mapProgress(d.done, d.total)); pnd?.note?.(d.done, d.total); pnd?.kick?.()
601
- }
602
- else if (d.type === 'supsab') { try { supSabRef.current = { arr: new Uint32Array(d.buf, d.ptr, 1) } } catch {} }
603
- else if (d.type === 'layer') { // 30단계 스트리밍: 레이어 즉시 수신(transfer) → 누적, 워치독 리셋
604
- pnd?.kick?.()
605
- const a = streamAccumRef.current
606
- if (a) { a.layers.push({ z: d.z, paths: d.paths, widths: d.widths }); if (d.gcode) a.gcode.push(d.gcode) }
607
- }
608
- else if (d.type === 'done') { stopSupPoll(); if (pnd) { pendingSliceRef.current = null; pnd.stop?.(); pnd.resolve(assembleResult(d.result)) } else { handleResult(assembleResult(d.result)); setSlicing(false) } }
609
- else if (d.type === 'error') { stopSupPoll(); if (pnd) { pendingSliceRef.current = null; pnd.stop?.(); pnd.reject(new Error(d.error)) } else { setError('슬라이스 실패: ' + d.error); setSlicing(false) } }
610
- else if (d.type === 'prepared') { /* selector mesh registered */ }
611
- else if (d.type === 'painted') { setPaintCounts({ enf: d.enf, blk: d.blk }); wk.postMessage({ cmd: 'overlay' }) }
612
- else if (d.type === 'overlay') { rebuildPaintOverlay(d.enf, d.blk) }
613
- }
614
- // 30단계 OOM 감지: worker error/messageerror → 진행 중 슬라이스를 거부(사다리가 절약 재시도 판정).
615
- const killPending = (msg) => { stopSupPoll(); supSabRef.current = null; const pnd = pendingSliceRef.current; if (pnd) { pendingSliceRef.current = null; pnd.stop?.(); try { wk.terminate() } catch {} workerRef.current = null; pnd.reject(new Error(msg)) } else { setError(msg); setSlicing(false) } }
616
- wk.onerror = (ev) => killPending('Worker 종료(메모리 초과 추정): ' + (ev.message || 'worker error'))
617
- wk.onmessageerror = () => killPending('Worker 메시지 오류(구조화 복제 실패)')
618
- workerRef.current = wk
619
- if (typeof window !== 'undefined') window.__vpWorker = wk // dev/test aid: drive selector cmds directly
620
- }
621
- return workerRef.current
622
- }
623
- // 30단계: 스트리밍 결과 조립 — streamed 면 g-code/layers 는 'layer' 로 이미 받았으므로 누적분으로 구성.
624
- // batch/MM 는 result 에 gcode+layers 가 그대로 있음. 절약 모드는 layers 빈 배열(툴패스 없음) + gcode 만.
625
- function assembleResult(result) {
626
- if (result && result.stats && result.stats.streamed) {
627
- const a = streamAccumRef.current || { layers: [], gcode: [] }
628
- return { stats: result.stats, layers: a.layers, gcode: a.gcode.join('') }
629
- }
630
- return result
631
- }
632
- function handleResult(result) {
633
- if (result.error) { setError(String(result.error)); return }
634
- layersDataRef.current = result.layers
635
- const n = result.layers.length
636
- layerLoRef.current = 0; layerHiRef.current = n - 1; setLayerLo(0); setLayerHi(n - 1) // 이중 슬라이더 전체 범위
637
- rebuildToolpaths()
638
- apiRef.current?.onSliced()
639
- setCanvasMode('preview') // S2: 슬라이스 완료 시 자동 Preview 전환
640
- setStats({ layers: result.stats.layers, segments: result.stats.path_segments, filament: result.stats.filament_mm, timeSec: result.stats.time_estimate })
641
- setOverBed(!!result.stats.over_bed)
642
- setLayerCount(n)
643
- setGcodeUrl(prev => { if (prev) URL.revokeObjectURL(prev); return URL.createObjectURL(new Blob([result.gcode], { type: 'text/plain' })) })
644
- }
645
-
646
- // ---- 26단계: 모델 로드 (STL/OBJ/3MF/AMF/PLY, 누적) — 파일선택+드래그앤드롭 공용 ----
647
- async function loadFiles(fileList) {
648
- const files = Array.from(fileList || []).filter(f => SUPPORTED_EXT.includes(fileExt(f.name)))
649
- const rejected = Array.from(fileList || []).length - files.length
650
- if (!files.length) { if (rejected) setError('지원 포맷: STL/OBJ/3MF/AMF/PLY'); return }
651
- setError(''); setTriWarn(''); setProgress(0)
652
- layersDataRef.current = null; segDataRef.current = null; plateResultsRef.current = {}; plateOffsetsRef.current = {}
653
- clearToolpaths(); refreshSlicedCount()
654
- setStats(null); setOverBed(false); setLayerCount(0); setSegCount(0); setColorRange(null); setSliceNotice(''); setDowngradeOffer(null)
655
- setGcodeUrl(prev => { if (prev) URL.revokeObjectURL(prev); return '' })
656
- setCanvasMode('prepare') // S2: 새 모델은 Prepare 로
657
- apiRef.current?.showObjects()
658
- let totalTri = 0
659
- for (const f of files) {
660
- try {
661
- const __tl0 = performance.now() // [vp-prof] 로드 계측(임시)
662
- const buf = await f.arrayBuffer()
663
- const __tl1 = performance.now()
664
- const objs = await loadModel(f.name, buf) // [{name, modelPos}] (3MF/AMF 는 복수 가능)
665
- const __tl2 = performance.now()
666
- for (const ob of objs) { apiRef.current?.addObject(ob.name, ob.modelPos); totalTri += ob.modelPos.length / 9 }
667
- console.info(`[vp-prof] load ${f.name}: read ${(__tl1-__tl0).toFixed(0)}ms, parse ${(__tl2-__tl1).toFixed(0)}ms, scene ${(performance.now()-__tl2).toFixed(0)}ms`)
668
- } catch (err) { setError(`로드 실패 ${f.name}: ${(err && err.message) || err}`) }
669
- }
670
- setObjects(objectsRef.current.map(o => ({ id: o.id, name: o.name, extruder: o.extruder, visible: o.visible !== false })))
671
- if (totalTri > 100000) setTriWarn(`삼각형 ${Math.round(totalTri).toLocaleString()}개 — 슬라이스가 오래 걸릴 수 있습니다`)
672
- }
673
- function onFiles(e) { loadFiles(e.target.files); e.target.value = '' }
674
- function removeObject(id) { apiRef.current?.removeObject(id); setObjects(objectsRef.current.map(o => ({ id: o.id, name: o.name, extruder: o.extruder, visible: o.visible !== false }))) }
675
- // 26단계 R4: 뷰포트 전체 드롭존
676
- function onDrop(e) { e.preventDefault(); setDragOver(false); loadFiles(e.dataTransfer?.files) }
677
- function onDragOver(e) { e.preventDefault(); e.dataTransfer && (e.dataTransfer.dropEffect = 'copy'); if (!dragOver) setDragOver(true) }
678
- function onDragLeave(e) { if (!e.currentTarget.contains(e.relatedTarget)) setDragOver(false) }
679
-
680
- // ---- 20단계: 수동 서포트 페인팅 (enforcer/blocker) ----
681
- function rebuildPaintOverlay(enfArr, blkArr) {
682
- const t = three.current; if (!t.objectsGroup) return
683
- const X = paintXformRef.current || { cx:0, cy:0, minz:0 }
684
- const ov = paintOverlayRef.current
685
- if (ov) { for (const k of ['enf','blk']) if (ov[k]) { t.objectsGroup.remove(ov[k]); ov[k].geometry.dispose(); ov[k].material.dispose() } }
686
- const mk = (arr, color) => {
687
- if (!arr || arr.length < 9) return null
688
- const pos = new Float32Array(arr.length)
689
- for (let i=0;i<arr.length;i+=3){ const kx=arr[i],ky=arr[i+1],kz=arr[i+2]; // kernel -> STL -> viewer(Y-up)
690
- pos[i]=kx+X.cx; pos[i+1]=kz+X.minz; pos[i+2]=-(ky+X.cy) }
691
- const g = new THREE.BufferGeometry(); g.setAttribute('position', new THREE.Float32BufferAttribute(pos,3)); g.computeVertexNormals()
692
- const m = new THREE.Mesh(g, new THREE.MeshBasicMaterial({ color, transparent:true, opacity:0.55, side:THREE.DoubleSide, depthTest:false }))
693
- m.renderOrder = 999; t.objectsGroup.add(m); return m
694
- }
695
- paintOverlayRef.current = { enf: mk(enfArr, 0x2b6cff), blk: mk(blkArr, 0xe23b3b) } // enforcer=파랑, blocker=빨강
696
- three.current.invalidate?.() // worker 메시지 경로(React 재렌더 없이 씬 변경)
697
- }
698
- function clearPaintOverlay() {
699
- const t = three.current, ov = paintOverlayRef.current
700
- if (ov && t.objectsGroup) for (const k of ['enf','blk']) if (ov[k]) { t.objectsGroup.remove(ov[k]); ov[k].geometry.dispose(); ov[k].material.dispose() }
701
- paintOverlayRef.current = null
702
- }
703
- function setPaintMode(mode) {
704
- if (mode !== 'off' && objectsRef.current.length === 0) { setError('먼저 STL 을 업로드하세요'); return }
705
- if (mode !== 'off') {
706
- const merged = apiRef.current?.buildMergedSTL(selectedPlateRef.current); if (!merged) return
707
- // 29단계: 병합 STL 이 중심화(Cmx,Cmy 뺌)됨 → 페인트 레이캐스트(월드)를 같은 만큼 빼야 셀렉터와 정합. Cmx=offX, Cmy=-offZ.
708
- paintXformRef.current = { cx: merged.offX, cy: -merged.offZ, minz: 0 }
709
- apiRef.current?.detachTransform()
710
- getWorker().postMessage({ cmd: 'prepare', stl: merged.buf })
711
- }
712
- paintModeRef.current = mode; setPaintModeState(mode)
713
- apiRef.current?.refreshCursor() // 페인트 진입/해제 시 커서 힌트 갱신
714
- }
715
- function clearPaint() { getWorker().postMessage({ cmd: 'clear' }); clearPaintOverlay(); setPaintCounts({ enf:0, blk:0 }) }
716
-
717
- // ---- 슬라이스 (우측 패널 설정값 유도) — 29단계-2 플레이트별 + 30단계 스트리밍/워치독/OOM 사다리 ----
718
- const WATCHDOG_MS = 60000 // 30단계 행 워치독: 진행(progress/layer) 무소식 60초 → 죽음 판정
719
- const SILENT_STAGE_MS = 300000 // 표면·서포트 창(진행률 50% 부근): 커널이 구조적으로 무보고 — 대형 모델은 수 분 정상. 오탐 방지용 완화 한도.
720
- function sliceOne(buf, paramsStr) {
721
- return new Promise((resolve, reject) => {
722
- // dev/test 훅(프로덕션 미설정): __vpFail(n)=강제 실패(사다리 검증) · __vpStallNext=워커 무응답(워치독) · __vpWatchdogOnce=짧은 워치독(ms).
723
- if (typeof window !== 'undefined' && window.__vpFail && window.__vpFail(window.__vpSliceN = (window.__vpSliceN || 0) + 1)) { reject(new Error('forced failure (test hook)')); return }
724
- const stall = (typeof window !== 'undefined' && window.__vpStallNext) ? (window.__vpStallNext = false, true) : false
725
- const wdMs = (typeof window !== 'undefined' && window.__vpWatchdogOnce) ? ((v) => (window.__vpWatchdogOnce = 0, v))(window.__vpWatchdogOnce) : WATCHDOG_MS
726
- streamAccumRef.current = { layers: [], gcode: [] } // 스트리밍 누적 초기화(레이어별 수신)
727
- let t = 0
728
- let lastD = 0, lastT = 0
729
- const __ts = performance.now(); let __stage = '' // [vp-prof] 스테이지 도달 시각(임시)
730
- const note = (done, total) => {
731
- lastD = done; lastT = total
732
- const N = total > 2 ? (total - 2) / 2 : 0
733
- const st = !N ? '' : done < N ? 'PASS1…' : done === N ? 'PASS1-done' : done === N + 1 ? 'surf-done' : done === N + 2 ? 'support-done' : done === total ? 'emit-done' : 'emit…'
734
- if (st && st !== __stage) { console.info(`[vp-prof] ${st} +${((performance.now() - __ts) / 1000).toFixed(1)}s`); __stage = st }
735
- }
736
- // 마지막 진행이 표면·서포트 창(d ∈ [N, N+2), N=(t−2)/2)이면 완화 한도 — 이 구간은 무보고가 정상이라
737
- // 60초 워치독이 건강한 슬라이스를 죽였다(실측: 774k tri 서포트 19s+, 저사양·부하 시 60s 초과).
738
- const curWd = () => {
739
- const N = lastT > 2 ? (lastT - 2) / 2 : 0
740
- return (N && lastD >= N && lastD < N + 2) ? SILENT_STAGE_MS : wdMs
741
- }
742
- const stop = () => { if (t) { clearTimeout(t); t = 0 } }
743
- const kick = () => { stop(); const ms = curWd(); t = setTimeout(() => {
744
- pendingSliceRef.current = null
745
- stopSupPoll(); supSabRef.current = null // 워커 종료 → SAB 폴링/참조 해제
746
- try { workerRef.current?.terminate() } catch {} ; workerRef.current = null // 멎은 워커 강제 종료
747
- reject(new Error(`watchdog: ${ms}ms 무진행 — 메모리 압박으로 판단`))
748
- }, ms) }
749
- pendingSliceRef.current = { resolve, reject, kick, stop, note }
750
- kick()
751
- getWorker().postMessage({ stl: buf, params: paramsStr, stall })
752
- })
753
- }
754
- function recreateWorker() { try { workerRef.current?.terminate() } catch {} ; workerRef.current = null; getWorker() }
755
- // OOM 재시도 사다리: ① 정상(스트리밍) → 실패(오류/abort/워치독) 시 ② 워커 재생성 + 절약 모드(툴패스·시간
756
- // 추정 생략, g-code 만)로 완주. 둘 다 실패면 throw → 호출부가 다운그레이드(간소화) 제안. 부분 g-code 는 주지 않음.
757
- async function sliceLadder(buf, params) {
758
- try { const r = await sliceOne(buf, JSON.stringify(params)); return { r, economy: !!(r.stats && r.stats.economy) } }
759
- catch (e1) {
760
- recreateWorker()
761
- const r = await sliceOne(buf, JSON.stringify({ ...params, economy: true })) // 실패 시 throw 전파
762
- return { r, economy: true, recovered: true }
763
- }
764
- }
765
- function buildParams(merged) {
766
- const params = deriveKernelParams(settings)
767
- if (merged.extruders >= 2 && merged.split > 0) { params.extruder_count = merged.extruders; params.mm_group_split = merged.split; params.wipe_tower_real = wipeTowerReal }
768
- if (downgradeRef.current) { params.sparse_infill_pattern = 'rectilinear'; params.infill_density = Math.min(params.infill_density ?? 0.15, 0.08); params.economy = true } // 다운그레이드 재시도
769
- if (typeof window !== 'undefined' && window.__vpForceTree) { params.enable_support = true; params.support_style = 'tree'; params.support_threshold_angle = 40 } // 31단계 테스트 훅: 트리 서포트 강제(프로덕션 미설정)
770
- return params
771
- }
772
- // 캐시된 결과를 Preview 로 표시 — 캐시된 모든 플레이트의 툴패스를 각자 오프셋으로 동시 렌더하고,
773
- // idx 는 포커스(슬라이더/stats/G-code 대상)가 된다. 캐시 없는 플레이트 포커스 = 빈 상태(잔류 제거).
774
- function showPlateResult(idx) {
775
- ensurePlateToolpaths() // 전 플레이트 동시 조회
776
- const prev = toolpathRef.current
777
- if (prev) prev.setLayerRange(0, 1e9) // 이전 포커스는 전체 범위로 복귀
778
- const r = plateResultsRef.current[idx]
779
- if (!r || r.error || !r.layers || !r.layers.length) { // 결과 없는 플레이트: 포커스 UI 비움
780
- layersDataRef.current = null; toolpathRef.current = null; segDataRef.current = null
781
- setStats(null); setOverBed(false); setLayerCount(0); setSegCount(0); setColorRange(null); setRoleLegend([])
782
- setGcodeUrl(prevUrl => { if (prevUrl) URL.revokeObjectURL(prevUrl); return '' })
783
- return
784
- }
785
- layersDataRef.current = r.layers
786
- const n = r.layers.length
787
- layerLoRef.current = 0; layerHiRef.current = n - 1; setLayerLo(0); setLayerHi(n - 1)
788
- const cached = plateTpRef.current[idx]
789
- const entry = (cached && cached.layers === r.layers) ? cached : buildPlateToolpath(idx, r.layers)
790
- if (entry) {
791
- toolpathRef.current = entry.ctl; segDataRef.current = entry.seg
792
- entry.ctl.setLayerRange(0, n - 1)
793
- applyViewColors()
794
- setSegCount(entry.seg.nSeg); setRoleLegend(roleRatios(entry.seg.typeLengths))
795
- }
796
- apiRef.current?.onSliced()
797
- setCanvasMode('preview')
798
- setStats({ layers: r.stats.layers, segments: r.stats.path_segments, filament: r.stats.filament_mm, timeSec: r.stats.time_estimate })
799
- setOverBed(!!r.stats.over_bed); setLayerCount(n)
800
- setGcodeUrl(prevUrl => { if (prevUrl) URL.revokeObjectURL(prevUrl); return URL.createObjectURL(new Blob([r.gcode], { type: 'text/plain' })) })
801
- }
802
- function downloadGcode(gcode, name) { const url = URL.createObjectURL(new Blob([gcode], { type: 'text/plain' })); const a = document.createElement('a'); a.href = url; a.download = name; a.style.display = 'none'; document.body.appendChild(a); a.click(); setTimeout(() => { a.remove(); URL.revokeObjectURL(url) }, 4000) }
803
- const _sleep = (ms) => new Promise(r => setTimeout(r, ms))
804
- // plateResultsRef 는 ref 라 UI 가 자동 갱신되지 않는다 — 변경 지점마다 개수를 state 로 동기화.
805
- function refreshSlicedCount() {
806
- setSlicedPlateCount(Object.values(plateResultsRef.current).filter(r => r && !r.error && r.gcode).length)
807
- }
808
- // 슬라이스된 전 플레이트 G-code 를 한 번에 저장 — 사용자가 명시적으로 요청했을 때만(자동 저장 아님).
809
- // 브라우저가 연속 다운로드를 쓰로틀링하므로 파일 사이에 간격을 둔다.
810
- async function exportAllGcode() {
811
- setSliceMenu(false)
812
- const done = Object.entries(plateResultsRef.current)
813
- .filter(([, r]) => r && !r.error && r.gcode)
814
- .sort((a, b) => Number(a[0]) - Number(b[0]))
815
- if (!done.length) { setError('내보낼 슬라이스 결과가 없습니다 — 먼저 슬라이스하세요'); return }
816
- for (const [i, r] of done) { downloadGcode(r.gcode, `plate_${Number(i) + 1}.gcode`); await _sleep(350) }
817
- setSliceNotice(`${done.length}개 플레이트 G-code 를 내보냈습니다`)
818
- }
819
- async function onSlice(scope = 'current') {
820
- setSliceMenu(false); setError(''); setSliceNotice(''); setDowngradeOffer(null)
821
- const idx0 = selectedPlateRef.current
822
- lineWidthRef.current = deriveKernelParams(settings).line_width
823
- if (scope === 'all') {
824
- setSlicing(true); setProgress(0)
825
- let sliced = 0, anyEconomy = false; const failed = []
826
- for (let i = 0; i < plateCountRef.current; i++) {
827
- const merged = apiRef.current?.buildMergedSTL(i); if (!merged) continue
828
- plateOffsetsRef.current[i] = { offX: merged.offX, offZ: merged.offZ }
829
- try {
830
- const { r, economy } = await sliceLadder(merged.buf, buildParams(merged)) // 정상 실패 시 절약 재시도
831
- plateResultsRef.current[i] = r; refreshSlicedCount(); sliced++ // 자동 다운로드 없음 — 탭 전환으로 조회, 저장은 명시적 내보내기로
832
- if (economy) anyEconomy = true
833
- } catch (e) { failed.push(i + 1) } // E1: 실패해도 이미 완주한 플레이트 g-code 는 보존/제공됨
834
- }
835
- setSlicing(false)
836
- if (!sliced) { setDowngradeOffer({ scope: 'all' }); setError('모든 플레이트 슬라이스 실패(절약 모드 포함) — 간소화 재시도를 시도하세요'); return }
837
- if (failed.length) setError(`플레이트 ${failed.join(', ')} 실패 — 완주한 ${sliced}개 결과는 유지됨(탭에서 조회·내보내기)`)
838
- if (anyEconomy) setSliceNotice('메모리 압박 — 일부 플레이트를 절약 모드로 완주(프리뷰 없음, G-code 정상)')
839
- showPlateResult(plateResultsRef.current[idx0] ? idx0 : Object.keys(plateResultsRef.current).map(Number)[0])
840
- } else {
841
- const __tm0 = performance.now() // [vp-prof] 전처리 계측(임시)
842
- const merged = apiRef.current?.buildMergedSTL(idx0)
843
- if (!merged) { setError(`플레이트 ${idx0 + 1} 에 오브젝트가 없습니다`); return }
844
- console.info(`[vp-prof] buildMergedSTL ${(performance.now() - __tm0).toFixed(0)}ms (${(merged.buf.byteLength / 1048576).toFixed(1)}MB)`)
845
- plateOffsetsRef.current[idx0] = { offX: merged.offX, offZ: merged.offZ }
846
- setSlicing(true); setProgress(0)
847
- try {
848
- const { r, economy } = await sliceLadder(merged.buf, buildParams(merged))
849
- if (r?.stats) console.info(`[vp-prof] kernel stages p1=${(r.stats.t_pass1_ms/1000).toFixed(1)}s surf=${(r.stats.t_surface_ms/1000).toFixed(1)}s sup=${(r.stats.t_support_ms/1000).toFixed(1)}s emit=${(r.stats.t_emit_ms/1000).toFixed(1)}s`)
850
- plateResultsRef.current[idx0] = r; refreshSlicedCount(); setSlicing(false); showPlateResult(idx0)
851
- if (economy) setSliceNotice('메모리 압박 — 절약 모드로 완주(프리뷰 없음, G-code 는 다운로드 가능)')
852
- } catch (e) { setSlicing(false); setDowngradeOffer({ scope: 'current' }); setError('슬라이스 실패(절약 모드도 실패): ' + e.message) }
853
- }
854
- }
855
- // 다운그레이드 재시도: 인필 패턴 단순화(rectilinear)+밀도↓+절약 모드로 다시(사용자 선택). buildParams 가 반영.
856
- async function retryDowngrade() {
857
- const off = downgradeOffer; setDowngradeOffer(null); if (!off) return
858
- downgradeRef.current = true
859
- try { await onSlice(off.scope) } finally { downgradeRef.current = false }
860
- }
861
- // 플레이트 추가/삭제/선택
862
- function addPlate() { setPlateCount(n => Math.min(MAX_PLATES, n + 1)) }
863
- function deletePlate() {
864
- setPlateCount(n => { if (n <= 1) return n; const last = n - 1; delete plateResultsRef.current[last]; disposePlateToolpath(last); refreshSlicedCount(); if (selectedPlateRef.current >= last) selectPlate(last - 1); return last })
865
- }
866
- function selectPlate(i) {
867
- selectedPlateRef.current = i; setSelectedPlate(i); placeXRef.current = 0
868
- if (canvasMode === 'preview') showPlateResult(i) // Preview 에서 플레이트 전환 → 캐시 결과로 스위치
869
- }
870
- // 프린터 카드의 베드 폭×깊이 편집 — printable_area 사각형으로 환원(원점 보존). 원형/커스텀은 패널 에디터 담당.
871
- function setBedSize(w, d) {
872
- if (!(w > 0) || !(d > 0)) return
873
- const pa = settingRaw(settings, 'printable_area')
874
- const ok = Array.isArray(pa) && pa.length >= 3
875
- const x0 = ok ? Math.min(...pa.map(p => p[0])) : 0, y0 = ok ? Math.min(...pa.map(p => p[1])) : 0
876
- setSettings(s => ({ ...s, printable_area: [[x0, y0], [x0 + w, y0], [x0 + w, y0 + d], [x0, y0 + d]] }))
877
- }
878
- function setObjExtruder(id, e) { apiRef.current?.setObjectExtruder(id, e); setObjects(objectsRef.current.map(o => ({ id: o.id, name: o.name, extruder: o.extruder, visible: o.visible !== false }))) }
879
-
880
- // 25단계 S6: 이중 슬라이더(lo/hi) — 단일 레이어 모드면 두 썸이 함께 이동.
881
- function setRange(lo, hi) {
882
- const max = Math.max(0, layerCount - 1)
883
- lo = Math.max(0, Math.min(max, lo)); hi = Math.max(0, Math.min(max, hi))
884
- if (lo > hi) { const t = lo; lo = hi; hi = t }
885
- layerLoRef.current = lo; layerHiRef.current = hi; setLayerLo(lo); setLayerHi(hi); applyLayerRange()
886
- }
887
- function onLo(e) { const v = parseInt(e.target.value, 10); if (singleLayer) setRange(v, v); else setRange(v, layerHiRef.current) }
888
- function onHi(e) { const v = parseInt(e.target.value, 10); if (singleLayer) setRange(v, v); else setRange(layerLoRef.current, v) }
889
- function toggleSingle() {
890
- const next = !singleLayer; setSingleLayer(next)
891
- if (next) setRange(layerHiRef.current, layerHiRef.current) // 단일 레이어 = 상한 레이어만
892
- }
893
- function onViewType(e) { const v = e.target.value; setViewType(v); viewTypeRef.current = v; applyViewColors() }
894
- function onToggleTravel(e) { const v = e.target.checked; setShowTravel(v); showTravelRef.current = v; for (const p of Object.values(plateTpRef.current)) p.ctl.setTravelVisible(v) }
895
- function onToggleSupport(e) { const v = e.target.checked; setSettings(s => ({ ...s, enable_support: v })) }
896
- const supportOn = !!settingRaw(settings, 'enable_support')
897
- // 27단계 S4: 필라멘트 색/개수 + 오브젝트 출력토글 + 페인팅 기즈모 모드
898
- function refreshObjects() { setObjects(objectsRef.current.map(o => ({ id: o.id, name: o.name, extruder: o.extruder, visible: o.visible !== false }))) }
899
- function setExtColor(i, hex) { setExtruderColors(cs => { const n = [...cs]; n[i] = hex; extruderColorsRef.current = n; apiRef.current?.recolorObjects(); return n }) }
900
- function addFilament() { setExtruderColors(cs => { if (cs.length >= 4) return cs; const pal = ['#e0473b', '#3bb0e0', '#7ad14a']; const n = [...cs, pal[cs.length - 1] || '#888888']; extruderColorsRef.current = n; return n }) }
901
- function removeFilament() {
902
- setExtruderColors(cs => {
903
- if (cs.length <= 1) return cs
904
- const n = cs.slice(0, -1); extruderColorsRef.current = n
905
- objectsRef.current.forEach(o => { if ((o.extruder || 1) > n.length) apiRef.current?.setObjectExtruder(o.id, n.length) })
906
- apiRef.current?.recolorObjects(); refreshObjects(); return n
907
- })
908
- }
909
- function toggleObjVisible(id) { const o = objectsRef.current.find(x => x.id === id); apiRef.current?.setObjectVisible(id, !(o?.visible !== false)); refreshObjects() }
910
- function togglePaintGizmo() { setPaintMode(paintMode === 'off' ? 'enforcer' : 'off') }
911
-
912
- // ---- 오브젝트 툴바 정의 ----
913
- // 원본 툴바(GLToolbar) 구성을 따른다. run 이 없으면 비활성으로 렌더되며, 툴팁에 무엇이고 왜 안 되는지 적는다.
914
- // 버튼을 늘릴 때 JSX 를 복제하지 말고 이 배열만 고칠 것.
915
- const OBJECT_TOOLS = [
916
- { id: 'add', icon: addIcon, label: '추가', tip: '모델 파일을 현재 플레이트에 추가 (기존 오브젝트 유지)', run: () => fileInputRef.current?.click() },
917
- { id: 'delete', icon: deleteIcon, label: '삭제', tip: '선택한 오브젝트 삭제 (Del)', run: deleteSelected },
918
- { id: 'delete-all', icon: deleteallIcon, label: '전체 삭제', tip: '플레이트의 모든 오브젝트 삭제', run: deleteAllObjects, disabled: () => objects.length === 0 },
919
- { sep: true },
920
- { id: 'duplicate', icon: duplicateIcon, label: '복제', tip: '선택 오브젝트 복제 (Ctrl+K)', run: duplicateSelected },
921
- { id: 'split', icon: splitIcon, label: '분리', tip: '객체로 분리 — 서로 떨어진 부분(연결 성분)마다 독립 오브젝트로 나눔. 파트로 분리는 미구현(파트 개념 부재)', run: splitSelected },
922
- { id: 'onbed', icon: onbedIcon, label: '바닥에 놓기', tip: '모든 오브젝트를 베드 바닥(Z=0)에 붙임 — 기즈모로 띄운 뒤 재안착용', run: () => apiRef.current?.placeOnBed() },
923
- { sep: true },
924
- { id: 'arrange', icon: arrangeIcon, label: '배치', tip: '자동 배치 — 오브젝트를 베드에 겹치지 않게 정렬. 미구현(libslic3r Arrange 이식 필요)' },
925
- { id: 'orient', icon: orientIcon, label: '방향', tip: '자동 방향 — 서포트가 덜 필요한 방향으로 회전. 미구현(libslic3r Orient 이식 필요)' },
926
- { sep: true },
927
- { id: 'cut', icon: cutIcon, label: '컷', tip: '컷 — 평면으로 모델을 잘라 두 조각으로 분리. 미구현(절단면 재봉합 필요)' },
928
- { id: 'boolean', icon: booleanIcon, label: '불리언', tip: '메시 불리언 — 두 오브젝트의 합/차/교집합. 미구현(CGAL 불리언 이식 필요)' },
929
- { id: 'negative', icon: negativeIcon, label: '음각 파트', tip: '음각·모디파이어 파트 추가 — 한 오브젝트 안에 성질이 다른 파트를 넣음. 미구현(파트 개념 부재)' },
930
- { sep: true },
931
- { id: 'seam', icon: seamIcon, label: '심 페인팅', tip: '심 페인팅 — 레이어 이음매 위치를 브러시로 지정. 미구현(커널 심 배선 필요)' },
932
- { id: 'mmu', icon: mmuIcon, label: '컬러 페인팅', tip: '컬러 페인팅 — 멀티머티리얼 색을 면 단위로 지정. 미구현(MMU 페인트 코덱 배선 필요)' },
933
- { id: 'text', icon: textmarkIcon, label: '텍스트', tip: '텍스트·SVG 각인 — 모델 표면에 문자/도형을 새김. 미구현(폰트 래스터화 필요)' },
934
- { id: 'measure', icon: measureIcon, label: '측정', tip: '측정 — 두 지점/면 사이 거리·각도 측정. 미구현' },
935
- { id: 'varlayer', icon: varlayerIcon, label: '가변 레이어', tip: '가변 레이어 높이 — 구간별로 레이어 높이를 다르게. 미구현(커널 가변 z 미지원)' },
936
- ]
937
-
938
- // ---- 단축키 (SPECS §4 원본 + PrusaSlicer/Cura 관례) ----
939
- // Prepare/Preview 로 유효 키가 갈린다. 입력 위젯 포커스 시 전부 무시.
940
- // 33단계: 선택이 없을 때 조용히 무시하지 않고 이유를 알린다(툴바 버튼에서 눌러도 아무 일이 없어 보이던 문제).
941
- function duplicateSelected() {
942
- const id = apiRef.current?.selectedObjectId()
943
- if (!id) { setError('먼저 복제할 오브젝트를 선택하세요'); return }
944
- const snap = apiRef.current?.getSnapshot(id)
945
- if (snap) { apiRef.current?.spawnSnapshot(snap); refreshObjects(); setError('') }
946
- }
947
- function copySelected() {
948
- const id = apiRef.current?.selectedObjectId()
949
- if (!id) { setError('먼저 복사할 오브젝트를 선택하세요'); return }
950
- clipboardRef.current = apiRef.current?.getSnapshot(id); setError('')
951
- setSliceNotice('오브젝트를 복사했습니다 (Ctrl+V 로 붙여넣기)')
952
- }
953
- function pasteClipboard() { if (clipboardRef.current) { apiRef.current?.spawnSnapshot(clipboardRef.current); refreshObjects() } }
954
- function deleteSelected() {
955
- const id = apiRef.current?.selectedObjectId()
956
- if (!id) { setError('먼저 삭제할 오브젝트를 선택하세요'); return }
957
- removeObject(id); setError('')
958
- }
959
- // 33단계: 전체 삭제 (원본 Ctrl+D / Delete all). 씬의 모든 오브젝트를 비운다.
960
- function deleteAllObjects() {
961
- const ids = objectsRef.current.map(o => o.id)
962
- if (!ids.length) return
963
- for (const id of ids) apiRef.current?.removeObject(id)
964
- refreshObjects()
965
- setSliceNotice(`${ids.length}개 오브젝트를 모두 삭제했습니다`)
966
- }
967
- // 33단계: 객체 분리 (원본 Split to objects). 연결 성분마다 독립 오브젝트로 만든다.
968
- // 각 성분은 원본 좌표를 유지하므로, spawnMesh 의 배치 커서가 제대로 놓도록 bakeLocal 과 같은 규칙
969
- // (XZ 중심·minY=0)으로 재정렬한 뒤 등록한다.
970
- function splitSelected() {
971
- const id = apiRef.current?.selectedObjectId()
972
- if (!id) { setError('먼저 분리할 오브젝트를 선택하세요'); return }
973
- const snap = apiRef.current?.getSnapshot(id); if (!snap) return
974
- let parts
975
- try { parts = splitConnectedComponents(snap.localPos) }
976
- catch (e) { setError('분리 실패: ' + (e?.message || e)); return }
977
- if (!parts || parts.length < 2) { setError('분리할 독립 부분이 없습니다 — 하나로 연결된 메시입니다'); return }
978
- // 각 성분의 좌표는 부모의 로컬 좌표계 그대로다. 부모의 위치/회전/스케일을 그대로 물려주면
979
- // 분리 후에도 화면상 위치가 변하지 않는다(원본 Split 과 동일 — 부품이 제자리에 남는다).
980
- // 재정렬해서 배치 커서로 늘어놓으면 21개가 일렬이 되어 베드를 벗어난다(실측).
981
- const base = String(snap.name || 'object').replace(/\.[^.]+$/, '')
982
- removeObject(id)
983
- parts.forEach((p, i) => apiRef.current?.spawnSnapshot(
984
- { name: `${base}_${i + 1}`, localPos: p, rot: snap.rot, scale: snap.scale, pos: snap.pos }, true))
985
- refreshObjects()
986
- setError('')
987
- setSliceNotice(`${parts.length}개 오브젝트로 분리했습니다`)
988
- }
989
- function setGizmo(m) { if (paintModeRef.current !== 'off') setPaintMode('off'); apiRef.current?.setMode(m) } // 페인트 해제 선행(툴바와 동일 경로)
990
-
991
- keyRef.current = (e) => {
992
- // Shadow DOM: window 레벨에서는 e.target 이 shadow host 로 리타게팅됨 → composedPath 로 실제 타깃 확인
993
- const t = e.composedPath ? e.composedPath()[0] : e.target
994
- if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.tagName === 'SELECT' || t.isContentEditable)) return
995
- const k = e.key, low = typeof k === 'string' ? k.toLowerCase() : ''
996
- const mod = e.ctrlKey || e.metaKey
997
- const preview = canvasModeRef.current === 'preview'
998
- const stop = () => { e.preventDefault(); e.stopPropagation() }
999
-
1000
- if (mod) { // ---- Ctrl/⌘ 조합 (모드 공통) ----
1001
- if (low === 'r') { stop(); if (!slicing) onSlice('current') } // 슬라이스(원본 Ctrl+R)
1002
- else if (low === 'c') { stop(); copySelected() }
1003
- else if (low === 'v') { stop(); pasteClipboard() }
1004
- else if (low === 'x') { stop(); copySelected(); deleteSelected() }
1005
- else if (low === 'k' || low === 'd') { stop(); duplicateSelected() } // Ctrl+K(원본) / ⌘D(macOS 관례)
1006
- return
1007
- }
1008
-
1009
- if (preview) { // ---- Preview: 레이어 검사 ----
1010
- const step = e.shiftKey ? 10 : 1
1011
- if (k === 'ArrowUp') { stop(); const v = layerHiRef.current + step; singleLayer ? setRange(v, v) : setRange(layerLoRef.current, v) }
1012
- else if (k === 'ArrowDown') { stop(); const v = layerHiRef.current - step; singleLayer ? setRange(v, v) : setRange(layerLoRef.current, v) }
1013
- else if (low === 'l') { stop(); toggleSingle() }
1014
- else if (low === 't') { stop(); onToggleTravel({ target: { checked: !showTravelRef.current } }) }
1015
- else if (low === 'z') { stop(); apiRef.current?.frame() }
1016
- else if (low === 'b') { stop(); apiRef.current?.frameBed() }
1017
- else if (k === 'Escape') { stop(); setCanvasMode('prepare') }
1018
- return
1019
- }
1020
-
1021
- // ---- Prepare: 오브젝트 조작 ----
1022
- if (low === 'm' || low === 'g') { stop(); setGizmo('translate') } // M=원본, G=Blender 관례(유지)
1023
- else if (low === 'r') { stop(); setGizmo('rotate') }
1024
- else if (low === 's') { stop(); setGizmo('scale') }
1025
- else if (low === 'z') { stop(); apiRef.current?.frame() }
1026
- else if (low === 'b') { stop(); apiRef.current?.frameBed() }
1027
- else if (k === 'Delete' || k === 'Backspace') { stop(); deleteSelected() }
1028
- else if (k === 'Escape') { stop(); if (paintModeRef.current !== 'off') setPaintMode('off'); apiRef.current?.detachTransform() }
1029
- else if (k === 'PageUp') { stop(); apiRef.current?.rotateSelectedY(Math.PI / 4) }
1030
- else if (k === 'PageDown') { stop(); apiRef.current?.rotateSelectedY(-Math.PI / 4) }
1031
- else if (k.startsWith?.('Arrow')) { // 넛지: 10mm, Shift=1mm (Prusa 관례)
1032
- stop(); const d = e.shiftKey ? 1 : 10
1033
- apiRef.current?.nudgeSelected(k === 'ArrowLeft' ? -d : k === 'ArrowRight' ? d : 0,
1034
- k === 'ArrowUp' ? -d : k === 'ArrowDown' ? d : 0)
1035
- }
1036
- else if (k === '?') { stop(); setShowHelp(v => !v) }
1037
- }
1038
- const nozzleDia = kp.nozzle_diameter || settingRaw(settings, 'nozzle_diameter') || '0.4'
1039
- three.current.invalidate?.() // 렌더 온 디맨드: React 재렌더(슬라이더·토글·상태 변화)마다 1프레임 무효화
1040
-
1041
- // Preview 컨트롤(뷰 타입 + 이중 슬라이더 + 범례) — 사이드바에 배치
1042
- const previewControls = layerCount > 0 && (
1043
- <div className="slice-layer" data-testid="preview-controls">
1044
- <label className="view-type-row">뷰 타입
1045
- <select value={viewType} onChange={onViewType} data-testid="view-type-select" title="툴패스 색을 무엇으로 칠할지 — 피처 종류·속도·레이어 높이·폭·팬·온도">
1046
- {VIEW_TYPES.map(v => <option key={v.key} value={v.key}>{v.label}</option>)}
1047
- </select>
1048
- </label>
1049
- <label>레이어 <b data-testid="layer-range">{singleLayer ? (layerHi + 1) : `${layerLo + 1}..${layerHi + 1}`}</b> / {layerCount}
1050
- <span className="muted"> ({segCount.toLocaleString()} 세그먼트)</span></label>
1051
- <div className="dual-slider">
1052
- <input type="range" min="0" max={Math.max(0, layerCount - 1)} value={layerLo} onChange={onLo} data-testid="layer-lo" title="표시할 최하단 레이어 (↓/↑ 로도 조절)" />
1053
- <input type="range" min="0" max={Math.max(0, layerCount - 1)} value={layerHi} onChange={onHi} data-testid="layer-hi" title="표시할 최상단 레이어 (↓/↑, Shift 로 10단계)" />
1054
- </div>
1055
- <div className="layer-ctl">
1056
- <button className={singleLayer ? 'on' : ''} onClick={toggleSingle} data-testid="single-layer-btn" title="선택한 한 층만 보기 (L)">단일 레이어</button>
1057
- <label className="slice-travel"><input type="checkbox" checked={showTravel} onChange={onToggleTravel} data-testid="travel-toggle" title="압출 없이 이동하는 경로를 회색 선으로 표시 (T)" /> 트래블</label>
1058
- </div>
1059
- {colorRange && colorRange.cont ? (
1060
- <div className="grad-legend" data-testid="view-legend">
1061
- <div className="grad-title">{colorRange.label} <span className="muted">{colorRange.unit}</span></div>
1062
- <div className="grad-bar" data-testid="gradient-bar" style={{ background: `linear-gradient(to right, ${DEFAULT_RANGES_COLORS.map(c => `rgb(${Math.round(c[0] * 255)},${Math.round(c[1] * 255)},${Math.round(c[2] * 255)})`).join(',')})` }} />
1063
- <div className="grad-minmax"><span data-testid="grad-min">{colorRange.min.toFixed(colorRange.unit === 'mm' ? 2 : 0)}</span><span data-testid="grad-max">{colorRange.max.toFixed(colorRange.unit === 'mm' ? 2 : 0)}</span></div>
1064
- </div>
1065
- ) : (
1066
- <div className="role-legend" data-testid="view-legend">
1067
- {roleLegend.map(r => (
1068
- <span key={r.type} className="role-item">
1069
- <i style={{ background: `rgb(${Math.round(r.color[0] * 255)},${Math.round(r.color[1] * 255)},${Math.round(r.color[2] * 255)})` }} />
1070
- {r.label} <b>{r.pct.toFixed(0)}%</b>
1071
- </span>
1072
- ))}
1073
- </div>
1074
- )}
1075
- </div>
1076
- )
1077
- const statsBlock = stats && (
1078
- <>
1079
- <div><b>{stats.layers}</b> 레이어 · <b>{stats.segments}</b> 세그먼트</div>
1080
- <div>필라멘트 <b>{stats.filament.toFixed(1)}</b> mm</div>
1081
- {typeof stats.timeSec === 'number' && stats.timeSec > 0 && (() => {
1082
- const s = Math.round(stats.timeSec)
1083
- const t = `${Math.floor(s / 3600)}h ${String(Math.floor((s % 3600) / 60)).padStart(2, '0')}m ${String(s % 60).padStart(2, '0')}s`
1084
- return <div data-testid="print-time">⏱ 예상 출력 <b>{t}</b></div>
1085
- })()}
1086
- {overBed && <div className="slice-warn" data-testid="over-bed">⚠ 모델이 베드를 벗어남</div>}
1087
- </>
1088
- )
1089
-
1090
- return (
1091
- <ShadowHost css={shadowCss}>
1092
- <div className="app-shell">
1093
- {/* 공용 숨김 파일 입력 */}
1094
- <input ref={fileInputRef} type="file" accept=".stl,.obj,.3mf,.amf,.ply" multiple onChange={onFiles} title="STL·OBJ·3MF·AMF·PLY (여러 개 선택 가능)" data-testid="stl-input" style={{ display: 'none' }} />
1095
-
1096
- {/* S1 상단바 */}
1097
- <header className="topbar">
1098
- <div className="tb-left">
1099
- <span className="tb-logo"><b>Three</b>Slicer <span className="tb-re">RE</span></span>
1100
- <button className="tb-btn" onClick={() => fileInputRef.current?.click()} title="모델 파일 열기 — 기존 오브젝트는 모두 지워짐" data-testid="open-file"><img src={openIcon} alt="" /><span>열기</span></button>
1101
- </div>
1102
- {ok && (
1103
- <div className="tb-tabs" role="tablist" aria-label="캔버스 모드">
1104
- <button role="tab" className={canvasMode === 'prepare' ? 'on' : ''} onClick={() => setCanvasMode('prepare')} data-testid="mode-prepare" title="모델 배치·변형·서포트 페인팅">Prepare</button>
1105
- <button role="tab" className={canvasMode === 'preview' ? 'on' : ''} onClick={() => setCanvasMode('preview')} disabled={layerCount === 0} data-testid="mode-preview" title="슬라이스된 툴패스 미리보기 (슬라이스 후 활성화)">Preview</button>
1106
- </div>
1107
- )}
1108
- <div className="tb-right">
1109
- <button className="tb-icon" disabled title="실행 취소 — 미구현(작업 이력 스택 필요)">↶</button>
1110
- <button className="tb-icon" disabled title="다시 실행 — 미구현(작업 이력 스택 필요)">↷</button>
1111
- </div>
1112
- </header>
1113
-
1114
- <div className="app-body">
1115
- {/* S3 좌측 기즈모 툴바 */}
1116
- {ok && canvasMode === 'prepare' && (
1117
- <nav className="left-rail" role="toolbar" aria-label="기즈모 도구">
1118
- <button className={gmode === 'translate' && paintMode === 'off' ? 'on' : ''} onClick={() => { setPaintMode('off'); apiRef.current?.setMode('translate') }} title="오브젝트 이동 — 기즈모 축을 끌거나 방향키로 10mm(Shift 1mm) 이동 (M/G)" data-testid="gizmo-move"><img src={moveIcon} alt="이동" /></button>
1119
- <button className={gmode === 'rotate' && paintMode === 'off' ? 'on' : ''} onClick={() => { setPaintMode('off'); apiRef.current?.setMode('rotate') }} title="오브젝트 회전 — PageUp/PageDown 으로 45° 단위 회전 (R)" data-testid="gizmo-rotate"><img src={rotateIcon} alt="회전" /></button>
1120
- <button className={gmode === 'scale' && paintMode === 'off' ? 'on' : ''} onClick={() => { setPaintMode('off'); apiRef.current?.setMode('scale') }} title="오브젝트 크기 조절 (S)" data-testid="gizmo-scale"><img src={scaleIcon} alt="스케일" /></button>
1121
- <div className="rail-sep" />
1122
- <button className={paintMode !== 'off' ? 'on' : ''} onClick={togglePaintGizmo} title="브러시로 면을 칠해 서포트를 강제하거나 차단 — 휠로 브러시 크기 조절" data-testid="gizmo-paint"><img src={paintIcon} alt="서포트 페인팅" /></button>
1123
- </nav>
1124
- )}
1125
-
1126
- {/* 중앙 뷰포트 */}
1127
- <div className="viewport-col">
1128
- <div className={(ok ? 'vp-canvas' : 'vp-canvas fail') + (dragOver ? ' drag-over' : '')} ref={mountRef}
1129
- onDrop={onDrop} onDragOver={onDragOver} onDragLeave={onDragLeave} data-testid="drop-zone">
1130
- {!ok && <div className="vp-fallback">⚠ {status}</div>}
1131
- {ok && objects.length === 0 && canvasMode !== 'preview' && (
1132
- <div className="empty-hint" data-testid="empty-hint">
1133
- <div className="eh-icon">📦</div>
1134
- <div className="eh-title">파일을 드래그하거나 선택하세요</div>
1135
- <div className="eh-sub">STL · OBJ · 3MF · AMF · PLY</div>
1136
- <button className="eh-btn" onClick={() => fileInputRef.current?.click()} data-testid="empty-pick" title="STL·OBJ·3MF·AMF·PLY 파일 선택 (여러 개 가능)">파일 선택</button>
1137
- </div>
1138
- )}
1139
- {dragOver && <div className="drop-overlay" data-testid="drop-overlay">여기에 놓기 (STL/OBJ/3MF/AMF/PLY)</div>}
1140
- {ctxMenu && (
1141
- <>
1142
- <div className="ctx-scrim" onPointerDown={() => setCtxMenu(null)} onContextMenu={e => { e.preventDefault(); setCtxMenu(null) }} />
1143
- <div className="ctx-menu" style={{ left: ctxMenu.x, top: ctxMenu.y }} data-testid="ctx-menu">
1144
- {ctxMenu.onObject ? (<>
1145
- <button onClick={() => { setCtxMenu(null); duplicateSelected() }} data-testid="ctx-duplicate" title="선택 오브젝트를 같은 크기·회전으로 복제해 옆에 배치">복제 <span className="ctx-key">Ctrl+K</span></button>
1146
- <button onClick={() => { setCtxMenu(null); copySelected() }} data-testid="ctx-copy" title="선택 오브젝트를 버퍼에 복사 (붙여넣기로 추가)">복사 <span className="ctx-key">Ctrl+C</span></button>
1147
- <button onClick={() => { setCtxMenu(null); splitSelected() }} data-testid="ctx-split" title="객체로 분리 — 서로 떨어진 부분(연결 성분)마다 독립 오브젝트로 나눔. 파트로 분리는 미구현(파트 개념 부재)">분리</button>
1148
- <button onClick={() => { setCtxMenu(null); apiRef.current?.placeOnBed() }} data-testid="ctx-seat" title="모든 오브젝트를 베드 바닥(Z=0)에 붙임">바닥에 놓기</button>
1149
- <hr />
1150
- <button className="danger" onClick={() => { setCtxMenu(null); deleteSelected() }} data-testid="ctx-delete" title="선택 오브젝트를 씬에서 제거">삭제 <span className="ctx-key">Del</span></button>
1151
- </>) : (<>
1152
- <button onClick={() => { setCtxMenu(null); fileInputRef.current?.click() }} data-testid="ctx-open" title="모델 파일을 열어 현재 플레이트에 추가">모델 열기…</button>
1153
- <button disabled={!clipboardRef.current} onClick={() => { setCtxMenu(null); pasteClipboard() }} data-testid="ctx-paste" title="복사해 둔 오브젝트를 현재 플레이트에 추가">붙여넣기 <span className="ctx-key">Ctrl+V</span></button>
1154
- <hr />
1155
- <button onClick={() => { setCtxMenu(null); apiRef.current?.frame() }} data-testid="ctx-zoom-all" title="모든 오브젝트가 보이도록 카메라 맞춤">전체 보기 <span className="ctx-key">Z</span></button>
1156
- <button onClick={() => { setCtxMenu(null); apiRef.current?.frameBed() }} data-testid="ctx-zoom-bed" title="선택한 플레이트 전체가 보이도록 카메라 맞춤">베드 보기 <span className="ctx-key">B</span></button>
1157
- </>)}
1158
- </div>
1159
- </>
1160
- )}
1161
- {showHelp && (
1162
- <div className="help-overlay" data-testid="help-overlay" onClick={() => setShowHelp(false)}>
1163
- <div className="help-card" onClick={e => e.stopPropagation()}>
1164
- <h3>단축키 <span className="muted">(? 로 닫기)</span></h3>
1165
- <div className="help-cols">
1166
- <section>
1167
- <h4>Prepare</h4>
1168
- <dl>
1169
- <dt>M / G</dt><dd>이동</dd><dt>R</dt><dd>회전</dd><dt>S</dt><dd>스케일</dd>
1170
- <dt>방향키</dt><dd>10mm 이동 (Shift 1mm)</dd>
1171
- <dt>PageUp/Down</dt><dd>45° 회전</dd>
1172
- <dt>Del</dt><dd>선택 삭제</dd><dt>Esc</dt><dd>선택/페인트 해제</dd>
1173
- </dl>
1174
- </section>
1175
- <section>
1176
- <h4>Preview</h4>
1177
- <dl>
1178
- <dt>↑ / ↓</dt><dd>레이어 (Shift 10단계)</dd>
1179
- <dt>L</dt><dd>단일 레이어</dd><dt>T</dt><dd>트래블</dd>
1180
- <dt>Esc</dt><dd>Prepare 로</dd>
1181
- </dl>
1182
- </section>
1183
- <section>
1184
- <h4>공통</h4>
1185
- <dl>
1186
- <dt>Ctrl+R</dt><dd>슬라이스</dd>
1187
- <dt>Ctrl+K / ⌘D</dt><dd>복제</dd>
1188
- <dt>Ctrl+C/V/X</dt><dd>복사/붙여넣기/잘라내기</dd>
1189
- <dt>Z / B</dt><dd>전체 / 베드로 줌</dd>
1190
- </dl>
1191
- </section>
1192
- </div>
1193
- </div>
1194
- </div>
1195
- )}
1196
- </div>
1197
-
1198
- {/* 뷰포트 상단 오브젝트 툴바 */}
1199
- {ok && canvasMode === 'prepare' && (
1200
- <div className="vp-top-toolbar" role="toolbar" aria-label="오브젝트 도구">
1201
- {OBJECT_TOOLS.map((t, i) => t.sep
1202
- ? <span key={'sep' + i} className="vtt-sep" />
1203
- : <button key={t.id} onClick={t.run} disabled={t.disabled?.() ?? !t.run}
1204
- title={t.tip} data-testid={`tool-${t.id}`}><img src={t.icon} alt={t.label} /></button>)}
1205
- </div>
1206
- )}
1207
-
1208
- {/* 페인팅 플로팅 패널 */}
1209
- {ok && canvasMode === 'prepare' && paintMode !== 'off' && (
1210
- <div className="brush-panel" data-testid="paint-tools">
1211
- <div className="bp-title">서포트 페인팅</div>
1212
- <div className="bp-modes">
1213
- <button className={paintMode === 'enforcer' ? 'on enf' : 'enf'} onClick={() => setPaintMode('enforcer')} title="칠한 면 아래에 서포트를 강제로 생성" data-testid="paint-enforcer">enforcer</button>
1214
- <button className={paintMode === 'blocker' ? 'on blk' : 'blk'} onClick={() => setPaintMode('blocker')} title="칠한 면 아래에 서포트가 생기지 않게 차단" data-testid="paint-blocker">blocker</button>
1215
- <button onClick={clearPaint} data-testid="paint-clear" title="칠한 강제/차단 영역을 모두 지움">지우기</button>
1216
- <button onClick={() => setPaintMode('off')} data-testid="paint-off" title="페인팅 모드 종료 (Esc)">닫기</button>
1217
- </div>
1218
- <label className="bp-radius">브러시 반경 {brushRadius}mm
1219
- <input type="range" min="1" max="15" step="0.5" value={brushRadius}
1220
- onChange={e => { const v = parseFloat(e.target.value); setBrushRadius(v); brushRadiusRef.current = v }} data-testid="brush-radius" />
1221
- </label>
1222
- <div className="muted bp-counts" data-testid="paint-counts">enforcer {paintCounts.enf} · blocker {paintCounts.blk} · 모델 위 드래그로 페인트</div>
1223
- </div>
1224
- )}
1225
-
1226
- {/* Preview 좌하단 stats 카드 */}
1227
- {ok && canvasMode === 'preview' && stats && (
1228
- <div className="stats-card" data-testid="slice-stats">{statsBlock}</div>
1229
- )}
1230
-
1231
- {/* 29단계-2: 플레이트 탭바(선택·이름표 + 추가/삭제) */}
1232
- {ok && (
1233
- <div className="plate-bar" data-testid="plate-bar" role="tablist" aria-label="플레이트">
1234
- {Array.from({ length: plateCount }, (_, i) => (
1235
- <button key={i} role="tab" className={'plate-tab' + (i === selectedPlate ? ' on' : '')} onClick={() => selectPlate(i)} title={`플레이트 ${i + 1} 선택 — Preview 에선 이 플레이트 결과로 전환`} data-testid={`plate-${i}`} title={`플레이트 ${i + 1}`}>{i + 1}</button>
1236
- ))}
1237
- <button className="plate-add" onClick={addPlate} disabled={plateCount >= MAX_PLATES} title="빈 플레이트 추가 (최대 9개) — 플레이트별로 따로 슬라이스·내보내기" data-testid="plate-add">+</button>
1238
- {plateCount > 1 && <button className="plate-del" onClick={deletePlate} title="마지막 플레이트와 그 슬라이스 결과 삭제" data-testid="plate-del">−</button>}
1239
- </div>
1240
- )}
1241
-
1242
- {ok && <div className="vp-status" data-testid="vp-status">{status}</div>}
1243
- </div>
1244
-
1245
- {/* S4 우측 사이드바 */}
1246
- {ok && (
1247
- <aside className="sidebar">
1248
- <div className="sidebar-scroll">
1249
- {/* ① 프린터 */}
1250
- <section className="side-card">
1251
- <div className="sc-head">🖨 프린터</div>
1252
- <div className="sc-info"><span>베드</span>
1253
- {/* 폭×깊이 직접 편집(사각형). 원점·원형·커스텀 형상은 프로세스 패널의 printable_area 에디터에서. */}
1254
- <span className="sc-bed" title="플레이트 크기 — Enter 또는 포커스 아웃으로 적용. 원형/커스텀 형상은 printable_area 옵션에서">
1255
- <input type="number" min="1" key={`w${Math.round(kp.bed_width)}`} defaultValue={Math.round(kp.bed_width)}
1256
- onBlur={e => setBedSize(+e.target.value, kp.bed_depth)}
1257
- onKeyDown={e => { if (e.key === 'Enter') e.target.blur() }} data-testid="bed-w-card" />
1258
- ×
1259
- <input type="number" min="1" key={`d${Math.round(kp.bed_depth)}`} defaultValue={Math.round(kp.bed_depth)}
1260
- onBlur={e => setBedSize(kp.bed_width, +e.target.value)}
1261
- onKeyDown={e => { if (e.key === 'Enter') e.target.blur() }} data-testid="bed-d-card" />
1262
- mm
1263
- </span>
1264
- </div>
1265
- <div className="sc-info"><span>노즐 Ø</span><b>{nozzleDia} mm</b></div>
1266
- </section>
1267
-
1268
- {/* ② 필라멘트 */}
1269
- <section className="side-card" data-testid="filament-section">
1270
- <div className="sc-head">🧵 필라멘트 <span className="sc-count">{extruderColors.length}</span>
1271
- <span className="sc-head-btns">
1272
- <button onClick={addFilament} disabled={extruderColors.length >= 4} title="필라멘트(익스트루더) 추가 — 최대 4개, 오브젝트별로 지정 가능" data-testid="filament-add">+</button>
1273
- <button onClick={removeFilament} disabled={extruderColors.length <= 1} title="마지막 필라멘트 제거 — 이를 쓰던 오브젝트는 남은 번호로 재배정" data-testid="filament-del">−</button>
1274
- </span>
1275
- </div>
1276
- {extruderColors.map((c, i) => (
1277
- <div className="filament-row" key={i}>
1278
- <input type="color" value={c} onChange={e => setExtColor(i, e.target.value)} title={`T${i + 1} 필라멘트 색`} data-testid={`filament-color-${i}`} />
1279
- <span className="fil-t">T{i + 1}</span>
1280
- <span className="fil-swatch" style={{ background: c }} />
1281
- <span className="fil-hex muted">{c}</span>
1282
- </div>
1283
- ))}
1284
- </section>
1285
-
1286
- {/* ④ 오브젝트 리스트 */}
1287
- {objects.length > 0 && (
1288
- <section className="side-card" data-testid="object-section">
1289
- <div className="sc-head">📦 오브젝트 <span className="sc-count">{objects.length}</span></div>
1290
- <ul className="obj-list2" data-testid="obj-list">
1291
- {objects.map(o => (
1292
- <li key={o.id} className={o.visible === false ? 'obj-hidden' : ''}>
1293
- <button className="obj-eye" onClick={() => toggleObjVisible(o.id)} title="이 오브젝트를 출력 대상에서 제외/포함 — 제외해도 씬에는 남음" data-testid={`eye-${o.id}`}>{o.visible === false ? '🚫' : '👁'}</button>
1294
- <span className="obj-name" title={o.name}>{o.name}</span>
1295
- <select className="obj-ext" value={o.extruder ?? 1} onChange={e => setObjExtruder(o.id, +e.target.value)} title="이 오브젝트를 출력할 필라멘트(익스트루더) 번호" data-testid={`ext-${o.id}`}>
1296
- {extruderColors.map((c, i) => <option key={i} value={i + 1}>T{i + 1}</option>)}
1297
- </select>
1298
- <button className="obj-split" onClick={() => { apiRef.current?.selectObject(o.id); splitSelected() }} title="객체로 분리 — 서로 떨어진 부분(연결 성분)마다 독립 오브젝트로 나눔. 파트로 분리는 미구현(파트 개념 부재)" data-testid={`split-${o.id}`}><img src={splitIcon} alt="분리" /></button>
1299
- <button className="obj-del" onClick={() => removeObject(o.id)} title="이 오브젝트를 씬에서 제거">✕</button>
1300
- </li>
1301
- ))}
1302
- </ul>
1303
- <label className="slice-support"><input type="checkbox" checked={supportOn} onChange={onToggleSupport} title="오버행 아래에 지지 구조를 생성 (설정 패널의 enable_support 와 동일)" data-testid="support-toggle" /> 서포트 생성</label>
1304
- <label className="slice-support"><input type="checkbox" checked={wipeTowerReal} onChange={e => setWipeTowerReal(e.target.checked)} title="멀티머티리얼 툴체인지 시 원본 WipeTower 로 퍼지량을 계산 (끄면 단순 사각 링)" data-testid="wipe-tower-real-toggle" /> 실 와이프타워 <span className="muted">(MM)</span></label>
1305
- </section>
1306
- )}
1307
- {triWarn && <div className="slice-warn side-warn">⚠ {triWarn}</div>}
1308
- {sliceNotice && <div className="slice-warn side-warn" data-testid="slice-notice">ℹ {sliceNotice}</div>}
1309
- {error && <div className="slice-err side-warn" data-testid="slice-err">{error}</div>}
1310
- {downgradeOffer && <button className="slice-btn" data-testid="downgrade-retry" onClick={retryDowngrade} title="인필을 단순화하고 밀도를 낮춰 메모리 부담을 줄인 뒤 재시도">간소화 재시도(인필 단순화·절약 모드)</button>}
1311
-
1312
- {/* Preview 컨트롤(뷰타입/슬라이더/범례) */}
1313
- {canvasMode === 'preview' && previewControls && (
1314
- <section className="side-card">
1315
- <div className="sc-head">🎚 미리보기</div>
1316
- {previewControls}
1317
- </section>
1318
- )}
1319
-
1320
- {/* ③ 프로세스(설정 패널) */}
1321
- <section className="side-card process-card" data-testid="process-section">
1322
- <div className="sc-head">⚙ 프로세스</div>
1323
- {processPanel}
1324
- </section>
1325
- </div>
1326
-
1327
- {/* ⑤ 하단 고정 버튼 바 */}
1328
- <div className="side-bottom">
1329
- <div className="slice-dd">
1330
- <button className="slice-btn" title={plateCount > 1 ? '슬라이스 대상 선택 (Ctrl+R = 현재 플레이트)' : '현재 플레이트를 슬라이스 (Ctrl+R)'} onClick={() => (plateCount > 1 ? setSliceMenu(v => !v) : onSlice('current'))} disabled={objects.length === 0 || slicing} data-testid="slice-btn">
1331
- {slicing ? `슬라이싱… ${Math.round(progress * 100)}%` : (plateCount > 1 ? '슬라이스 ▾' : '슬라이스')}
1332
- </button>
1333
- {sliceMenu && plateCount > 1 && (
1334
- <div className="slice-menu" data-testid="slice-menu">
1335
- <button onClick={() => onSlice('current')} data-testid="slice-current" title="선택한 플레이트만 슬라이스 (Ctrl+R)">현재 플레이트 (P{selectedPlate + 1})</button>
1336
- <button onClick={() => onSlice('all')} data-testid="slice-all" title="모든 플레이트를 차례로 슬라이스 — 결과는 탭 전환으로 조회">전체 플레이트 ({plateCount})</button>
1337
- {slicedPlateCount > 0 && (
1338
- <button onClick={exportAllGcode} data-testid="export-all" title="슬라이스된 모든 플레이트의 G-code를 각각 파일로 저장">전체 G-code 내보내기 ({slicedPlateCount})</button>
1339
- )}
1340
- </div>
1341
- )}
1342
- </div>
1343
- {gcodeUrl
1344
- ? <a className="export-btn" href={gcodeUrl} download={`plate_${selectedPlate + 1}.gcode`} title="현재 보고 있는 플레이트의 G-code 파일 저장" data-testid="gcode-dl">G-code 내보내기</a>
1345
- : <button className="export-btn" disabled title="G-code 내보내기 — 슬라이스 후 활성화됩니다">G-code 내보내기</button>}
1346
- </div>
1347
- </aside>
1348
- )}
1349
- </div>
1350
- </div>
1351
- </ShadowHost>
1352
- )
1353
- }