From 80ea4322bcd1c7f74a6022f89f047e7f5a0aab8c Mon Sep 17 00:00:00 2001 From: kim Date: Tue, 22 Sep 2026 13:23:05 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=9E=91=EC=97=85=EC=8B=A4=20=EC=95=B1?= =?UTF-8?q?=EC=97=90=20=ED=94=84=EB=A1=9C=EC=A0=9D=ED=8A=B8=EB=B3=84=20?= =?UTF-8?q?=ED=8C=8C=EC=9D=BC=20=EB=B8=8C=EB=9D=BC=EC=9A=B0=EC=A0=80=20+?= =?UTF-8?q?=20K2=20CAD=20=ED=8E=B8=EC=A7=91=EA=B8=B0=20=EC=97=B0=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - routes-workshop.ts: 파일 목록/업로드/폴더생성/삭제 API 추가 (case-storage.ts 재사용, appType='workshop', caseId=projectId) - workshop-app.html: 파일 탭 추가 — 드래그앤드롭 업로드, 폴더 그룹핑, 사진 썸네일, STL 클릭 시 Three.js CAD 편집기 팝업으로 연동 - cad-editor-view.js: ?path= 쿼리로 특정 STL 자동 로드 지원 - server.ts: registerWorkshopRoutes에 isPathInsideDir 전달 - stl-cad-core.ts: 평면뷰 렌더 카메라가 원점이 아닌 바운딩박스 중심을 look-at하도록 수정 — 원점에서 떨어진 모델 위쪽 잘림 해결 Co-Authored-By: Claude Code --- src/gateway/routes/routes-workshop.ts | 156 +++++++++++++++++++ src/gateway/server.ts | 2 +- src/tools/stl-cad-core.ts | 9 +- web-ui/html/workshop-app.html | 216 ++++++++++++++++++++++++++ web-ui/js/k2/cad-editor-view.js | 6 + 5 files changed, 387 insertions(+), 2 deletions(-) diff --git a/src/gateway/routes/routes-workshop.ts b/src/gateway/routes/routes-workshop.ts index 90a8fc5..e452928 100644 --- a/src/gateway/routes/routes-workshop.ts +++ b/src/gateway/routes/routes-workshop.ts @@ -4,14 +4,38 @@ * routes-robot.ts/robot-app.html이었던 것을 여러 프로젝트를 오갈 수 있게 일반화했다, * 2026-09-20). 저장 로직은 workshop-storage.ts에 있음 — 전용 채팅도구 * (src/tools/workshop-project.ts)와 공유한다. + * + * 09-22: 프로젝트별 첨부파일(사진/CAD/STL 등) 폴더 기능 추가 — 저장소 레이아웃/업로드 + * 파싱은 doctor·lawyer 앱이 쓰는 case-storage.ts를 그대로 재사용(appType='workshop', + * caseId=projectId). 파일은 /workshop//... 에 저장되고 기존 + * 범용 /api/files/{*filePath} 라우트로 서빙된다. */ import express from 'express'; +import fs from 'fs'; +import path from 'path'; import { getUserWorkspace } from '../../config/config'; import { loadWorkshop, saveWorkshop } from './workshop-storage'; +import { caseFilesDir, resolveUploadPath, sanitizePathSegment, walkCaseFiles, fileCategory } from './case-storage'; + +const APP_TYPE = 'workshop'; + +// walkCaseFiles만으로는 파일이 하나도 없는 빈 폴더가 안 보이므로, 디렉토리 구조를 따로 순회. +function listCaseFolders(dir: string, relBase = ''): string[] { + if (!fs.existsSync(dir)) return []; + const out: string[] = []; + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + if (!entry.isDirectory()) continue; + const rel = relBase ? `${relBase}/${entry.name}` : entry.name; + out.push(rel); + out.push(...listCaseFolders(path.join(dir, entry.name), rel)); + } + return out; +} export function registerWorkshopRoutes( app: express.Application, getSessionUser: (req: express.Request) => any, + isPathInsideDir: (base: string, target: string) => boolean, ): void { app.get('/api/workshop/data', (req, res) => { const session = getSessionUser(req); @@ -29,4 +53,136 @@ export function registerWorkshopRoutes( saveWorkshop(getUserWorkspace(session.username), { projects, activeProjectId: String(activeProjectId || '') }); res.json({ success: true }); }); + + app.get('/api/workshop/files', (req, res) => { + const session = getSessionUser(req); + if (!session) return res.status(401).json({ error: 'Unauthorized' }); + const projectId = String(req.query.projectId || ''); + if (!/^[a-zA-Z0-9_-]+$/.test(projectId)) return res.status(400).json({ error: 'invalid projectId' }); + const dir = caseFilesDir(session.username, APP_TYPE, projectId); + const files = walkCaseFiles(dir).map(e => { + const segs = e.relPath.split('/'); + return { + name: e.name, + relPath: e.relPath, + folder: segs.length > 1 ? segs.slice(0, -1).join('/') : '', + size: e.size, + mtime: e.mtime, + url: `/api/files/${APP_TYPE}/${projectId}/${segs.map(encodeURIComponent).join('/')}`, + category: fileCategory(e.name), + }; + }); + res.json({ files, folders: listCaseFolders(dir) }); + }); + + app.post('/api/workshop/files/folder', (req, res) => { + const session = getSessionUser(req); + if (!session) return res.status(401).json({ error: 'Unauthorized' }); + const projectId = String(req.body?.projectId || ''); + const folder = String(req.body?.folder || ''); + const segments = folder.split('/').filter(Boolean).map(sanitizePathSegment); + if (!/^[a-zA-Z0-9_-]+$/.test(projectId) || !segments.length) { + return res.status(400).json({ error: 'invalid' }); + } + const dir = caseFilesDir(session.username, APP_TYPE, projectId); + const targetPath = path.join(dir, ...segments); + if (!isPathInsideDir(dir, targetPath)) return res.status(403).json({ error: 'Forbidden' }); + fs.mkdirSync(targetPath, { recursive: true }); + res.json({ success: true, folder: segments.join('/') }); + }); + + app.post('/api/workshop/upload', (req, res) => { + const session = getSessionUser(req); + if (!session) { res.status(401).json({ success: false, error: 'Unauthorized' }); return; } + const projectId = String(req.query.projectId || ''); + if (!/^[a-zA-Z0-9_-]+$/.test(projectId)) { res.status(400).json({ success: false, error: 'invalid projectId' }); return; } + const folder = String(req.query.folder || '').split('/').filter(Boolean).map(sanitizePathSegment).join('/'); + const contentType = String(req.headers['content-type'] || ''); + if (!contentType.includes('multipart/form-data')) { + res.status(400).json({ success: false, error: 'Content-Type must be multipart/form-data' }); return; + } + const boundary = contentType.split('boundary=')[1]; + if (!boundary) { res.status(400).json({ success: false, error: 'Missing boundary' }); return; } + + const chunks: Buffer[] = []; + req.on('data', (chunk: Buffer) => chunks.push(chunk)); + req.on('end', () => { + const raw = Buffer.concat(chunks).toString('binary'); + const boundaryDelim = '--' + boundary; + + let filename = 'upload.bin'; + let filetype = 'application/octet-stream'; + let fileData: Buffer | null = null; + + const parts = raw.split(boundaryDelim); + for (const part of parts) { + if (!part || part.trim() === '--' || part.trim() === '') continue; + const headerEnd = part.indexOf('\r\n\r\n'); + if (headerEnd === -1) continue; + const header = part.substring(0, headerEnd); + if (!header.includes('name="file"')) continue; + + const fnMatch = header.match(/filename\*=UTF-8''([^\r\n]+)/i) + ?? header.match(/filename="([^"]+)"/); + if (fnMatch) { + const raw8 = decodeURIComponent(fnMatch[1]) === fnMatch[1] + ? Buffer.from(fnMatch[1], 'binary').toString('utf-8') + : decodeURIComponent(fnMatch[1]); + filename = raw8; + } + const ctMatch = header.match(/Content-Type:\s*([^\r\n]+)/i); + if (ctMatch) filetype = ctMatch[1].trim(); + + const bodyStart = headerEnd + 4; + const bodyEnd = part.lastIndexOf('\r\n'); + if (bodyEnd <= bodyStart) continue; + + fileData = Buffer.from(part.substring(bodyStart, bodyEnd), 'binary'); + break; + } + + if (!fileData) { res.status(400).json({ success: false, error: 'No file found in upload' }); return; } + if (fileData.length > 100 * 1024 * 1024) { res.status(400).json({ success: false, error: 'File too large (max 100MB)' }); return; } + + const dir = caseFilesDir(session.username, APP_TYPE, projectId); + fs.mkdirSync(dir, { recursive: true }); + const targetName = folder ? `${folder}/${filename}` : filename; + const { relPath, absPath } = resolveUploadPath(dir, targetName); + fs.writeFileSync(absPath, fileData); + + res.json({ + success: true, + name: path.basename(relPath), + relPath, + size: fileData.length, + type: filetype, + url: `/api/files/${APP_TYPE}/${projectId}/${relPath.split('/').map(encodeURIComponent).join('/')}`, + category: fileCategory(relPath), + }); + }); + req.on('error', (err: any) => { res.status(500).json({ success: false, error: String(err?.message || err) }); }); + }); + + app.delete('/api/workshop/files', (req, res) => { + const session = getSessionUser(req); + if (!session) return res.status(401).json({ error: 'Unauthorized' }); + const projectId = String(req.body?.projectId || ''); + const relPath = String(req.body?.relPath || ''); + const segments = relPath.split('/').filter(Boolean); + if (!/^[a-zA-Z0-9_-]+$/.test(projectId) || !segments.length || segments.some(s => s === '..' || s === '.')) { + return res.status(400).json({ error: 'invalid' }); + } + const dir = caseFilesDir(session.username, APP_TYPE, projectId); + const targetPath = path.join(dir, ...segments); + if (!isPathInsideDir(dir, targetPath)) return res.status(403).json({ error: 'Forbidden' }); + if (!fs.existsSync(targetPath)) { res.json({ success: true, alreadyGone: true }); return; } + try { + const stat = fs.statSync(targetPath); + if (stat.isDirectory()) fs.rmSync(targetPath, { recursive: true, force: true }); + else fs.unlinkSync(targetPath); + } catch (e: any) { + res.status(500).json({ success: false, error: String(e?.message || e) }); return; + } + res.json({ success: true }); + }); } diff --git a/src/gateway/server.ts b/src/gateway/server.ts index 6e5f7d0..5c61fa4 100644 --- a/src/gateway/server.ts +++ b/src/gateway/server.ts @@ -4613,7 +4613,7 @@ registerLawyerCaseRoutes(app, getSessionUser, isPathInsideDir); registerWopiRoutes(app, '/api/lawyer'); registerDoctorCaseRoutes(app, getSessionUser, isPathInsideDir); registerWopiRoutes(app, '/api/doctor'); -registerWorkshopRoutes(app, getSessionUser); +registerWorkshopRoutes(app, getSessionUser, isPathInsideDir); registerCadRoutes(app); diff --git a/src/tools/stl-cad-core.ts b/src/tools/stl-cad-core.ts index ca6f190..8a6cec8 100644 --- a/src/tools/stl-cad-core.ts +++ b/src/tools/stl-cad-core.ts @@ -70,6 +70,13 @@ export async function renderStlPng(stlPath: string, view: string, outPngPath: st try { bbox = readStlBBox(stlPath); } catch (e: any) { return { ok: false, detail: String(e?.message || e) }; } const maxExtent = Math.max(bbox.max[0] - bbox.min[0], bbox.max[1] - bbox.min[1], bbox.max[2] - bbox.min[2]); const dist = Math.max(50, maxExtent * 3); + // 카메라 시선 중심(look-at) — 원점이 아니라 실제 바운딩박스 중심으로 잡아야 원점에서 + // 떨어진 모델(예: 바닥이 Z=0이고 위로 솟은 조립체)도 위쪽이 잘리지 않는다. + const center = [ + (bbox.min[0] + bbox.max[0]) / 2, + (bbox.min[1] + bbox.max[1]) / 2, + (bbox.min[2] + bbox.max[2]) / 2, + ]; const scadPath = outPngPath.replace(/\.png$/i, '.scad'); fs.writeFileSync(scadPath, `import(${JSON.stringify(stlPath)});\n`); @@ -80,7 +87,7 @@ export async function renderStlPng(stlPath: string, view: string, outPngPath: st } else { const rot = VIEW_ROTATIONS[view]; if (!rot) { try { fs.unlinkSync(scadPath); } catch { /* noop */ } return { ok: false, detail: `알 수 없는 view: ${view}` }; } - scadArgs.push(`--camera=0,0,0,${rot[0]},${rot[1]},${rot[2]},${dist}`, '--projection=ortho'); + scadArgs.push(`--camera=${center[0]},${center[1]},${center[2]},${rot[0]},${rot[1]},${rot[2]},${dist}`, '--projection=ortho'); } scadArgs.push(scadPath); diff --git a/web-ui/html/workshop-app.html b/web-ui/html/workshop-app.html index b14cb76..c560db5 100644 --- a/web-ui/html/workshop-app.html +++ b/web-ui/html/workshop-app.html @@ -157,6 +157,33 @@ body{background:var(--bg);color:var(--text);font-family:system-ui,sans-serif;dis /* ── 메모 ── */ .rb-notes{width:100%;height:calc(100dvh - 220px);box-sizing:border-box;background:var(--panel);border:1px solid var(--line);border-radius:8px;padding:12px 14px;font-size:12px;color:var(--text);font-family:inherit;resize:none;outline:none;line-height:1.7;} .rb-notes:focus{border-color:#f59e0b;} + +/* ── 파일 ── */ +.rb-files-toolbar{display:flex;gap:8px;align-items:center;margin-bottom:10px;flex-wrap:wrap;} +.rb-files-toolbar select{background:var(--panel-2);border:1px solid var(--line);border-radius:6px;padding:5px 8px;font-size:12px;color:var(--text);font-family:inherit;outline:none;} +.rb-upload-btn{background:rgba(245,158,11,.1);border:1px solid rgba(245,158,11,.3);border-radius:6px;padding:5px 12px;font-size:11px;color:#f59e0b;cursor:pointer;font-weight:700;display:inline-flex;align-items:center;gap:4px;} +.rb-upload-btn:hover{background:rgba(245,158,11,.25);} +.rb-files-dropzone{border:2px dashed var(--line);border-radius:10px;padding:18px;text-align:center;font-size:12px;color:var(--muted);margin-bottom:14px;transition:.15s;} +.rb-files-dropzone.dragover{border-color:#f59e0b;color:#f59e0b;background:rgba(245,158,11,.05);} +.rb-files-empty{font-size:12px;color:var(--muted);padding:20px 0;text-align:center;} +.rb-files-group{margin-bottom:18px;} +.rb-files-group-hdr{display:flex;align-items:center;gap:8px;margin-bottom:8px;} +.rb-files-group-name{font-size:12px;font-weight:700;color:var(--text);} +.rb-files-group-count{font-size:10px;color:var(--muted);} +.rb-files-group-del{margin-left:auto;background:none;border:none;cursor:pointer;font-size:11px;color:var(--muted);} +.rb-files-group-del:hover{color:#ef4444;} +.rb-files-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(120px,1fr));gap:10px;} +.rb-file-card{border:1px solid var(--line);border-radius:8px;overflow:hidden;background:var(--panel);position:relative;} +.rb-file-thumb{width:100%;aspect-ratio:1/1;background:var(--panel-2);display:flex;align-items:center;justify-content:center;overflow:hidden;} +.rb-file-thumb img{width:100%;height:100%;object-fit:cover;} +.rb-file-thumb .rb-file-icon{font-size:28px;opacity:.6;} +.rb-file-info{padding:6px 8px;} +.rb-file-name{font-size:11px;color:var(--text);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;} +.rb-file-size{font-size:10px;color:var(--muted);} +.rb-file-del{position:absolute;top:4px;right:4px;background:rgba(0,0,0,.55);border:none;border-radius:5px;color:#fff;font-size:10px;padding:2px 5px;cursor:pointer;opacity:0;transition:.15s;} +.rb-file-card:hover .rb-file-del{opacity:1;} +.rb-file-del:hover{background:#ef4444;} +.rb-file-card a{display:block;color:inherit;text-decoration:none;} @@ -185,6 +212,7 @@ body{background:var(--bg);color:var(--text);font-family:system-ui,sans-serif;dis + @@ -222,6 +250,16 @@ body{background:var(--bg);color:var(--text);font-family:system-ui,sans-serif;dis +
+
+ + + +
+
여기로 파일을 끌어다 놓거나 위 버튼으로 업로드 (사진/CAD/STL 등, 최대 100MB)
+
+
+
@@ -320,6 +358,7 @@ function switchProject(id){ loadLocalFromProject(); refreshAllPanes(); scheduleSave(); + if(document.getElementById('pane-files').classList.contains('active'))loadFiles(); } function createProjectPrompt(){ @@ -390,6 +429,7 @@ function switchTab(tab){ document.querySelectorAll('.rb-pane').forEach(p=>p.classList.remove('active')); document.getElementById('pane-'+tab).classList.add('active'); if(tab==='equip')startEquipTimers();else stopEquipTimers(); + if(tab==='files')loadFiles(); } // ── 장비 위젯(K2/밀링) ──────────────────────────────────────────────────────── @@ -731,6 +771,182 @@ function deleteTask(phaseId,taskId){ scheduleSave(); } +// ── 파일 ────────────────────────────────────────────────────────────────────── +// 부품/작업/메모와 달리 workshopData(JSON)에 안 담고, 서버 디스크에 실제 파일로 +// 저장된다(case-storage.ts, doctor/lawyer 앱과 동일 패턴) — /api/workshop/files 로 +// 목록을 받아 그때그때 다시 그린다. +const FILE_ICONS={photo:'🖼️',video:'🎬',document:'📄',other:'📦'}; +let filesState={files:[],folders:[]}; + +async function loadFiles(){ + const proj=currentProject(); + if(!proj)return; + const container=document.getElementById('files-list-container'); + try{ + const r=await fetch('/api/workshop/files?projectId='+encodeURIComponent(proj.id),{headers:authH()}); + if(!r.ok)throw new Error('불러오기 실패'); + const d=await r.json(); + filesState.files=Array.isArray(d.files)?d.files:[]; + filesState.folders=Array.isArray(d.folders)?d.folders:[]; + }catch{ + container.innerHTML='
파일 목록을 불러오지 못했습니다.
'; + return; + } + renderFiles(); +} + +function renderFolderSelect(){ + const sel=document.getElementById('upload-folder-select'); + const prev=sel.value; + sel.innerHTML=''+filesState.folders.map(f=>``).join(''); + if([...sel.options].some(o=>o.value===prev))sel.value=prev; +} + +function renderFiles(){ + renderFolderSelect(); + const container=document.getElementById('files-list-container'); + if(!filesState.files.length&&!filesState.folders.length){ + container.innerHTML='
아직 첨부된 파일이 없습니다. 사진/CAD/STL 등을 업로드해 보세요.
'; + return; + } + // 폴더별로 묶기 — 최상위(폴더 없음) 파일은 "미분류"로. + const groups=new Map(); + for(const f of filesState.folders)groups.set(f,[]); + for(const file of filesState.files){ + const key=file.folder||''; + if(!groups.has(key))groups.set(key,[]); + groups.get(key).push(file); + } + const keys=[...groups.keys()].sort((a,b)=>{ + if(a==='')return 1; if(b==='')return -1; + return a.localeCompare(b); + }); + container.innerHTML=keys.map(key=>{ + const files=groups.get(key)||[]; + const label=key||'미분류'; + const delBtn=key?``:''; + const grid=files.length?`
${files.map(renderFileCard).join('')}
` + :'
(빈 폴더)
'; + return `
+
+ 📁 ${escAttr(label)} + ${files.length}개 + ${delBtn} +
+ ${grid} +
`; + }).join(''); +} + +const STL_RE=/\.stl$/i; +function renderFileCard(file){ + const isStl=STL_RE.test(file.name); + const icon=isStl?'🔧':(FILE_ICONS[file.category]||FILE_ICONS.other); + const thumb=file.category==='photo' + ? `${escAttr(file.name)}` + : `${icon}`; + // STL은 새탭 다운로드 대신 Three.js CAD 편집기 팝업으로 연다(작업실↔K2 CAD 플러그인 연동). + const openAttrs=isStl?`href="javascript:void(0)" onclick="openCadEditorForFile('${escAttr(file.relPath)}')"`:`href="${escAttr(file.url)}" target="_blank" rel="noopener"`; + return ``; +} + +// K2 프린터 앱의 "🔧 STL 편집기"(Three.js, cad-editor-app.html)를 그대로 재사용 — STL 목록은 +// 워크스페이스 전체를 훑으므로(findStlFiles) 작업실 파일도 이미 그 목록에 잡힌다. ?path= 로 +// 어느 파일을 열지 지정하면 편집기가 자동으로 로드한다(cad-editor-view.js 09-22 추가). +function openCadEditorForFile(relPath){ + const proj=currentProject(); + if(!proj)return; + const workspacePath='workshop/'+proj.id+'/'+relPath; + const w=1320,h=860; + const left=Math.max(0,(screen.width-w)/2), top=Math.max(0,(screen.height-h)/2); + window.open('/html/cad-editor-app.html?path='+encodeURIComponent(workspacePath),'cad-editor',`width=${w},height=${h},left=${left},top=${top},menubar=no,toolbar=no,location=no,status=no,resizable=yes`); +} + +function fmtFileSize(n){ + if(!n&&n!==0)return ''; + if(n<1024)return n+'B'; + if(n<1024*1024)return (n/1024).toFixed(1)+'KB'; + return (n/1024/1024).toFixed(1)+'MB'; +} + +async function promptNewFolder(){ + const name=(prompt('새 폴더 이름을 입력하세요 (예: 사진, CAD, STL):')||'').trim(); + if(!name)return; + const proj=currentProject(); + if(!proj)return; + try{ + const r=await fetch('/api/workshop/files/folder',{method:'POST',headers:authH({'Content-Type':'application/json'}),body:JSON.stringify({projectId:proj.id,folder:name})}); + if(!r.ok)throw new Error(); + await loadFiles(); + }catch{alert('폴더 생성 실패');} +} + +async function deleteFolder(folder){ + if(!confirm(`"${folder}" 폴더와 그 안의 파일이 모두 삭제됩니다. 계속할까요?`))return; + const proj=currentProject(); + if(!proj)return; + try{ + await fetch('/api/workshop/files',{method:'DELETE',headers:authH({'Content-Type':'application/json'}),body:JSON.stringify({projectId:proj.id,relPath:folder})}); + }catch{} + await loadFiles(); +} + +async function deleteFile(relPath){ + if(!confirm('이 파일을 삭제할까요?'))return; + const proj=currentProject(); + if(!proj)return; + try{ + await fetch('/api/workshop/files',{method:'DELETE',headers:authH({'Content-Type':'application/json'}),body:JSON.stringify({projectId:proj.id,relPath})}); + }catch{} + await loadFiles(); +} + +async function uploadFiles(fileList){ + const proj=currentProject(); + if(!proj||!fileList||!fileList.length)return; + const folder=document.getElementById('upload-folder-select').value; + const zone=document.getElementById('files-dropzone'); + const origText=zone.textContent; + for(const file of fileList){ + zone.textContent=`업로드 중… ${file.name}`; + const fd=new FormData(); + fd.append('file',file); + try{ + const qs='projectId='+encodeURIComponent(proj.id)+(folder?'&folder='+encodeURIComponent(folder):''); + const r=await fetch('/api/workshop/upload?'+qs,{method:'POST',headers:authH(),body:fd}); + if(!r.ok){const d=await r.json().catch(()=>({}));throw new Error(d.error||'업로드 실패');} + }catch(e){ + alert(`"${file.name}" 업로드 실패: ${e.message}`); + } + } + zone.textContent=origText; + await loadFiles(); +} + +function handleFileInputChange(ev){ + uploadFiles(ev.target.files); + ev.target.value=''; +} + +(function initDropzone(){ + const zone=document.getElementById('files-dropzone'); + ['dragenter','dragover'].forEach(evt=>zone.addEventListener(evt,e=>{e.preventDefault();zone.classList.add('dragover');})); + ['dragleave','drop'].forEach(evt=>zone.addEventListener(evt,e=>{e.preventDefault();zone.classList.remove('dragover');})); + zone.addEventListener('drop',e=>{ + const files=e.dataTransfer?.files; + if(files&&files.length)uploadFiles(files); + }); +})(); + // ── 메모 ────────────────────────────────────────────────────────────────────── function onNotesChange(){ notes=document.getElementById('notes-textarea').value; diff --git a/web-ui/js/k2/cad-editor-view.js b/web-ui/js/k2/cad-editor-view.js index 82cc393..01a6ad0 100644 --- a/web-ui/js/k2/cad-editor-view.js +++ b/web-ui/js/k2/cad-editor-view.js @@ -612,6 +612,12 @@ const ro = new ResizeObserver(() => resize()); ro.observe(vpEl); requestAnimationFrame(() => { resize(); requestAnimationFrame(resize); }); + // 다른 앱(작업실 파일 탭 등)에서 특정 STL을 바로 열도록 ?path= 로 넘겨줄 수 있다. + const preselect = new URLSearchParams(location.search).get('path'); + if (preselect && files.includes(preselect)) { + panelEl.querySelector('#ce-file').value = preselect; + loadSelected(); + } } if (document.readyState === 'loading') {