fix(ground): WSL에서 지반 생성 Python·DWG 변환 실행 경로 해결

- 공통 resolvePython: Windows venv → .venv/bin/python → PIERZAINER_PYTHON → python3/python
- 지반 생성·수량산출 Excel·DWG 출력 서버가 같은 해석기 사용, ENOENT 안내 문구 보강
- WSL에서 번들 dwg2dxf.exe 호출 시 wslpath -w로 경로 변환, .exe 실행 권한 부여
- 실제 운곡교 DWG+Excel로 엔진/HTTP 경로 생성 확인(교각16·지형6,545점)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
This commit is contained in:
ypshimandClaude Haiku 5.5 committed 2026-11-09 14:03:16 +09:00
1 parent f51865c57b
commit d740406a8c
16 files changed
+67 -6

No files matched your search

+3 -2
View File
@@ -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('지반 생성 작업을 찾을 수 없습니다.');
@@ -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)
+26
View File
@@ -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 실행 파일을 찾지 못했습니다');
});
});
+20
View File
@@ -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;
}
View File
File mode changed.
View File
File mode changed.
View File
File mode changed.
View File
File mode changed.
View File
File mode changed.
View File
File mode changed.
View File
File mode changed.
View File
File mode changed.
View File
File mode changed.
View File
File mode changed.
+3 -2
View File
@@ -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)=>{
+2 -1
View File
@@ -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<void>((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<void>((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});}
};