import { spawn } from 'node:child_process'; import { mkdir,writeFile,readFile,readdir } from 'node:fs/promises'; import { existsSync } from 'node:fs'; import path from 'node:path'; import { randomUUID } from 'node:crypto'; import type { Plugin } from 'vite'; export const GROUND_LINK_API='/__pierzainer/ground-link'; export function startGroundBat(bat:string,context:string,cwd:string){ return spawn(process.env.ComSpec??'cmd.exe',['/d','/s','/c',`""${bat}""`],{cwd,windowsHide:true,windowsVerbatimArguments:true,env:{...process.env,PIERZAINER_GROUND_CONTEXT:context},stdio:'ignore'}); } export async function findGroundDrawing(folder:string):Promise{ const matches:string[]=[]; const walk=async(dir:string,depth:number)=>{for(const entry of await readdir(dir,{withFileTypes:true})){const file=path.join(dir,entry.name);if(entry.isDirectory()&&depth<2)await walk(file,depth+1);else if(entry.isFile()&&/\.(dwg|dxf)$/i.test(entry.name)&&/종평면|종평|종단|평종면/.test(entry.name))matches.push(file);}}; if(folder)await walk(folder,0);const originals=matches.filter(file=>/\.dwg$/i.test(file));return originals.length===1?originals[0]:matches.length===1?matches[0]:undefined; } export function groundLinkPlugin(root:string):Plugin{ const base=path.join(root,'pierzainer_ground'),cache=path.join(base,'data/cache/app-link'),stages=new Map(),jobs=new Map(); const handler=async(req:any,res:any,next:()=>void)=>{ const pathname=new URL(req.url??'/','http://localhost').pathname;if(!pathname.startsWith(GROUND_LINK_API+'/'))return next(); const reply=(code:number,value:unknown)=>{res.writeHead(code,{'Content-Type':'application/json; charset=utf-8','Cache-Control':'no-store'});res.end(JSON.stringify(value));}; const host=req.headers.host??'',address=req.socket.remoteAddress??''; if(req.method!=='POST'||! /^(localhost|127\.0\.0\.1|\[::1\])(?::\d+)?$/i.test(host)||!['127.0.0.1','::1','::ffff:127.0.0.1'].includes(address)||req.headers['x-pierzainer-action']!=='ground-link'||req.headers.origin&&req.headers.origin!==`http://${host}`&&req.headers.origin!==`https://${host}`){reply(403,{error:'이 PC의 Pierzainer에서 실행하세요.'});return;} try{ let raw='';for await(const part of req){raw+=part;if(raw.length>280*1024*1024)throw new Error('Excel 파일이 너무 큽니다(최대 20MB).');}const body=JSON.parse(raw||'{}'),action=pathname.slice(GROUND_LINK_API.length+1); if(action==='stage'){ if(typeof body.name!=='string'||!body.name.toLowerCase().endsWith('.xlsx')||typeof body.base64!=='string'||!/^[A-Za-z0-9+/]*={0,2}$/.test(body.base64))throw new Error('교각 Excel(.xlsx) 파일을 선택하세요.'); const bytes=Buffer.from(body.base64,'base64');if(bytes.length>20*1024*1024||bytes[0]!==80||bytes[1]!==75)throw new Error('유효한 xlsx 파일이 아닙니다.'); const id=randomUUID(),dir=path.join(cache,id);await mkdir(dir,{recursive:true});const excel=path.join(dir,'piers.xlsx');await writeFile(excel,bytes);stages.set(id,{excel,name:body.name,folder:typeof body.folder==='string'?body.folder:''});reply(200,{id});return; } if(action==='prepare'){ const stage=stages.get(body.stageId);const drawing=stage?.folder?await findGroundDrawing(stage.folder):undefined;reply(200,{drawing:drawing??'',excelSelected:!!stage,excelName:stage?.name??''});return; } if(action==='upload'){ if(typeof body.name!=='string'||!/\.(dwg|dxf|xlsx)$/i.test(body.name)||typeof body.base64!=='string'||! /^[A-Za-z0-9+/]*={0,2}$/.test(body.base64))throw new Error('DWG/DXF 또는 Excel을 선택하세요.'); const bytes=Buffer.from(body.base64,'base64');if(bytes.length>200*1024*1024)throw new Error('최대 200MB입니다.');const dir=path.join(cache,randomUUID());await mkdir(dir,{recursive:true});const file=path.join(dir,path.basename(body.name));await writeFile(file,bytes);reply(200,{file});return; } if(action==='launch'){ const stage=stages.get(body.stageId),drawing=String(body.drawing??''),piers=String(body.excel??stage?.excel??''); const original=stage?.folder?await findGroundDrawing(stage.folder):undefined; const cached=(file:string)=>{const r=path.relative(cache,path.resolve(file));return !!r&&!r.startsWith('..')&&!path.isAbsolute(r);}; if(!existsSync(drawing)||!/\.(dwg|dxf)$/i.test(drawing)||drawing!==original&&!cached(drawing))throw new Error('종평면도를 선택하세요.'); if(!existsSync(piers)||!/\.xlsx$/i.test(piers)||!cached(piers))throw new Error('교각 Excel을 선택하세요.'); const id=randomUUID(),output=path.join(base,'data/output',`app_${id}`),job={output,logs:'',state:'running'};jobs.set(id,job); const python=existsSync(path.join(base,'.venv/Scripts/python.exe'))?path.join(base,'.venv/Scripts/python.exe'):'python'; const child=spawn(python,['-B','-u','-X','utf8','-m','pierzainer_ground.web_job',drawing,piers,output],{cwd:base,windowsHide:true,stdio:['ignore','pipe','pipe']}); const append=(data:Buffer)=>{job.logs=(job.logs+data.toString('utf8')).slice(-200000);};child.stdout.on('data',append);child.stderr.on('data',append); child.once('error',error=>{job.state='error';job.logs+='\n'+error.message;});child.once('close',code=>{job.state=code===0?'done':'error';});reply(200,{id});return; } if(action==='result'){ const job=jobs.get(body.id);if(!job)throw new Error('지반 생성 작업을 찾을 수 없습니다.'); if(job.state!=='done'){reply(200,{ready:false,state:job.state,logs:job.logs});return;} for(const filename of ['ground_model_3d_with_maps.json','ground_model.json']){const file=path.join(job.output,filename);if(existsSync(file)){const text=await readFile(file,'utf8');JSON.parse(text);reply(200,{ready:true,filename,text,state:job.state,logs:job.logs});return;}} reply(200,{ready:false});return; }reply(404,{error:'지원하지 않는 지반 연동 요청입니다.'}); }catch(error){reply(400,{error:(error as Error).message});} }; return{name:'pierzainer-ground-link',configureServer(server){server.middlewares.use((req,res,next)=>void handler(req,res,next));},configurePreviewServer(server){server.middlewares.use((req,res,next)=>void handler(req,res,next));}}; }