diff --git a/pierzainer_ground/ground-link-server.ts b/pierzainer_ground/ground-link-server.ts index 707025d..daceff0 100644 --- a/pierzainer_ground/ground-link-server.ts +++ b/pierzainer_ground/ground-link-server.ts @@ -4,6 +4,7 @@ import { existsSync } from 'node:fs'; import path from 'node:path'; import { randomUUID } from 'node:crypto'; import type { Plugin } from 'vite'; +import { resolvePython,describeSpawnError } from './python-runtime.ts'; 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'}); @@ -42,10 +43,10 @@ export function groundLinkPlugin(root:string):Plugin{ 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 python=resolvePython(root); 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; + child.once('error',error=>{job.state='error';job.logs+='\n'+describeSpawnError(error,python);});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('지반 생성 작업을 찾을 수 없습니다.'); diff --git a/pierzainer_ground/pierzainer_ground/dxf_terrain.py b/pierzainer_ground/pierzainer_ground/dxf_terrain.py index 954067e..f9a94c5 100644 --- a/pierzainer_ground/pierzainer_ground/dxf_terrain.py +++ b/pierzainer_ground/pierzainer_ground/dxf_terrain.py @@ -29,6 +29,18 @@ class TerrainData: stats: dict = field(default_factory=dict) +def _exe_path_arg(exe: str, file: Path) -> str: + """Windows .exe를 WSL/Linux에서 부를 때 경로를 Windows 형식으로 바꾼다.""" + import os + if os.name == "nt" or not exe.lower().endswith(".exe"): + return str(file) + wslpath = shutil.which("wslpath") + if not wslpath: + return str(file) + result = subprocess.run([wslpath, "-w", str(Path(file).resolve())], check=False, capture_output=True, text=True) + return result.stdout.strip() if result.returncode == 0 and result.stdout.strip() else str(file) + + def convert_dwg_to_dxf(dwg: str | Path, out_dir: str | Path) -> Path: """LibreDWG의 dwg2dxf 로 DWG를 DXF로 바꾼다.""" exe = shutil.which("dwg2dxf") @@ -55,7 +67,7 @@ def convert_dwg_to_dxf(dwg: str | Path, out_dir: str | Path) -> Path: temporary=out.with_name(out.stem+'.converting.dxf') try: temporary.unlink(missing_ok=True) - result=subprocess.run([exe, "-y", "-o", str(temporary), str(dwg)], check=False, capture_output=True, timeout=300) + result=subprocess.run([exe, "-y", "-o", _exe_path_arg(exe,temporary), _exe_path_arg(exe,dwg)], check=False, capture_output=True, timeout=300) if result.returncode!=0 or not temporary.is_file() or temporary.stat().st_size==0: raise RuntimeError(f"DWG 변환에 실패했습니다: {dwg}") temporary.replace(out) diff --git a/pierzainer_ground/python-runtime.test.ts b/pierzainer_ground/python-runtime.test.ts new file mode 100644 index 0000000..b0a3da8 --- /dev/null +++ b/pierzainer_ground/python-runtime.test.ts @@ -0,0 +1,26 @@ +import { describe,it,expect } from 'vitest'; +import path from 'node:path'; +import { resolvePython,describeSpawnError } from './python-runtime'; + +const root='/repo',venv=path.join(root,'pierzainer_ground','.venv'); +const has=(...files:string[])=>(file:string)=>files.includes(file); + +describe('resolvePython',()=>{ + it('uses the Windows venv on win32',()=>{ + const exe=path.join(venv,'Scripts','python.exe'); + expect(resolvePython(root,'win32',has(exe),{})).toBe(exe); + }); + it('uses the POSIX venv on Linux/WSL',()=>{ + const bin=path.join(venv,'bin','python'); + expect(resolvePython(root,'linux',has(bin),{})).toBe(bin); + }); + it('falls back to PIERZAINER_PYTHON, then python3 / python',()=>{ + expect(resolvePython(root,'linux',has(),{PIERZAINER_PYTHON:'/opt/py'})).toBe('/opt/py'); + expect(resolvePython(root,'linux',has(),{})).toBe('python3'); + expect(resolvePython(root,'win32',has(),{})).toBe('python'); + }); + it('explains ENOENT instead of the raw spawn message',()=>{ + const error=Object.assign(new Error('spawn python ENOENT'),{code:'ENOENT'}); + expect(describeSpawnError(error,'python')).toContain('Python 실행 파일을 찾지 못했습니다'); + }); +}); diff --git a/pierzainer_ground/python-runtime.ts b/pierzainer_ground/python-runtime.ts new file mode 100644 index 0000000..e168a5d --- /dev/null +++ b/pierzainer_ground/python-runtime.ts @@ -0,0 +1,20 @@ +import { existsSync } from 'node:fs'; +import path from 'node:path'; + +/** + * Python used by the local dev-server tools (ground engine, quantity workbook, DWG export). + * Order: Windows venv → POSIX venv (WSL/Linux) → PIERZAINER_PYTHON → python3/python on PATH. + */ +export function resolvePython(root:string,platform:NodeJS.Platform=process.platform,exists:(file:string)=>boolean=existsSync,env:NodeJS.ProcessEnv=process.env):string{ + const venv=path.join(root,'pierzainer_ground','.venv'); + const candidates=platform==='win32'?[path.join(venv,'Scripts','python.exe')]:[path.join(venv,'bin','python'),path.join(venv,'Scripts','python.exe')]; + const found=candidates.find(file=>exists(file));if(found)return found; + if(env.PIERZAINER_PYTHON)return env.PIERZAINER_PYTHON; + return platform==='win32'?'python':'python3'; +} + +/** Human-readable reason for a failed spawn (ENOENT otherwise shows only "spawn python ENOENT"). */ +export function describeSpawnError(error:NodeJS.ErrnoException,python:string):string{ + if(error.code==='ENOENT')return `Python 실행 파일을 찾지 못했습니다(${python}). pierzainer_ground/.venv를 만들고 requirements.txt를 설치하거나 PIERZAINER_PYTHON을 지정하세요.`; + return error.message; +} diff --git a/pierzainer_ground/tools/libredwg/dwg2SVG.exe b/pierzainer_ground/tools/libredwg/dwg2SVG.exe old mode 100644 new mode 100755 diff --git a/pierzainer_ground/tools/libredwg/dwg2dxf.exe b/pierzainer_ground/tools/libredwg/dwg2dxf.exe old mode 100644 new mode 100755 diff --git a/pierzainer_ground/tools/libredwg/dwgbmp.exe b/pierzainer_ground/tools/libredwg/dwgbmp.exe old mode 100644 new mode 100755 diff --git a/pierzainer_ground/tools/libredwg/dwggrep.exe b/pierzainer_ground/tools/libredwg/dwggrep.exe old mode 100644 new mode 100755 diff --git a/pierzainer_ground/tools/libredwg/dwglayers.exe b/pierzainer_ground/tools/libredwg/dwglayers.exe old mode 100644 new mode 100755 diff --git a/pierzainer_ground/tools/libredwg/dwgread.exe b/pierzainer_ground/tools/libredwg/dwgread.exe old mode 100644 new mode 100755 diff --git a/pierzainer_ground/tools/libredwg/dwgrewrite.exe b/pierzainer_ground/tools/libredwg/dwgrewrite.exe old mode 100644 new mode 100755 diff --git a/pierzainer_ground/tools/libredwg/dwgwrite.exe b/pierzainer_ground/tools/libredwg/dwgwrite.exe old mode 100644 new mode 100755 diff --git a/pierzainer_ground/tools/libredwg/dxf2dwg.exe b/pierzainer_ground/tools/libredwg/dxf2dwg.exe old mode 100644 new mode 100755 diff --git a/pierzainer_ground/tools/libredwg/dxfwrite.exe b/pierzainer_ground/tools/libredwg/dxfwrite.exe old mode 100644 new mode 100755 diff --git a/도면작성/dwg-server.ts b/도면작성/dwg-server.ts index 2788bb5..0e3b6fd 100644 --- a/도면작성/dwg-server.ts +++ b/도면작성/dwg-server.ts @@ -5,6 +5,7 @@ import { existsSync } from 'node:fs'; import { mkdir,readFile,writeFile } from 'node:fs/promises'; import { randomUUID } from 'node:crypto'; import path from 'node:path'; +import { resolvePython } from '../pierzainer_ground/python-runtime.ts'; export const DWG_API='/__pierzainer/general-arrangement/dwg'; /** A narrow local export route; no project state or existing drawing-extractor endpoints are changed. */ @@ -21,8 +22,8 @@ export function generalArrangementDwgPlugin(root:string):Plugin{ for await(const chunk of req){const b=Buffer.from(chunk);size+=b.length;if(size>20*1024*1024)throw new Error('도면 크기가 20MB를 넘습니다.');chunks.push(b);} const body=JSON.parse(Buffer.concat(chunks).toString('utf8')) as {dxf?:unknown}; if(typeof body.dxf!=='string'||!body.dxf.includes('SECTION')||!body.dxf.trim().endsWith('EOF'))throw new Error('유효한 DXF 도면 데이터가 필요합니다.'); - const python=path.join(root,'pierzainer_ground','.venv','Scripts','python.exe'),script=path.join(root,'도면작성','dwg_export.py'); - if(!existsSync(python))throw new Error('DWG 출력용 기존 Python 실행 환경을 찾을 수 없습니다.'); + const python=resolvePython(root),script=path.join(root,'도면작성','dwg_export.py'); + if(path.isAbsolute(python)&&!existsSync(python))throw new Error('DWG 출력용 기존 Python 실행 환경을 찾을 수 없습니다.'); const folder=path.join(root,'도면작성','output',randomUUID());await mkdir(folder,{recursive:true}); const source=path.join(folder,'source.dxf'),output=path.join(folder,'general.dwg');await writeFile(source,body.dxf,'utf8'); const result=await new Promise<{code:number;stdout:string;stderr:string}>((resolve,reject)=>{ diff --git a/수량산출/server.ts b/수량산출/server.ts index 006598b..de92f75 100644 --- a/수량산출/server.ts +++ b/수량산출/server.ts @@ -4,6 +4,7 @@ import {mkdir,readFile,writeFile} from 'node:fs/promises'; import {randomUUID} from 'node:crypto'; import path from 'node:path'; import {exportEdits,type ExportRequest} from './export.ts'; +import {resolvePython,describeSpawnError} from '../pierzainer_ground/python-runtime.ts'; export function quantityPlugin(root:string):Plugin{ const handler=async(req:any,res:any)=>{ const reply=(status:number,value:unknown)=>{res.writeHead(status,{'Content-Type':'application/json; charset=utf-8'});res.end(JSON.stringify(value));}; @@ -15,7 +16,7 @@ export function quantityPlugin(root:string):Plugin{ for(const key of ['specFooting','specBody','specBlinding','specBearing','specMortar'])if(new Set(records.map(r=>String(r.specs[key]??''))).size>1)throw new Error('교각별 재료 규격이 서로 다릅니다. 원본 집계표의 동일 공종·규격에 합산할 수 없어 규격별 집계 검토가 필요합니다.'); const payload={projects:records.map(r=>({...exportEdits(r),pier:r.pier})),bridge:records[0].project.bridge},folder=path.join(root,'수량산출/outputs',randomUUID());await mkdir(folder,{recursive:true}); const input=path.join(folder,'edits.json'),output=path.join(folder,'quantity.xlsx');await writeFile(input,JSON.stringify(payload),'utf8'); - await new Promise((resolve,reject)=>{const child=spawn(path.join(root,'pierzainer_ground/.venv/Scripts/python.exe'),['-B','-X','utf8',path.join(root,'수량산출/tools/project_workbook.py'),input,output],{cwd:root,windowsHide:true});let logs='';child.stdout.on('data',b=>logs+=b);child.stderr.on('data',b=>logs+=b);const timer=setTimeout(()=>{child.kill();reject(new Error('Excel 출력 제한시간 초과'));},60000);child.once('error',e=>{clearTimeout(timer);reject(e);});child.once('close',code=>{clearTimeout(timer);if(code!==0)reject(new Error(logs));else resolve();});}); + await new Promise((resolve,reject)=>{const python=resolvePython(root),child=spawn(python,['-B','-X','utf8',path.join(root,'수량산출/tools/project_workbook.py'),input,output],{cwd:root,windowsHide:true});let logs='';child.stdout.on('data',b=>logs+=b);child.stderr.on('data',b=>logs+=b);const timer=setTimeout(()=>{child.kill();reject(new Error('Excel 출력 제한시간 초과'));},60000);child.once('error',e=>{clearTimeout(timer);reject(new Error(describeSpawnError(e,python)));});child.once('close',code=>{clearTimeout(timer);if(code!==0)reject(new Error(logs));else resolve();});}); res.writeHead(200,{'Content-Type':'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet','Content-Disposition':'attachment; filename="quantity.xlsx"','Cache-Control':'no-store'});res.end(await readFile(output)); }catch(e){reply(422,{error:(e as Error).message});} };