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.