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:
1 parent
f51865c57b
commit
d740406a8c
16 files changed
+67
-6
No files matched your search
@@ -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)
|
||||
|
||||
@@ -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 실행 파일을 찾지 못했습니다');
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
Regular → Executable
File mode changed.
Regular → Executable
File mode changed.
Regular → Executable
File mode changed.
Regular → Executable
File mode changed.
Regular → Executable
File mode changed.
Regular → Executable
File mode changed.
Regular → Executable
File mode changed.
Regular → Executable
File mode changed.
Regular → Executable
File mode changed.
Regular → Executable
File mode changed.
+3
-2
@@ -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
@@ -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});}
|
||||
};
|
||||
|
||||
Reference in new issue
Block a user