Update Pier_v1 project
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
1 parent
f25e36c050
commit
ad73ddec2b
2476 files changed
+3423
-4902
No files matched your search
@@ -0,0 +1,9 @@
|
||||
"""Public, GUI-free bridge drawing extraction API."""
|
||||
from .api import ExtractionResult, WorkbookResult, extract_folder, check_environment
|
||||
from .core import ExtractionError
|
||||
|
||||
__version__ = '0.1.9'
|
||||
__all__ = ['extract_folder', 'check_environment', 'ExtractionResult', 'WorkbookResult', 'ExtractionError']
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
from .cli import main
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,86 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable
|
||||
import platform
|
||||
from importlib.metadata import version
|
||||
|
||||
from .automatic import run_folder
|
||||
from .core import ExtractionError, converter_path as find_converter
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class WorkbookResult:
|
||||
name: str
|
||||
xlsx_path: Path
|
||||
json_path: Path
|
||||
review_csv_path: Path
|
||||
data: dict[str, Any]
|
||||
|
||||
@property
|
||||
def review_count(self) -> int:
|
||||
return sum(r['status'] not in ['추출','식별자','등록값'] for r in self.data['records'])
|
||||
|
||||
def to_dict(self, *, include_data: bool = True) -> dict[str, Any]:
|
||||
result = dict(name=self.name, xlsx_path=str(self.xlsx_path), json_path=str(self.json_path),
|
||||
review_csv_path=str(self.review_csv_path), review_count=self.review_count)
|
||||
if include_data:
|
||||
result['data'] = self.data
|
||||
return result
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ExtractionResult:
|
||||
output_dir: Path
|
||||
workbooks: tuple[WorkbookResult, ...]
|
||||
files: tuple[dict[str, Any], ...]
|
||||
schema_version: str = '1.0'
|
||||
|
||||
@property
|
||||
def review_count(self) -> int:
|
||||
return sum(book.review_count for book in self.workbooks)
|
||||
|
||||
@property
|
||||
def failed_file_count(self) -> int:
|
||||
return sum(f['status'] == '읽기 실패' for f in self.files)
|
||||
|
||||
@property
|
||||
def status(self) -> str:
|
||||
return 'needs_review' if self.review_count or self.failed_file_count else 'completed'
|
||||
|
||||
def to_dict(self, *, include_data: bool = True) -> dict[str, Any]:
|
||||
return dict(schema_version=self.schema_version, status=self.status,
|
||||
output_dir=str(self.output_dir), review_count=self.review_count,
|
||||
failed_file_count=self.failed_file_count,
|
||||
workbooks=[b.to_dict(include_data=include_data) for b in self.workbooks], files=list(self.files))
|
||||
|
||||
def extract_folder(
|
||||
folder: str | Path,
|
||||
*,
|
||||
output_dir: str | Path | None = None,
|
||||
converter_path: str | Path | None = None,
|
||||
on_progress: Callable[[str], None] | None = None,
|
||||
) -> ExtractionResult:
|
||||
"""Read a DWG folder synchronously and return paths, cell data and review statuses.
|
||||
|
||||
No GUI or network calls. Provide LibreDWG dwgread externally. On failure,
|
||||
raises ExtractionError. Partial file failures remain visible in result.files.
|
||||
Use a worker thread/process when calling from a GUI or async server.
|
||||
"""
|
||||
try:
|
||||
directory, outputs, files = run_folder(folder, output_dir, converter_path, on_progress or (lambda _: None))
|
||||
except ExtractionError:
|
||||
raise
|
||||
except (OSError, ValueError) as exc:
|
||||
raise ExtractionError(str(exc)) from exc
|
||||
books=tuple(WorkbookResult(data['name'],path.resolve(),path.with_suffix('.json').resolve(),
|
||||
path.with_suffix('.csv').resolve(),data) for path,data in outputs)
|
||||
return ExtractionResult(directory.resolve(),books,tuple(files))
|
||||
|
||||
def check_environment(*, converter_path: str | Path | None = None) -> dict[str, Any]:
|
||||
"""Check dependencies without converting any files or starting a GUI."""
|
||||
result = dict(python=platform.python_version(), openpyxl=version('openpyxl'), converter_available=False)
|
||||
try:
|
||||
result.update(converter_available=True, converter_path=str(find_converter(converter_path)))
|
||||
except ExtractionError as exc:
|
||||
result['converter_error']=str(exc)
|
||||
return result
|
||||
@@ -0,0 +1,314 @@
|
||||
"""Folder-only workflow: inspect CAD contents, infer table structure, relocate landmarks."""
|
||||
import copy
|
||||
import csv
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
import re
|
||||
import math
|
||||
from itertools import combinations
|
||||
from .core import APP,HEADERS,ROLES,ExtractionError,convert,converter_path,read_json,write_json,extract
|
||||
from .semantic import Index,norm,classify,bearings,pier,pier_tables,calculation_elevations,drawing_direction,pile_minimum_m
|
||||
from .workbook import export_workbook
|
||||
from .inference import coordinate_points,associate_ips,profile_fields,integrated_bearings
|
||||
from .geometry import LineIndex
|
||||
|
||||
EXCLUDE={'backup','temp','outputs','code','rag','node_modules','__pycache__','_도면추출결과'}
|
||||
PRIMARY_ROLES={'01','02','03'}
|
||||
SOURCE_POLICY={
|
||||
'Pier':{'C':'종평면 형식 표기, 미확인 시 T형','D':'03:교좌면EL 최솟값','E':'01','F':'01','G':'01','H':'01:말뚝길이 최솟값 mm','I':'02:전후열 중심거리+2000mm, 500mm 올림','J':'02:IP1-IP3 거리 초과 500mm','K':'T형 1개 / π형 2개'},
|
||||
'IP':{'D':'02','E':'02'},
|
||||
'Bearing':{'D':'02','E':'02','F':'03:교량받침EL(몰탈상단)','G':'02','H':'02:받침 표제의 전열/후열'},
|
||||
}
|
||||
|
||||
def discover(folder):
|
||||
root=Path(folder).resolve()
|
||||
if not root.is_dir():raise ExtractionError('도면 폴더가 없습니다.')
|
||||
files=[]
|
||||
for p in root.rglob('*'):
|
||||
rel=p.relative_to(root)
|
||||
if any(part.casefold() in EXCLUDE or part.startswith('.') for part in rel.parts[:-1]):continue
|
||||
if p.is_file() and p.suffix.lower()=='.dwg':files.append(p)
|
||||
if not files:raise ExtractionError('폴더와 하위 폴더에 DWG 파일이 없습니다.')
|
||||
return sorted(files)
|
||||
|
||||
def choose_layout(documents):
|
||||
scored=[]
|
||||
for path in (APP/'layouts').glob('*.json'):
|
||||
p=read_json(path);scores=[]
|
||||
for d,s in p['sources'].items():
|
||||
if d not in documents:continue
|
||||
if documents[d]['sha256']==s['sha256']:scores.append(1);continue
|
||||
sig=set(s.get('signature',[]));ts={norm(e['t']) for e in documents[d]['texts']}
|
||||
scores.append(len(sig&ts)/len(sig) if sig else 0)
|
||||
score=sum(scores)/3
|
||||
if score>=.55:scored.append((score,p))
|
||||
scored.sort(key=lambda x:-x[0])
|
||||
if not scored or len(scored)>1 and abs(scored[0][0]-scored[1][0])<.05:return None
|
||||
return scored[0][1]
|
||||
|
||||
def adaptive_result(profile,documents):
|
||||
"""Move numerical locators via text landmarks; never move manual geometry defaults."""
|
||||
p=copy.deepcopy(profile);indices={d:Index(v['texts']) for d,v in documents.items()}
|
||||
# The general drawing is intentionally unavailable under the current policy.
|
||||
p['rules']=[r for r in p['rules'] if not (r['sheet']=='Pier' and r['cell'][0] in 'CDIJK') and not (r['sheet']=='Bearing' and r['cell'][0]=='F')]
|
||||
p['notes']=[]
|
||||
for rule in p['rules']:
|
||||
if rule['sheet']=='Pier' and rule['cell'].startswith('H'):rule['pile_minimum']=True
|
||||
for ref in rule.get('refs',[])+rule.get('guards',[]):
|
||||
d=ref['doc']
|
||||
if d not in documents:
|
||||
rule['op']='missing';rule['note']='해당 역할의 도면을 찾지 못했습니다.';break
|
||||
if documents[d]['sha256']==p['sources'][d]['sha256']:continue
|
||||
entity=indices[d].locate(ref)
|
||||
if entity:
|
||||
ref.update(handle=entity['h'],x=entity['x'],y=entity['y'])
|
||||
else:
|
||||
# A failed semantic relocation must not accidentally fall back to
|
||||
# an unrelated numeric value remaining at the former coordinate.
|
||||
rule['op']='missing';rule['note']='제목·주변 문자로 추출 위치를 유일하게 대응하지 못했습니다.';break
|
||||
return extract(p,documents)
|
||||
|
||||
def blank_result(name):
|
||||
return dict(name=name,profile='automatic',cells={s:{} for s in HEADERS},records=[],changed_sources=[],notes=[])
|
||||
|
||||
def setcell(result,sheet,cell,value,status='추출',messages=None,evidence=None):
|
||||
result['cells'][sheet][cell]=value
|
||||
result['records']=[r for r in result['records'] if not(r['sheet']==sheet and r['cell']==cell)]
|
||||
result['records'].append(dict(sheet=sheet,cell=cell,value=value,status=status,messages=messages or [],note='도면 내용 및 상대 위치 자동 판독',evidence=evidence or []))
|
||||
|
||||
def combine_values(entries,field):
|
||||
values={e[field] for e in entries}
|
||||
if len(values)==1:return entries[0][field],'추출',[]
|
||||
return None,'미확정',['복수 도면/표의 값 상충: '+' / '.join(map(str,sorted(values,key=str)))]
|
||||
|
||||
def next_500(distance_m):
|
||||
# Remove sub-micrometre coordinate arithmetic noise at exact half-metres.
|
||||
return (math.floor(round(distance_m*1000,6)/500)+1)*500
|
||||
|
||||
def column_count(pier_type):
|
||||
return {'T형':1,'T':1,'Π형':2,'Π':2,'PI형':2}.get(norm(str(pier_type)))
|
||||
|
||||
def bearing_row_width(cells,rows):
|
||||
groups={'전열':[],'후열':[]}
|
||||
for row in rows:
|
||||
kind=cells.get(f'H{row}');xy=[cells.get(f'{c}{row}') for c in 'DE']
|
||||
if kind not in groups or not all(isinstance(v,(int,float)) and math.isfinite(v) for v in xy):return None,None
|
||||
groups[kind].append(xy)
|
||||
if not all(groups.values()):return None,None
|
||||
centers=[tuple(sum(p[k] for p in points)/len(points) for k in [0,1]) for points in groups.values()]
|
||||
distance_mm=math.dist(*centers)*1000
|
||||
if distance_mm<=1e-6:return None,None
|
||||
return math.ceil(round(distance_mm+2000,6)/500)*500,distance_mm
|
||||
|
||||
def apply_input_conventions(result):
|
||||
for cell,p in list(result['cells']['Pier'].items()):
|
||||
if not cell.startswith('B'):continue
|
||||
row=cell[1:];kind=result['cells']['Pier'].get('C'+row)
|
||||
count=column_count(kind)
|
||||
setcell(result,'Pier','K'+row,count,'기본값' if count else '미확인',[f'형식별 지정값: T형 1개, π형 2개. 현재 형식 {kind}'])
|
||||
record=next((r for r in result['records'] if r['sheet']=='Pier' and r['cell']=='H'+row),None)
|
||||
value=result['cells']['Pier'].get('H'+row);length=pile_minimum_m(value)
|
||||
setcell(result,'Pier','H'+row,round(length*1000,6) if length is not None else None,
|
||||
'산정값' if length is not None else '미확인',
|
||||
[f'말뚝길이 최솟값 선택 후 m→mm 변환. 변환 전 값: {value}']+((record or {}).get('messages',[])),
|
||||
(record or {}).get('evidence',[]))
|
||||
|
||||
def apply_derived_piers(result,zrows):
|
||||
cells=result['cells']
|
||||
def rows_for(sheet,p):
|
||||
return sorted(int(c[1:]) for c,v in cells[sheet].items() if c.startswith('B') and v==p)
|
||||
def point(sheet,row):
|
||||
xy=[cells[sheet].get(f'{c}{row}') for c in 'DE']
|
||||
return tuple(xy) if all(isinstance(v,(int,float)) and math.isfinite(v) for v in xy) else None
|
||||
def evidence(sheet,rows):
|
||||
return [e for r in result['records'] if r['sheet']==sheet and r['cell'][0] in 'DE' and int(r['cell'][1:]) in rows for e in r['evidence']]
|
||||
for cell,p in list(cells['Pier'].items()):
|
||||
if not cell.startswith('B'):continue
|
||||
row=int(cell[1:])
|
||||
if not cells['Pier'].get(f'C{row}'):setcell(result,'Pier',f'C{row}','T형','기본값',['사용자 지정 기본 교각형식. 형상 판독값 아님.'])
|
||||
entries=[e for e in zrows if e['p']==p and 'seat' in e]
|
||||
grouped={no:[e for e in entries if e['no']==no] for no in {e['no'] for e in entries}}
|
||||
seats=[combine_values(es,'seat')[0] for es in grouped.values()]
|
||||
expected={cells['Bearing'].get(f'C{r}') for r in rows_for('Bearing',p)}
|
||||
if seats and None not in seats and expected.issubset(grouped):
|
||||
setcell(result,'Pier',f'D{row}',min(seats),'산정값',['교각별 교좌면 EL 최솟값 (m)'],[e for r in entries for e in r['evidence']])
|
||||
else:setcell(result,'Pier',f'D{row}',None,'미확정',['교좌면 EL 누락 또는 동일 받침의 복수 값 상충'],[e for r in entries for e in r['evidence']])
|
||||
br=rows_for('Bearing',p);width,dist=bearing_row_width(cells['Bearing'],br)
|
||||
if width is not None:
|
||||
setcell(result,'Pier',f'I{row}',width,'산정값',[f'전열/후열 받침 좌표 평균점 사이 거리 {dist:.6f} mm + 2000mm, 500mm 단위 올림'],evidence('Bearing',br)+[e for r in result['records'] if r['sheet']=='Bearing' and r['cell'].startswith('H') and int(r['cell'][1:]) in br for e in r['evidence']])
|
||||
else:setcell(result,'Pier',f'I{row}',None,'미확인',['폭 산정에 필요한 전열·후열 구분 또는 좌표 부족/상충'])
|
||||
ip=rows_for('IP',p);selected=[]
|
||||
for number in [1,3]:
|
||||
matches=[r for r in ip if norm(str(cells['IP'].get(f'C{r}'))).replace('.','') in [str(number),f'IP{number}']]
|
||||
if len(matches)==1:selected.append(matches[0])
|
||||
points=[point('IP',r) for r in selected]
|
||||
if len(points)==2 and all(v is not None for v in points) and math.dist(*points)>0:
|
||||
dist=math.dist(*points)
|
||||
setcell(result,'Pier',f'J{row}',next_500(dist),'산정값',[f'IP1-IP3 평면거리 {dist*1000:.6f} mm를 초과하는 다음 500mm'],evidence('IP',selected))
|
||||
else:setcell(result,'Pier',f'J{row}',None,'미확인',['길이 산정에 필요한 유일한 IP1·IP3 좌표 부족/상충'])
|
||||
|
||||
def assemble_group(name,documents_by_role):
|
||||
# Multiple revisions are all read. Do not silently choose a "latest" filename.
|
||||
documents_by_role={d:v for d,v in documents_by_role.items() if d!='04'}
|
||||
simple={d:items[0][1] for d,items in documents_by_role.items() if len(items)==1}
|
||||
layout=choose_layout(simple)
|
||||
if layout:result=adaptive_result(layout,{d:simple[d] for d in ['01','02'] if d in simple})
|
||||
else:result=blank_result(name)
|
||||
result['name']=name;result['profile']='automatic'
|
||||
sources={};brows=[];zrows=[];prows=[];observed=set();ip_points=[]
|
||||
result['source_policy']=SOURCE_POLICY
|
||||
result['supplementary_drawings']=[]
|
||||
directions={drawing_direction(e['t']) for _,data in documents_by_role.get('02',[]) for e in data['texts']}-{None}
|
||||
for doc,items in documents_by_role.items():
|
||||
for index,(path,data) in enumerate(items):
|
||||
lines=LineIndex(data.get('segments')) if data.get('segments') else None
|
||||
key=doc if len(items)==1 else f'{doc}_{index+1}'
|
||||
sources[key]=dict(path=str(path),sha256=data['sha256'])
|
||||
if doc=='03':
|
||||
result['supplementary_drawings'].append(dict(doc=key,role=ROLES[doc],
|
||||
status='표고 추출 원문',texts=data['texts'],
|
||||
note='받침 Z=몰탈상단, 두부보 EL=교좌면 EL 최솟값'))
|
||||
rows,issues=calculation_elevations(data['texts'],lines);result['notes'].extend(issues)
|
||||
for b in integrated_bearings(data['texts'],rows):
|
||||
b['evidence']=[dict(doc=key,**e) for e in b['evidence']];brows.append(b);observed.add(b['p'])
|
||||
for row in rows:
|
||||
if len(directions)==1 and row['direction'] is not None and row['direction'] not in directions:continue
|
||||
row['evidence']=[dict(doc=key,**e) for e in row['evidence']];zrows.append(row);observed.add(row['p'])
|
||||
if doc=='01':
|
||||
for row in pier_tables(data['texts'],doc)+profile_fields(data['texts'],lines):
|
||||
row['evidence']=[dict(doc=key,**e) for e in row['evidence']];prows.append(row);observed.add(row['p'])
|
||||
if doc=='02':
|
||||
for point in coordinate_points(data['texts']):
|
||||
point['evidence']=[dict(doc=key,**e) for e in point['evidence']];ip_points.append(point)
|
||||
rows,issues=bearings(data['texts'],lines);result['notes'].extend(issues)
|
||||
for row in rows:
|
||||
row['evidence']=[dict(doc=key,**e) for e in row['evidence']];brows.append(row);observed.add(row['p'])
|
||||
primary_piers={r['p'] for r in brows+zrows}
|
||||
if primary_piers:
|
||||
observed=primary_piers
|
||||
prows=[r for r in prows if r['p'] in primary_piers]
|
||||
result['sources']=sources
|
||||
if len(directions)>1:result['notes'].append('복수 방향 도면: 교각/받침 번호가 겹치면 상충값으로 보존하며 임의 방향을 선택하지 않음.')
|
||||
if brows or zrows:
|
||||
result['cells']['Bearing']={};result['records']=[r for r in result['records'] if r['sheet']!='Bearing']
|
||||
keys=sorted({(r['p'],r['no']) for r in brows+zrows},key=lambda x:(int(x[0][1:]),x[1]))
|
||||
for row,(p,no) in enumerate(keys,3):
|
||||
setcell(result,'Bearing',f'B{row}',p,'식별자');setcell(result,'Bearing',f'C{row}',no,'식별자')
|
||||
for col,field,entries in [('D','x',brows),('E','y',brows),('F','z',zrows),('G','angle',brows),('H','row_type',brows)]:
|
||||
es=[e for e in entries if e['p']==p and e['no']==no and field in e]
|
||||
if es:
|
||||
value,status,messages=combine_values(es,field)
|
||||
setcell(result,'Bearing',f'{col}{row}',value,status,messages,[e for r in es for e in r['evidence']])
|
||||
else:setcell(result,'Bearing',f'{col}{row}',None,'미확인',[f'{p} 받침 {no}: 대응 표를 찾지 못했습니다.'])
|
||||
# Newly discovered piers are never silently omitted because an old layout had fewer rows.
|
||||
existing={v for c,v in result['cells']['Pier'].items() if c.startswith('B')}
|
||||
for p in sorted(observed-existing,key=lambda p:int(p[1:])):
|
||||
row=max([int(c[1:]) for c in result['cells']['Pier']]+[2])+1
|
||||
setcell(result,'Pier',f'B{row}',p,'식별자')
|
||||
for col in 'CDEFGHIJK':setcell(result,'Pier',f'{col}{row}',None,'미확인',['형상·치수·종단 값의 교각 대응 근거 미확인'])
|
||||
for p in observed:
|
||||
rows=[int(c[1:]) for c,v in result['cells']['Pier'].items() if c.startswith('B') and v==p]
|
||||
if len(rows)!=1:continue
|
||||
for col in 'CDEFGHIJK':
|
||||
entries=[e for e in prows if e['p']==p and e['col']==col]
|
||||
if entries:
|
||||
explicit=[e for e in entries if not e.get('inferred')]
|
||||
if explicit:entries=explicit
|
||||
elif layout:continue # Preserve audited locators, including unresolved conflicts.
|
||||
value,status,messages=combine_values(entries,'value')
|
||||
if col=='H':
|
||||
lengths=[pile_minimum_m(e['value']) for e in entries]
|
||||
value=min(lengths) if None not in lengths else None
|
||||
status='산정값' if value is not None else '미확인';messages=['말뚝길이 표기의 최솟값 선택']
|
||||
if any(e.get('inferred') for e in entries):
|
||||
messages+=['교각 표기와 종평면 인접 문자의 상대 위치로 대응: 지시선 연결 검토 필요']
|
||||
if value is not None:status='검토필요'
|
||||
setcell(result,'Pier',f'{col}{rows[0]}',value,status,messages,[e for r in entries for e in r['evidence']])
|
||||
# World-coordinate proximity, ambiguity rejection, then CAD top-to-bottom ordering.
|
||||
linked=associate_ips(ip_points,brows)
|
||||
if not result['cells']['IP']:
|
||||
row=3
|
||||
for p in sorted(observed,key=lambda p:int(p[1:])):
|
||||
points=linked.get(p,[])
|
||||
for i in range(4):
|
||||
point=points[i] if i<3 and i<len(points) else None
|
||||
setcell(result,'IP',f'B{row}',p,'식별자')
|
||||
setcell(result,'IP',f'C{row}',f'IP{i+1}','식별자')
|
||||
for col,field in [('D','x'),('E','y')]:
|
||||
setcell(result,'IP',f'{col}{row}',point[field] if point else None,'산정값' if point else '미확인',
|
||||
['받침 좌표 범위와 지시점 거리로 교각 대응, CAD 위→아래 순서' if point else '교각에 유일하게 연결되는 IP 3점 미확인'],point['evidence'] if point else [])
|
||||
if i==3:
|
||||
for col in 'DEF':setcell(result,'IP',f'{col}{row}',None,'기본값',['IP4는 빈칸으로 유지'])
|
||||
else:setcell(result,'IP',f'F{row}',None,'대상외',['꺾임각은 요청 항목 외'])
|
||||
row+=1
|
||||
max_ip_row=max([int(a[1:]) for a in result['cells']['IP']]+[2])
|
||||
for p in observed:
|
||||
rows=[r for r in range(3,max_ip_row+1) if result['cells']['IP'].get(f'B{r}')==p]
|
||||
fourth=[r for r in rows if norm(str(result['cells']['IP'].get(f'C{r}'))).replace('.','') in ['4','IP4']]
|
||||
if fourth:
|
||||
for r in fourth:
|
||||
for col in 'DEF':setcell(result,'IP',f'{col}{r}',None,'기본값',['IP4는 빈칸으로 유지'])
|
||||
continue
|
||||
row=max_ip_row+1;max_ip_row=row
|
||||
for col,value in [('B',p),('C','IP4'),('D',None),('E',None),('F',None)]:
|
||||
setcell(result,'IP',f'{col}{row}',value,'식별자' if col in 'BC' else '기본값',['IP4는 빈칸으로 유지'] if col not in 'BC' else [])
|
||||
result['coordinate_callouts']=ip_points
|
||||
apply_derived_piers(result,zrows)
|
||||
apply_input_conventions(result)
|
||||
result['notes']+=['종평면 형식 표기 우선, 미확인 시 기본 T형. 두부보 치수는 좌표 기반 산정값.',
|
||||
'폭=전후열 좌표 평균점 거리+2000mm를 500mm 올림. 길이=IP1-IP3 거리를 초과하는 다음 500mm.',
|
||||
'받침 Z=ELEVATION 몰탈상단, 두부보 EL=교각별 교좌면 EL 최솟값.',
|
||||
'폴더 자동 판독: 파일명 대신 도면 문자 내용으로 역할을 분류.',
|
||||
'좌표표 열은 헤더로 인식. 기타 값은 제목·주변 문자의 상대 배치를 대조.',
|
||||
'일반 형상 인식/OCR은 지원하지 않음. 근거가 모호한 값은 공란·검토 항목으로 보존.']
|
||||
for d in PRIMARY_ROLES:
|
||||
if d not in documents_by_role:result['notes'].append(f'{ROLES[d]} 역할 도면 미발견')
|
||||
return result
|
||||
|
||||
def run_folder(folder,output=None,converter=None,log=lambda s:None):
|
||||
root=Path(folder).resolve();files=discover(root)
|
||||
output=Path(output).resolve() if output else root/'_도면추출결과'
|
||||
output.mkdir(parents=True,exist_ok=True);exe=converter_path(converter)
|
||||
groups={};inventory=[]
|
||||
for path in files:
|
||||
if output==path.parent or output in path.parents:continue
|
||||
try:
|
||||
data=convert(path,output/'.cache',exe,log);roles=classify(data['texts'])
|
||||
inventory.append(dict(file=str(path),roles=roles,status='인식' if roles else '역할 미확정',text_count=len(data['texts'])))
|
||||
log(f'{path.name}: '+(', '.join(ROLES[d] for d in roles) or '문자 읽기 완료, 역할 미확정'))
|
||||
# Each folder is an independent drawing set: identical P1 IDs belonging
|
||||
# to different bridges must never be combined accidentally.
|
||||
for d in roles:
|
||||
if d=='04':continue # User policy: general pier drawings are not inputs.
|
||||
bucket=groups.setdefault(path.parent,{}).setdefault(d,[])
|
||||
if not any(old['sha256']==data['sha256'] for _,old in bucket):bucket.append((path,data))
|
||||
except Exception as e:
|
||||
inventory.append(dict(file=str(path),roles=[],status='읽기 실패',reason=str(e)));log(f'{path.name}: 읽기 실패 — {e}')
|
||||
if not groups:
|
||||
write_json(output/'파일판독내역.json',inventory)
|
||||
raise ExtractionError('모든 DWG를 읽었으나 대상 역할을 식별하지 못했습니다. 파일판독내역.json을 확인하세요.')
|
||||
# A single unambiguous set may be distributed over category subfolders.
|
||||
if len(groups)>1 and all(sum(len(g.get(d,[])) for g in groups.values())<=1 for d in ROLES):
|
||||
merged={}
|
||||
for g in groups.values():
|
||||
for d,items in g.items():merged.setdefault(d,[]).extend(items)
|
||||
groups={root:merged}
|
||||
destdir=output/datetime.now().strftime('%Y%m%d_%H%M%S_%f');destdir.mkdir()
|
||||
write_json(destdir/'파일판독내역.json',inventory);outputs=[]
|
||||
for group,docs in groups.items():
|
||||
simple={d:a[0][1] for d,a in docs.items() if len(a)==1};layout=choose_layout(simple)
|
||||
unchanged=layout and all(d in simple and simple[d]['sha256']==s['sha256'] for d,s in layout['sources'].items())
|
||||
name=layout['name'] if unchanged else group.name
|
||||
log(f'{name}: 표·교각 번호 및 상대 배치 분석 중')
|
||||
result=assemble_group(name,docs)
|
||||
if not any(result['cells'][s] for s in HEADERS):continue
|
||||
safe=re.sub(r'[^\w가-힣().-]+','_',name)[:45]
|
||||
target=destdir/(safe+f'_{len(outputs)+1}_교각입력데이터.xlsx')
|
||||
export_workbook(result,target);write_json(target.with_suffix('.json'),result)
|
||||
with target.with_suffix('.csv').open('w',encoding='utf-8-sig',newline='') as f:
|
||||
w=csv.writer(f);w.writerow(['시트','셀','상태','값','사유'])
|
||||
for r in result['records']:
|
||||
if r['status'] not in ['식별자','추출','등록값']:w.writerow([r['sheet'],r['cell'],r['status'],r['value'],' / '.join(r['messages'])])
|
||||
outputs.append((target,result));log(f'엑셀 저장: {target.name}')
|
||||
if not outputs:raise ExtractionError('역할은 식별했지만 교각/좌표 데이터를 추출하지 못했습니다. 파일판독내역을 확인하세요.')
|
||||
return destdir,outputs,inventory
|
||||
@@ -0,0 +1,31 @@
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from .api import extract_folder,check_environment
|
||||
from .core import ExtractionError
|
||||
|
||||
def main(argv=None):
|
||||
parser=argparse.ArgumentParser(description='Offline DWG bridge extractor (JSON stdout, progress stderr)')
|
||||
parser.add_argument('folder',nargs='?')
|
||||
parser.add_argument('--output',help='Result directory (default: input/_도면추출결과)')
|
||||
parser.add_argument('--converter',help='LibreDWG dwgread executable path')
|
||||
parser.add_argument('--check',action='store_true',help='Check runtime dependencies only')
|
||||
parser.add_argument('--include-data',action='store_true',help='Include all cell data in JSON stdout')
|
||||
args=parser.parse_args(argv)
|
||||
if hasattr(sys.stdout,'reconfigure'):sys.stdout.reconfigure(encoding='utf-8')
|
||||
if hasattr(sys.stderr,'reconfigure'):sys.stderr.reconfigure(encoding='utf-8')
|
||||
if args.check:
|
||||
result=check_environment(converter_path=args.converter)
|
||||
print(json.dumps(result,ensure_ascii=False))
|
||||
return 0 if result['converter_available'] else 2
|
||||
if not args.folder:parser.error('folder is required unless --check is used')
|
||||
try:
|
||||
result=extract_folder(args.folder,output_dir=args.output,converter_path=args.converter,
|
||||
on_progress=lambda msg:print(msg,file=sys.stderr,flush=True))
|
||||
print(json.dumps(result.to_dict(include_data=args.include_data),ensure_ascii=False))
|
||||
return 0
|
||||
except ExtractionError as exc:
|
||||
print(json.dumps(dict(schema_version='1.0',status='error',error=str(exc)),ensure_ascii=False))
|
||||
return 1
|
||||
|
||||
if __name__=='__main__':raise SystemExit(main())
|
||||
@@ -0,0 +1,284 @@
|
||||
"""Offline DWG extraction. No AI/network calls and no use of previous output values."""
|
||||
from __future__ import annotations
|
||||
import csv
|
||||
import hashlib
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
import re
|
||||
import subprocess
|
||||
import tempfile
|
||||
from .geometry import scene
|
||||
from datetime import datetime
|
||||
|
||||
APP = Path(__file__).resolve().parent
|
||||
ROLES = {'01': '종평면도', '02': '받침배치도', '04': '교각일반도'}
|
||||
ROLES['03'] = 'ELEVATION 산출근거도'
|
||||
HEADERS = {
|
||||
'Pier': ['교각명', '교각형식', '두부보\nEL_m', '기초 하단\nEL_m', '지반고\nEL_m', '기초 형식', '말뚝\n길이_mm', '두부보\n폭_mm', '두부보\n길이_mm', '기둥갯수'],
|
||||
'IP': ['교각명', 'IP점', 'x_m', 'y_m', '코핑 꺾임각'],
|
||||
'Bearing': ['교각명', '받침번호', 'x_m', 'y_m', 'z_m', '방위각', '전열/후열'],
|
||||
}
|
||||
|
||||
class ExtractionError(ValueError):
|
||||
pass
|
||||
|
||||
def read_json(path):
|
||||
return json.loads(Path(path).read_text(encoding='utf-8-sig'))
|
||||
|
||||
def write_json(path, data):
|
||||
Path(path).write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding='utf-8')
|
||||
|
||||
def sha256(path):
|
||||
h = hashlib.sha256()
|
||||
with Path(path).open('rb') as f:
|
||||
for chunk in iter(lambda: f.read(1024 * 1024), b''):
|
||||
h.update(chunk)
|
||||
return h.hexdigest()
|
||||
|
||||
def converter_path(explicit=None):
|
||||
# Explicit configuration is authoritative: a typo must not select another binary.
|
||||
configured = explicit or os.environ.get('BRIDGE_DWGREAD')
|
||||
if configured:
|
||||
candidate = Path(configured).expanduser()
|
||||
if candidate.is_file():
|
||||
return candidate.resolve()
|
||||
raise ExtractionError(f'Configured DWG converter does not exist: {configured}')
|
||||
found = shutil.which('dwgread') or shutil.which('dwgread.exe')
|
||||
if found:
|
||||
return Path(found).resolve()
|
||||
raise ExtractionError('DWG converter not found. Pass converter_path or set BRIDGE_DWGREAD / PATH.')
|
||||
|
||||
|
||||
def compact(raw):
|
||||
if not isinstance(raw.get('OBJECTS'), list):
|
||||
raise ExtractionError('변환 결과에 DWG 객체 목록이 없습니다.')
|
||||
texts = []
|
||||
for e in raw['OBJECTS']:
|
||||
if e.get('entity') not in ('TEXT','MTEXT','ATTRIB','ATTDEF','DIMENSION_LINEAR','DIMENSION_ALIGNED'):
|
||||
continue
|
||||
t = e.get('text_value', e.get('text', ''))
|
||||
p = e.get('ins_pt', e.get('text_midpt', [0,0]))
|
||||
if not isinstance(t, str) or not t.strip():
|
||||
continue
|
||||
texts.append(dict(h=e['handle'][-1], x=p[0], y=p[1], t=t))
|
||||
if not texts:
|
||||
raise ExtractionError('추출 가능한 CAD 문자가 없습니다. 스캔 이미지 도면은 지원하지 않습니다.')
|
||||
return texts
|
||||
|
||||
def read_cad_json(path):
|
||||
"""Recover independently valid text entities if LibreDWG emits invalid geometry JSON."""
|
||||
raw=Path(path).read_text(encoding='utf-8',errors='replace')
|
||||
warnings=[]
|
||||
if '\ufffd' in raw:warnings.append('변환기의 잘못된 UTF-8 문자를 대체함; 해당 문자는 검토 필요')
|
||||
try:return json.loads(raw),warnings
|
||||
except json.JSONDecodeError:
|
||||
decoder=json.JSONDecoder();objects=[];failed=0
|
||||
for m in re.finditer(r'(?m)^ \{\s*\n\s*"(?:entity|object)":\s*"[^\"]+"',raw):
|
||||
try:
|
||||
obj,_=decoder.raw_decode(raw,m.start()+4)
|
||||
if '\ufffd' in obj.get('text_value',obj.get('text','')):failed+=1;continue
|
||||
objects.append(obj)
|
||||
except (json.JSONDecodeError,TypeError):failed+=1
|
||||
if not objects:raise ExtractionError('변환 JSON 오류 및 복구 가능한 CAD 문자 없음')
|
||||
warnings.append(f'변환 JSON 오류: 독립 CAD 객체 {len(objects)}개 복구, 손상 객체 {failed}개 제외')
|
||||
return {'OBJECTS':objects},warnings
|
||||
|
||||
def convert(source, cache, exe, log=lambda s: None):
|
||||
source, cache = Path(source), Path(cache)
|
||||
if source.suffix.lower() != '.dwg' or not source.is_file():
|
||||
raise ExtractionError(f'DWG 파일을 확인하세요: {source}')
|
||||
digest = sha256(source)
|
||||
# Both input content and converter version participate in the cache identity.
|
||||
key = digest + '-' + sha256(exe)[:12]
|
||||
cache.mkdir(parents=True, exist_ok=True)
|
||||
dest = cache/(key+'.json')
|
||||
if dest.exists():
|
||||
try:
|
||||
data = read_json(dest)
|
||||
if data.get('scene_version')==3 and data['sha256']==digest and isinstance(data['texts'],list) and data['texts']:
|
||||
log(f'{source.name}: 변경 없음, 캐시 사용')
|
||||
return data
|
||||
except (ValueError, KeyError):
|
||||
pass
|
||||
log(f'{source.name}: DWG 문자 읽는 중')
|
||||
with tempfile.TemporaryDirectory(prefix='dwg_', dir=cache) as tmp:
|
||||
raw = Path(tmp)/'raw.json'
|
||||
try:
|
||||
p = subprocess.run([str(exe), '-O', 'JSON', '-o', str(raw), str(source.resolve())],
|
||||
capture_output=True, timeout=180, creationflags=getattr(subprocess,'CREATE_NO_WINDOW',0))
|
||||
except subprocess.TimeoutExpired as e:
|
||||
raise ExtractionError(f'DWG 변환 시간 초과: {source.name}') from e
|
||||
if p.returncode != 0 or not raw.exists():
|
||||
message = p.stderr.decode('utf8', errors='replace')[-1200:]
|
||||
raise ExtractionError(f'DWG 변환 실패: {source.name}\n{message}')
|
||||
decoded,warnings=read_cad_json(raw)
|
||||
texts,segments,scene_warnings=scene(decoded)
|
||||
if not texts:raise ExtractionError('표시 공간에 추출 가능한 CAD 문자가 없습니다.')
|
||||
data = dict(sha256=digest,texts=texts,segments=segments,scene_version=3,conversion_warnings=warnings+scene_warnings)
|
||||
if sha256(source)!=digest:
|
||||
raise ExtractionError('읽는 동안 원본 도면이 변경되었습니다. 저장 후 다시 실행하세요.')
|
||||
staging = Path(tmp)/'compact.json'
|
||||
write_json(staging, data)
|
||||
os.replace(staging, dest)
|
||||
return data
|
||||
|
||||
def load_profile(path):
|
||||
p = read_json(path)
|
||||
if p.get('version') != 1 or not p.get('rules') or set(p.get('sources',{})) != set(ROLES):
|
||||
raise ExtractionError('지원하지 않는 추출 규칙 파일입니다.')
|
||||
seen = set()
|
||||
for r in p['rules']:
|
||||
sheet, cell = r.get('sheet'), r.get('cell','')
|
||||
if sheet not in HEADERS or not re.fullmatch(r'[B-K][1-9]\d{0,4}',cell):
|
||||
raise ExtractionError(f'대상 셀 오류: {sheet}!{cell}')
|
||||
if int(re.search(r'\d+',cell)[0])<3 or ord(cell[0])-66>=len(HEADERS[sheet]):
|
||||
raise ExtractionError(f'입력 표 밖 셀: {sheet}!{cell}')
|
||||
if (sheet,cell) in seen:
|
||||
raise ExtractionError(f'중복 규칙: {sheet}!{cell}')
|
||||
seen.add((sheet,cell))
|
||||
if r.get('op') not in ['label','manual','missing','number','min','agree','range','text']:
|
||||
raise ExtractionError(f'지원하지 않는 연산: {r.get("op")}')
|
||||
if r['op'] not in ['label','manual','missing'] and not r.get('refs'):
|
||||
raise ExtractionError(f'추출 위치 없음: {sheet}!{cell}')
|
||||
if r['op']=='manual' and r.get('doc') not in ROLES:
|
||||
raise ExtractionError('등록값의 근거 도면이 필요합니다.')
|
||||
for ref in r.get('refs',[])+r.get('guards',[]):
|
||||
if ref.get('doc') not in ROLES:
|
||||
raise ExtractionError('알 수 없는 근거 도면 역할입니다.')
|
||||
rx=re.compile(ref['pattern'])
|
||||
if not 0<=int(ref.get('group',1))<=rx.groups:
|
||||
raise ExtractionError('정규식 그룹 번호가 맞지 않습니다.')
|
||||
return p
|
||||
|
||||
def source_files(profile, folder, overrides=None):
|
||||
folder=Path(folder); overrides=overrides or {}; found={}
|
||||
for doc,s in profile['sources'].items():
|
||||
if overrides.get(doc):p=Path(overrides[doc])
|
||||
else:
|
||||
# No recursive guessing; explicitly selected folder only.
|
||||
matches=[f for f in folder.iterdir() if f.is_file() and f.name.casefold()==s['filename'].casefold()]
|
||||
if len(matches)!=1:
|
||||
raise ExtractionError(f'{ROLES[doc]} 파일을 선택하세요: {s["filename"]}')
|
||||
p=matches[0]
|
||||
if not p.is_file() or p.suffix.lower()!='.dwg':
|
||||
raise ExtractionError(f'{ROLES[doc]} DWG가 없습니다: {p}')
|
||||
found[doc]=p.resolve()
|
||||
if len(set(found.values()))!=3:
|
||||
raise ExtractionError('서로 다른 종평면도·받침배치도·교각일반도 3개를 선택하세요.')
|
||||
return found
|
||||
|
||||
def resolve(ref, documents):
|
||||
texts=documents[ref['doc']]['texts']
|
||||
# Handle is validated against geometry and text grammar, never trusted alone.
|
||||
spatial=[e for e in texts if abs(e['x']-ref['x'])<0.01 and abs(e['y']-ref['y'])<0.01]
|
||||
matches=[e for e in spatial if re.fullmatch(ref['pattern'],e['t'])]
|
||||
exact=[e for e in matches if e['h']==ref['handle']]
|
||||
if len(exact)==1:
|
||||
entity=exact[0]
|
||||
elif len(matches)==1:
|
||||
entity=matches[0]
|
||||
else:
|
||||
raise ExtractionError(f"문자 위치 식별 실패({len(matches)}개): {ROLES[ref['doc']]} handle {ref['handle']:X}")
|
||||
value=re.fullmatch(ref['pattern'],entity['t']).group(ref.get('group',1))
|
||||
return value,dict(doc=ref['doc'],**entity)
|
||||
|
||||
def evaluate(rule, documents, changed):
|
||||
op=rule['op']; evidence=[]; status='추출'; messages=[]
|
||||
if op=='label':return rule.get('value'),'식별자',[],[]
|
||||
if op=='missing':return None,'미확인',[rule.get('note','추출 규칙 미등록')],[]
|
||||
if op=='manual':
|
||||
if rule['doc'] in changed:
|
||||
return None,'재검토',['도면 변경: 형상 판독 등록값을 자동 적용하지 않았습니다.'],[]
|
||||
return rule.get('value'),'등록값',[rule.get('note','등록 시 형상 판독값')],[]
|
||||
for ref in rule.get('guards',[]):
|
||||
_,e=resolve(ref,documents);evidence.append(e)
|
||||
values=[]
|
||||
for ref in rule['refs']:
|
||||
value,e=resolve(ref,documents);values.append(value);evidence.append(e)
|
||||
if rule.get('pile_minimum'):
|
||||
from .semantic import pile_minimum_m
|
||||
lengths=[pile_minimum_m(v) for v in values]
|
||||
value=min(lengths) if lengths and None not in lengths else None
|
||||
status='산정값' if value is not None else '미확인'
|
||||
messages.append('말뚝길이 표기 최솟값 선택: '+' / '.join(values))
|
||||
elif op=='text':value=values[0].strip()
|
||||
elif op=='range':
|
||||
value=values[0].replace(' ','')
|
||||
if '~' in value:status='검토필요';messages.append('도면 범위 원문. 단일 모델 입력 길이는 미확정.')
|
||||
else:value=float(value)
|
||||
else:
|
||||
nums=[float(v) for v in values]
|
||||
if not all(math.isfinite(v) for v in nums):raise ExtractionError('유한 숫자가 아닙니다.')
|
||||
if op=='agree' and max(nums)-min(nums)>1e-8:
|
||||
value=None;status='미확정';messages.append('도면 표기 상충: '+' / '.join(map(str,nums)))
|
||||
else:
|
||||
value=(min(nums) if op=='min' else nums[0])*rule.get('scale',1)
|
||||
if rule.get('scale')==1000:value=round(value,6)
|
||||
if rule.get('review'):messages.append(rule['review']);status='검토필요'
|
||||
if any(e['doc'] in changed for e in evidence):
|
||||
messages.append('등록 후 도면 변경됨. 동일 위치에서 재추출한 값의 의미를 확인하세요.')
|
||||
if status=='추출':status='재검토'
|
||||
return value,status,messages,evidence
|
||||
|
||||
def extract(profile, documents):
|
||||
changed={d for d in documents if documents[d]['sha256']!=profile['sources'][d]['sha256']}
|
||||
cells={s:{} for s in HEADERS};records=[]
|
||||
for rule in profile['rules']:
|
||||
try:value,status,messages,evidence=evaluate(rule,documents,changed)
|
||||
except (ExtractionError,ValueError,IndexError) as e:
|
||||
value,status,messages,evidence=None,'추출실패',[str(e)],[]
|
||||
cells[rule['sheet']][rule['cell']]=value
|
||||
records.append(dict(sheet=rule['sheet'],cell=rule['cell'],value=value,status=status,
|
||||
messages=messages,note=rule.get('note',''),evidence=evidence))
|
||||
# Every labeled record must have all non-optional fields registered.
|
||||
for sheet,values in cells.items():
|
||||
rows={int(c[1:]) for c in values if c.startswith('B') and values[c] is not None}
|
||||
for row in sorted(rows):
|
||||
for col in range(2,2+len(HEADERS[sheet])):
|
||||
cell=f'{chr(64+col)}{row}'
|
||||
if cell not in values:
|
||||
values[cell]=None
|
||||
records.append(dict(sheet=sheet,cell=cell,value=None,status='미확인',messages=['필드 추출 규칙 미등록'],note='',evidence=[]))
|
||||
return dict(name=profile['name'],profile=profile['id'],cells=cells,records=records,
|
||||
changed_sources=sorted(changed),notes=profile.get('notes',[]))
|
||||
|
||||
def run(profile_path, folder, output, converter=None, overrides=None, log=lambda s:None):
|
||||
from .workbook import export_workbook
|
||||
profile=load_profile(profile_path)
|
||||
paths=source_files(profile,folder,overrides)
|
||||
exe=converter_path(converter)
|
||||
output=Path(output);output.mkdir(parents=True,exist_ok=True)
|
||||
docs={d:convert(p,output/'.cache',exe,log) for d,p in paths.items()}
|
||||
result=extract(profile,docs)
|
||||
result['sources']={d:dict(path=str(paths[d]),sha256=docs[d]['sha256']) for d in paths}
|
||||
if all(r['status']=='추출실패' for r in result['records'] if r['status'] not in ['식별자','등록값','미확인']):
|
||||
raise ExtractionError('이 도면은 선택한 추출 규칙과 맞지 않습니다. 새 규칙을 등록하세요.')
|
||||
safe=re.sub(r'[^\w가-힣().-]+','_',profile['name'])[:45]
|
||||
run_dir=output/(safe+'_'+datetime.now().strftime('%Y%m%d_%H%M%S_%f'))
|
||||
run_dir.mkdir(exist_ok=False)
|
||||
dest=run_dir/(safe+'_교각입력데이터.xlsx')
|
||||
export_workbook(result,dest)
|
||||
write_json(run_dir/'추출근거.json',result)
|
||||
with (run_dir/'검토항목.csv').open('w',encoding='utf-8-sig',newline='') as f:
|
||||
w=csv.writer(f);w.writerow(['시트','셀','상태','값','사유'])
|
||||
for r in result['records']:
|
||||
if r['status'] not in ['추출','식별자','등록값']:
|
||||
w.writerow([r['sheet'],r['cell'],r['status'],r['value'], ' / '.join(r['messages'])])
|
||||
log(f'완료: {dest}')
|
||||
return dest,result
|
||||
|
||||
def index_files(paths,output,converter=None,log=lambda s:None):
|
||||
"""Export searchable CAD text for offline, code-free CSV mapping registration."""
|
||||
output=Path(output);output.mkdir(parents=True,exist_ok=True);exe=converter_path(converter)
|
||||
manifest={}
|
||||
for doc,p in paths.items():
|
||||
data=convert(p,output/'.cache',exe,log)
|
||||
manifest[doc]=dict(filename=Path(p).name,sha256=data['sha256'],texts=data['texts'])
|
||||
with (output/f'{doc}_문자목록.tsv').open('w',encoding='utf-8-sig',newline='') as f:
|
||||
w=csv.writer(f,delimiter='\t');w.writerow(['handle(10진수)','도면X','도면Y','문자'])
|
||||
for e in data['texts']:w.writerow([e['h'],e['x'],e['y'],e['t']])
|
||||
write_json(output/'문자색인.json',manifest)
|
||||
return output/'문자색인.json'
|
||||
@@ -0,0 +1,122 @@
|
||||
"""Offline 2-D block expansion and bounded line connectivity for CAD annotations."""
|
||||
import math
|
||||
import bisect
|
||||
|
||||
class LineIndex:
|
||||
def __init__(self,segments):
|
||||
self.segments=segments or []
|
||||
self.horizontal=sorted((s['a'][1],i) for i,s in enumerate(self.segments) if abs(s['a'][1]-s['b'][1])<1e-7)
|
||||
self.ys=[v[0] for v in self.horizontal]
|
||||
lengths=sorted(math.dist(s['a'],s['b']) for s in self.segments)
|
||||
self.size=max(lengths[len(lengths)//2]*10,1e-6) if lengths else 1
|
||||
self.grid={};self.long=[]
|
||||
for i,s in enumerate(self.segments):
|
||||
x1,x2=sorted(math.floor(p[0]/self.size) for p in [s['a'],s['b']]);y1,y2=sorted(math.floor(p[1]/self.size) for p in [s['a'],s['b']])
|
||||
if (x2-x1+1)*(y2-y1+1)>100:self.long.append(i);continue
|
||||
for x in range(x1,x2+1):
|
||||
for y in range(y1,y2+1):self.grid.setdefault((x,y),[]).append(i)
|
||||
def near(self,x,y,r):
|
||||
x1,x2=math.floor((x-r)/self.size),math.floor((x+r)/self.size);y1,y2=math.floor((y-r)/self.size),math.floor((y+r)/self.size)
|
||||
if (x2-x1+1)*(y2-y1+1)>10000:return self.segments
|
||||
ids=set(self.long)
|
||||
for ix in range(x1,x2+1):
|
||||
for iy in range(y1,y2+1):ids.update(self.grid.get((ix,iy),[]))
|
||||
return [self.segments[i] for i in ids]
|
||||
def horizontal_near(self,y,r):
|
||||
return [self.segments[i] for _,i in self.horizontal[bisect.bisect_left(self.ys,y-r):bisect.bisect_right(self.ys,y+r)]]
|
||||
|
||||
IDENTITY=(1,0,0,1,0,0)
|
||||
def transform(m,p):
|
||||
a,b,c,d,x,y=m
|
||||
return [a*p[0]+c*p[1]+x,b*p[0]+d*p[1]+y]
|
||||
def compose(a,b):
|
||||
p=transform(a,[b[4],b[5]])
|
||||
return (a[0]*b[0]+a[2]*b[1],a[1]*b[0]+a[3]*b[1],a[0]*b[2]+a[2]*b[3],a[1]*b[2]+a[3]*b[3],*p)
|
||||
def handle(obj):return obj.get('handle',[0])[-1]
|
||||
def reference(value):return value[-1] if isinstance(value,list) and value else None
|
||||
|
||||
def scene(raw):
|
||||
objects=raw.get('OBJECTS',[]);lookup={handle(e):e for e in objects}
|
||||
headers={handle(e):e for e in objects if e.get('object')=='BLOCK_HEADER'}
|
||||
child_handles={reference(ref) for h in headers.values() if not h.get('name','').upper().startswith(('*MODEL_SPACE','*PAPER_SPACE')) for ref in h.get('entities',[])}
|
||||
roots=[h for h in headers.values() if h.get('name','').upper().startswith(('*MODEL_SPACE','*PAPER_SPACE'))]
|
||||
texts=[];segments=[];warnings=[]
|
||||
def visit(e,m,chain):
|
||||
kind=e.get('entity');h=handle(e)
|
||||
if e.get('invisible'):return
|
||||
if kind=='INSERT':
|
||||
bh=reference(e.get('block_header'));block=headers.get(bh)
|
||||
if not block or bh in chain or len(chain)>=16:
|
||||
warnings.append(f'블록 전개 불가: {h:X}');return
|
||||
if e.get('extrusion',[0,0,1])!=[0,0,1]:
|
||||
warnings.append(f'비평면 블록 미지원: {h:X}');return
|
||||
angle=e.get('rotation',0);scale=e.get('scale',[1,1,1]);p=e.get('ins_pt',[0,0]);base=block.get('base_pt',[0,0])
|
||||
c,s=math.cos(angle),math.sin(angle)
|
||||
local=(c*scale[0],s*scale[0],-s*scale[1],c*scale[1],0,0)
|
||||
offset=transform(local,base);local=(*local[:4],p[0]-offset[0],p[1]-offset[1])
|
||||
for ref in block.get('entities',[]):
|
||||
child=lookup.get(reference(ref))
|
||||
if child:visit(child,compose(m,local),(*chain,bh))
|
||||
return
|
||||
if kind in ('TEXT','MTEXT','ATTRIB','ATTDEF','DIMENSION_LINEAR','DIMENSION_ALIGNED'):
|
||||
if kind=='ATTDEF':return
|
||||
t=e.get('text_value',e.get('text',''));p=e.get('ins_pt',e.get('text_midpt'))
|
||||
if isinstance(t,str) and t.strip() and p:
|
||||
p=transform(m,p)
|
||||
if all(math.isfinite(v) for v in p):texts.append(dict(h=h,x=p[0],y=p[1],t=t,height=abs(e.get('height',e.get('text_height',0)))*math.hypot(m[0],m[1]),block_path=list(chain)))
|
||||
pairs=[]
|
||||
if kind=='LINE':pairs=[(e.get('start'),e.get('end'))]
|
||||
elif kind in ('LWPOLYLINE','LEADER'):
|
||||
points=e.get('points',[]);pairs=list(zip(points,points[1:]))
|
||||
if kind=='LWPOLYLINE' and e.get('flag',0)&1 and len(points)>2:pairs.append((points[-1],points[0]))
|
||||
for a,b in pairs:
|
||||
if a is None or b is None:continue
|
||||
a,b=transform(m,a),transform(m,b)
|
||||
if all(math.isfinite(v) for v in a+b) and a!=b:segments.append(dict(h=h,a=a,b=b,layer=reference(e.get('layer')),block_path=list(chain)))
|
||||
if roots and all('entities' in h for h in roots):
|
||||
ids={reference(ref) for root in roots for ref in root['entities']}
|
||||
root_handles={handle(root) for root in roots}
|
||||
# Older DWG versions encode space membership via entmode/owner, not entity arrays.
|
||||
ids.update(handle(e) for e in objects if e.get('entity') and (e.get('entmode') in (1,2) or reference(e.get('ownerhandle')) in root_handles) and handle(e) not in child_handles)
|
||||
# ATTRIB coordinates are stored in drawing space by LibreDWG.
|
||||
ids.update(handle(e) for e in objects if e.get('entity')=='ATTRIB' and handle(e) not in child_handles)
|
||||
for h in ids:
|
||||
if h in lookup:visit(lookup[h],IDENTITY,())
|
||||
else:
|
||||
for e in objects:
|
||||
if handle(e) not in child_handles:visit(e,IDENTITY,())
|
||||
return texts,segments,list(dict.fromkeys(warnings))
|
||||
|
||||
def leader_endpoint(text,segments):
|
||||
"""Follow a short horizontal landing plus connected leader, never a dense grid."""
|
||||
height=text.get('height',0)
|
||||
if height<=0:return None
|
||||
x,y=text['x'],text['y'];tol=height*.15
|
||||
landing=[]
|
||||
for seg in segments:
|
||||
a,b=seg['a'],seg['b']
|
||||
if abs(a[1]-b[1])<tol and min(a[0],b[0])-height<x<max(a[0],b[0])+height and abs(a[1]-y)<height*1.8 and height<math.dist(a,b)<height*35:landing.append(seg)
|
||||
if len(landing)!=1:return None
|
||||
start=landing[0];tips=[]
|
||||
for endpoint in [start['a'],start['b']]:
|
||||
current=endpoint;used={id(start)};path=[start]
|
||||
for _ in range(4):
|
||||
nexts=[s for s in segments if id(s) not in used and min(math.dist(current,s['a']),math.dist(current,s['b']))<tol and math.dist(s['a'],s['b'])<height*60]
|
||||
if not nexts:
|
||||
if len(path)>1:tips.append((current,path))
|
||||
break
|
||||
if len(nexts)!=1:break
|
||||
seg=nexts[0];used.add(id(seg));path=path+[seg]
|
||||
current=seg['b'] if math.dist(current,seg['a'])<tol else seg['a']
|
||||
return tips[0] if len(tips)==1 else None
|
||||
|
||||
def table_row_band(point,segments,span):
|
||||
"""Use real horizontal cell borders; reject oversized/ambiguous enclosures."""
|
||||
ys=[]
|
||||
for s in (segments.horizontal_near(point['y'],span) if isinstance(segments,LineIndex) else segments or []):
|
||||
a,b=s['a'],s['b']
|
||||
if abs(a[1]-b[1])<span*1e-5 and min(a[0],b[0])<point['x']<max(a[0],b[0]) and abs(a[1]-point['y'])<span:ys.append(a[1])
|
||||
lower=[y for y in ys if y<point['y']];upper=[y for y in ys if y>point['y']]
|
||||
if not lower or not upper:return None
|
||||
lo,hi=max(lower),min(upper)
|
||||
return (lo,hi) if span*.02<hi-lo<span else None
|
||||
@@ -0,0 +1,165 @@
|
||||
"""Conservative drawing-only associations; no workbook or sample-name dependencies."""
|
||||
import math
|
||||
import re
|
||||
from .semantic import norm, distance, pile_minimum_m, angle_text
|
||||
from .geometry import leader_endpoint,LineIndex
|
||||
|
||||
def coordinate_points(texts):
|
||||
points=[]
|
||||
for e in texts:
|
||||
x=re.search(r'X\s*[=:]\s*([-+]?\d+(?:\.\d+)?)',e['t'],re.I)
|
||||
if not x:continue
|
||||
y=re.search(r'Y\s*[=:]\s*([-+]?\d+(?:\.\d+)?)',e['t'],re.I);ye=e
|
||||
if not y:
|
||||
candidates=[a for a in texts if a['y']<e['y'] and abs(a['x']-e['x'])<max(.01,(e['y']-a['y'])*.05) and re.search(r'Y\s*[=:]',a['t'],re.I)]
|
||||
if candidates:
|
||||
ye=min(candidates,key=lambda a:distance(a,e));y=re.search(r'Y\s*[=:]\s*([-+]?\d+(?:\.\d+)?)',ye['t'],re.I)
|
||||
if y:points.append(dict(x=float(x[1]),y=float(y[1]),cad=e,evidence=[e,ye]))
|
||||
return points
|
||||
|
||||
def associate_ips(points,bearing_rows):
|
||||
"""Require exactly three unique points near an unambiguous bearing footprint."""
|
||||
footprints={}
|
||||
for p in {r['p'] for r in bearing_rows}:
|
||||
rows=[r for r in bearing_rows if r['p']==p]
|
||||
xy={(r['x'],r['y']) for r in rows}
|
||||
# Conflicting coordinates for one bearing indicate overlapping carriageways.
|
||||
if any(len({(r['x'],r['y']) for r in rows if r['no']==no})>1 for no in {r['no'] for r in rows}):continue
|
||||
if len(xy)<2:continue
|
||||
center=tuple(sum(v[k] for v in xy)/len(xy) for k in [0,1])
|
||||
radius=max(math.dist(center,v) for v in xy)
|
||||
# IP callouts often sit outside the outermost bearing, near cap edges.
|
||||
# Competing-pier and exact-three checks below still reject ambiguous groups.
|
||||
footprints[p]=(center,max(radius*2.5,2.0))
|
||||
grouped={p:{} for p in footprints}
|
||||
for point in points:
|
||||
xy=(point['x'],point['y'])
|
||||
candidates=sorted((math.dist(xy,c),p,limit) for p,(c,limit) in footprints.items())
|
||||
if not candidates:continue
|
||||
d,p,limit=candidates[0]
|
||||
if d>limit or len(candidates)>1 and candidates[1][0]<max(d*1.5,d+1):continue
|
||||
grouped[p].setdefault(xy,point)
|
||||
return {p:sorted(ps.values(),key=lambda a:(-a['cad']['y'],a['cad']['x'])) for p,ps in grouped.items() if len(ps)==3}
|
||||
|
||||
def profile_fields(texts,segments=None):
|
||||
"""Local explicit EL/GL and pile labels only. Unlabelled terrain tables stay unresolved."""
|
||||
labels=[e for e in texts if re.fullmatch(r'P\d+',norm(e['t']))]
|
||||
result=[];connections={};line_index=segments if isinstance(segments,LineIndex) else LineIndex(segments)
|
||||
for label in labels:
|
||||
peers=[e for e in labels if norm(e['t'])!=norm(label['t']) and abs(e['y']-label['y'])<abs(e['x']-label['x'])*.25]
|
||||
if not peers:continue
|
||||
span=min(abs(e['x']-label['x']) for e in peers)
|
||||
if span<=0:continue
|
||||
if any(norm(e['t'])!=norm(label['t']) and abs(e['x']-label['x'])<span*.03 and 0<abs(e['y']-label['y'])<span*.25 for e in labels):continue
|
||||
local=[]
|
||||
for e in texts:
|
||||
pos=(e['x'],e['y']);connection=None
|
||||
if segments and re.match(r'(?:E\.?L\.?|G\.?L\.?)',norm(e['t'])):
|
||||
key=(e['h'],e['x'],e['y'])
|
||||
if key not in connections:
|
||||
reach=max(e.get('height',0)*100,span)
|
||||
nearby=line_index.near(e['x'],e['y'],reach)
|
||||
connections[key]=leader_endpoint(e,nearby)
|
||||
connection=connections[key]
|
||||
if connection:pos=connection[0]
|
||||
if abs(pos[0]-label['x'])<span*.42 and abs(pos[1]-label['y'])<span*1.2:
|
||||
if connection:e=dict(e,leader_tip=pos,leader_handles=[s['h'] for s in connection[1]])
|
||||
local.append(e)
|
||||
def record(col,value,ev):result.append(dict(p=norm(label['t']),col=col,value=value,inferred=True,evidence=[label,*ev]))
|
||||
els=[]
|
||||
for e in local:
|
||||
m=re.fullmatch(r'(?:E\.?L\.?)[=:]*([-+]?\d+(?:\.\d+)?)(?:\([좌우]\))?',norm(e['t']))
|
||||
if m and abs(e['y']-label['y'])>span*.05:els.append((float(m[1]),e))
|
||||
gl=re.fullmatch(r'(?:G\.?L\.?|지반고(?:EL)?)[=:]*([-+]?\d+(?:\.\d+)?)',norm(e['t']))
|
||||
if gl:record('F',float(gl[1]),[e])
|
||||
ground_heads=[e for e in texts if norm(e['t']) in ['지반고','지반고EL','GROUNDLEVEL'] and e['y']<label['y'] and 0<label['y']-e['y']<span*3 and e['x']<label['x']]
|
||||
if ground_heads:
|
||||
head=min(ground_heads,key=lambda e:abs(e['y']-label['y']))
|
||||
band=max(head.get('height',0)*2,span*.06)
|
||||
row_values=[e for e in texts if abs(e['y']-head['y'])<band and head['x']<e['x'] and re.fullmatch(r'[-+]?\d+\.\d+',e['t'].strip())]
|
||||
# Regular station samples are not the pier station. Prefer inserted pier ordinates.
|
||||
xs=sorted(set(e['x'] for e in row_values));pitches={}
|
||||
for a,b in zip(xs,xs[1:]):
|
||||
step=b-a
|
||||
if span*.15<step<span:
|
||||
key=round(step/(span*.002));pitches[key]=pitches.get(key,0)+1
|
||||
if pitches:
|
||||
pitch=max(pitches,key=pitches.get)*span*.002
|
||||
phases=[(sum(abs((x-a)/pitch-round((x-a)/pitch))<.025 for x in xs),a) for a in xs]
|
||||
count,origin=max(phases,default=(0,0))
|
||||
if count>=4:row_values=[e for e in row_values if abs((e['x']-origin)/pitch-round((e['x']-origin)/pitch))>=.025]
|
||||
ground=[e for e in row_values if abs(e['x']-label['x'])<span*.15]
|
||||
if ground:
|
||||
nearest=min(ground,key=lambda e:abs(e['x']-label['x']))
|
||||
equally_close=[e for e in ground if abs(abs(e['x']-label['x'])-abs(nearest['x']-label['x']))<span*.001]
|
||||
for e in equally_close:record('F',float(e['t']),[head,e])
|
||||
# Multiple EL leader levels cannot safely be identified as foundation bottom.
|
||||
# The lowest EL leader group in a pier's profile column belongs to its base.
|
||||
# Retain paired left/right labels as separate candidates rather than choosing a value.
|
||||
if els:
|
||||
bottom=min(e.get('leader_tip',[e['x'],e['y']])[1] for _,e in els)
|
||||
els=[(v,e) for v,e in els if e.get('leader_tip',[e['x'],e['y']])[1]-bottom<span*.10]
|
||||
local_foundation=bool(els)
|
||||
if local_foundation:
|
||||
for value,e in els:record('E',value,[e])
|
||||
# A plan-view P label must not steal an abutment or another section's pile label.
|
||||
foundation_texts=[e for e in local if local_foundation and min(distance(e,a) for _,a in els)<span*.65]
|
||||
piles=[e for e in foundation_texts if '말뚝' in e['t'] and len(e['t'])<60]
|
||||
direct=[e for e in foundation_texts if any(t in norm(e['t']) for t in ['직접기초','확대기초'])]
|
||||
if piles and not direct:
|
||||
record('G','현장타설말뚝' if any('현장타설' in e['t'] for e in piles) else '말뚝기초',piles)
|
||||
lengths=[]
|
||||
for e in local:
|
||||
m=re.search(r'(?<![A-Z.])L\s*=\s*(\d+(?:\.\d+)?(?:\s*[~~]\s*\d+(?:\.\d+)?)?)\s*(MM|M)\b',e['t'].replace('\\P',' '),re.I)
|
||||
if m and min(distance(e,a) for a in piles)<span*.35:
|
||||
lengths.append((pile_minimum_m(m[1]+m[2]),e))
|
||||
if lengths:record('H',min(v for v,e in lengths),[e for v,e in lengths])
|
||||
elif direct and not piles:
|
||||
record('G','직접기초',direct);record('H',0,direct)
|
||||
types=[]
|
||||
for e in local:
|
||||
t=norm(e['t'])
|
||||
if re.search(r'(?:기둥(?:개수|수)?[:=]?2|2주식|Π형)',t):types.append(('π형',e))
|
||||
elif re.search(r'(?:기둥(?:개수|수)?[:=]?1|1주식|T형)',t):types.append(('T형',e))
|
||||
for value,e in types:record('C',value,[e])
|
||||
return result
|
||||
|
||||
|
||||
def scalar(text):
|
||||
t=re.sub(r'\s+','',text)
|
||||
m=re.fullmatch(r'(?:E\.?L\.?[=:]*)?([-+]?\d+(?:\.\d+)?)(?:\([좌우]\))?',t,re.I)
|
||||
return float(m[1]) if m else None
|
||||
|
||||
def integrated_bearings(ts,erows):
|
||||
out=[]
|
||||
for row in erows:
|
||||
no=next(e for e in row['evidence'] if re.fullmatch(r'\d+',e['t']) and int(e['t'])==row['no'])
|
||||
xheads=[e for e in ts if norm(e['t'])=='X좌표' and e['y']>no['y'] and e['x']>no['x']]
|
||||
if not xheads:continue
|
||||
xh=min(xheads,key=lambda e:distance(e,no))
|
||||
yheads=[e for e in ts if norm(e['t'])=='Y좌표' and abs(e['y']-xh['y'])<max(abs(e['x']-xh['x'])*.001,1e-6)]
|
||||
if not yheads:continue
|
||||
yh=min(yheads,key=lambda e:distance(e,xh));span=abs(yh['x']-xh['x'])
|
||||
if not span or abs(no['x']-xh['x'])>span*4 or xh['y']-no['y']>span*12:continue
|
||||
same=[e for e in ts if abs(e['y']-no['y'])<span*.001]
|
||||
vals={};ev=list(row['evidence'])
|
||||
for field,title in [('x','X좌표'),('y','Y좌표'),('angle','일반각도계')]:
|
||||
heads=[e for e in ts if norm(e['t'])==title and abs(e['y']-xh['y'])<span*.001]
|
||||
if not heads:continue
|
||||
h=min(heads,key=lambda e:distance(e,xh))
|
||||
cand=[e for e in same if abs(e['x']-h['x'])<span*.4 and (scalar(e['t']) is not None if field!='angle' else '°' in angle_text(e['t']))]
|
||||
if cand:
|
||||
e=min(cand,key=lambda e:abs(e['x']-h['x']));vals[field]=scalar(e['t']) if field!='angle' else angle_text(e['t']);ev.extend([h,e])
|
||||
sta=[e for e in same if 'STA.' in e['t'] and 0<e['x']-xh['x']<span*4]
|
||||
station=None
|
||||
if sta:
|
||||
sm=re.search(r'(\d+)K\+([\d.]+)',sta[0]['t']);station=int(sm[1])*1000+float(sm[2]) if sm else None;ev+=sta
|
||||
if 'x' in vals and 'y' in vals:out.append(dict(p=row['p'],no=row['no'],row_type=None,station=station,evidence=ev,**vals))
|
||||
for p in {r['p'] for r in out}:
|
||||
rs=[r for r in out if r['p']==p and r['station'] is not None]
|
||||
ss=sorted(set(r['station'] for r in rs))
|
||||
if len(ss)>1:
|
||||
gaps=[(b-a,(a+b)/2) for a,b in zip(ss,ss[1:])];gap,mid=max(gaps)
|
||||
if gap>.5:
|
||||
for r in rs:r['row_type']='전열' if r['station']<mid else '후열'
|
||||
return out
|
||||
+47508
File diff suppressed because it is too large.
Load diff
+65610
File diff suppressed because it is too large.
Load diff
Whitespace-only changes.
@@ -0,0 +1,285 @@
|
||||
"""Content/relative-layout readers. No fixed handles, file names or absolute positions."""
|
||||
import collections
|
||||
import math
|
||||
import re
|
||||
|
||||
NUMBER=r'[-+]?\d+(?:\.\d+)?'
|
||||
|
||||
def pile_minimum_m(value):
|
||||
"""Parse a length or range; unqualified drawing lengths are metres."""
|
||||
if isinstance(value,(int,float)):
|
||||
return float(value) if math.isfinite(value) and value>=0 else None
|
||||
t=re.sub(r'\s+','',str(value)).upper()
|
||||
t=re.sub(r'^L[=:]?','',t)
|
||||
unit=.001 if t.endswith('MM') else 1
|
||||
t=re.sub(r'MM$|M$','',t)
|
||||
if not re.fullmatch(r'\d+(?:\.\d+)?(?:[~~〜–/,-]\d+(?:\.\d+)?)*',t):return None
|
||||
return min(map(float,re.findall(r'\d+(?:\.\d+)?',t)))*unit
|
||||
def norm(t):return re.sub(r'\s+','',t).upper()
|
||||
def distance(a,b):return math.hypot(a['x']-b['x'],a['y']-b['y'])
|
||||
|
||||
def unique_entities(items,tolerance):
|
||||
out=[]
|
||||
for e in items:
|
||||
if not any(norm(a['t'])==norm(e['t']) and distance(a,e)<tolerance for a in out):out.append(e)
|
||||
return out
|
||||
|
||||
def angle_text(t):return re.sub(r'%%[dD]','°',t).strip()
|
||||
def pier(text):
|
||||
m=re.search(r'(?<![A-Z])P\s*\.?\s*(\d+)',text.upper())
|
||||
return 'P'+str(int(m[1])) if m else None
|
||||
|
||||
def classify(texts):
|
||||
ts=[norm(e['t']) for e in texts];roles=[]
|
||||
# File paths are deliberately absent. Titles are hints; tables also identify roles.
|
||||
if (any(axis_header(t,'X') for t in ts) and any(axis_header(t,'Y') for t in ts)) or any(drawing_title(t,'bearing') for t in ts):roles.append('02')
|
||||
if any(drawing_title(t,'pier') for t in ts):roles.append('04')
|
||||
if any(t=='지반고' for t in ts) and any(t in ['계획고','측점'] for t in ts):roles.append('01')
|
||||
# Geological/structural sheets also contain local "종단면도 (A-A)" views.
|
||||
# Such section titles alone do not identify the bridge longitudinal schedule.
|
||||
if any(drawing_title(t,'profile') and ('종평면도' in t or any(v in ts for v in ['측점','계획고','STATION'])) for t in ts) and '01' not in roles:roles.append('01')
|
||||
if any(t in ['교각명','교각번호'] for t in ts):
|
||||
if any('두부보' in t or '코핑' in t for t in ts) and '04' not in roles:roles.append('04')
|
||||
if any('기초하단' in t or '말뚝길이' in t for t in ts) and '01' not in roles:roles.append('01')
|
||||
if any(drawing_title(t,'elevation') for t in ts) or ('ELEVATION' in ts and 'STATION' in ts) or ('교좌면EL' in ts and any('몰탈상단' in t for t in ts)):roles.append('03')
|
||||
# An EL-X table with a pier identifier is stronger evidence than an exact title.
|
||||
# Calculation sheets remain ancillary and cannot supply authoritative bearing Z.
|
||||
if '04' not in roles and '03' not in roles and any(elx_header(t) for t in ts) and any(pier(t) for t in ts) and any(t in ['NO.','NO','번호'] for t in ts):roles.append('04')
|
||||
return roles
|
||||
|
||||
def drawing_title(text,kind):
|
||||
t=norm(text)
|
||||
if len(t)>90 or any(w in t for w in ['참조','참고','따른다','SEE']):return False
|
||||
patterns={
|
||||
'pier':r'교각(?:일반도|구조일반도|구조도|도)(?:\([^)]*\)|[-_\d])*$',
|
||||
'bearing':r'(?:교량)?받침(?:배치도|배치계획도)(?:\([^)]*\)|[-_\d])*$',
|
||||
'profile':r'(?:종평면도|종단면도|종단도)(?:\([^)]*\)|[-_\d])*$',
|
||||
'elevation':r'(?:ELEVATION|표고)(?:산출|계산)(?:근거)?(?:도|표)(?:\([^)]*\)|[-_\d])*$',
|
||||
}
|
||||
return bool(re.search(patterns[kind],t))
|
||||
|
||||
def axis_header(text,axis):
|
||||
t=norm(text).replace('_','')
|
||||
return t in {axis+'좌표',axis+'(M)',axis+'COORDINATE',axis+'좌표(M)',axis+'좌표M'}
|
||||
|
||||
def elx_header(text):
|
||||
return re.sub(r'''[.′’‘'"“”]''','',norm(text))=='ELX'
|
||||
|
||||
ALIASES={
|
||||
'C':{'교각형식','교각형태'},'D':{'두부보EL','두부보상단EL','코핑EL'},
|
||||
'E':{'기초하단EL','기초저면EL'},'F':{'지반고','지반고EL'},
|
||||
'G':{'기초형식'},'H':{'말뚝길이'},'I':{'두부보폭','코핑폭'},
|
||||
'J':{'두부보길이','코핑길이'},'K':{'기둥갯수','기둥개수','기둥수'},
|
||||
}
|
||||
|
||||
def header_field(t):
|
||||
t=re.sub(r'[_\s]|\((?:MM|M)\)','',norm(t))
|
||||
t=re.sub(r'(?:MM|M)$','',t)
|
||||
return next((col for col,labels in ALIASES.items() if t in labels),None)
|
||||
|
||||
def pier_tables(texts,role):
|
||||
"""Read explicitly labeled pier schedules of previously unseen layouts."""
|
||||
result=[];allowed='CDIJK' if role=='04' else 'EFGH'
|
||||
for ph in [e for e in texts if norm(e['t']) in ['교각명','교각번호']]:
|
||||
candidates=[e for e in texts if header_field(e['t']) in set(allowed) and e['x']!=ph['x'] and abs(e['y']-ph['y'])<abs(e['x']-ph['x'])*.001]
|
||||
if not candidates:continue
|
||||
span=min(abs(e['x']-ph['x']) for e in candidates)
|
||||
ids=sorted([e for e in texts if re.fullmatch(r'P\d+',norm(e['t'])) and abs(e['x']-ph['x'])<span*.4 and e['y']<ph['y']],key=lambda e:-e['y'])
|
||||
if not ids:continue
|
||||
pitch=ph['y']-ids[0]['y']
|
||||
if pitch>span*2:continue
|
||||
for i,label in enumerate(ids):
|
||||
if i and ids[i-1]['y']-label['y']>pitch*1.8:break
|
||||
for h in candidates:
|
||||
es=[e for e in texts if abs(e['y']-label['y'])<pitch*.03 and abs(e['x']-h['x'])<span*.4]
|
||||
if len(es)!=1:continue
|
||||
e=es[0];col=header_field(h['t']);value=e['t'].strip()
|
||||
if col not in 'CG':
|
||||
if col=='H' and pile_minimum_m(value) is not None:
|
||||
value=pile_minimum_m(value)
|
||||
if 'MM' in norm(h['t']) and not re.search(r'M\s*$',e['t'],re.I):value/=1000
|
||||
elif re.fullmatch(r'(?:EL[.= ]*)?'+NUMBER,value,re.I):value=float(re.search(NUMBER+r'$',value)[0])
|
||||
else:continue
|
||||
# Dimension headers with explicit m must be converted into mm.
|
||||
if col in 'IJ' and isinstance(value,float) and re.search(r'(?:_M|\(M\))$',norm(h['t'])):value*=1000
|
||||
result.append(dict(p=pier(label['t']),col=col,value=value,evidence=[ph,label,h,e]))
|
||||
return result
|
||||
|
||||
class Index:
|
||||
def __init__(self,texts):
|
||||
self.texts=texts;self.by_text=collections.defaultdict(list);self.cache={}
|
||||
for e in texts:self.by_text[norm(e['t'])].append(e)
|
||||
|
||||
def locate(self,ref):
|
||||
"""Fit scale+translation from local text landmarks, then validate other landmarks."""
|
||||
key=(ref['x'],ref['y'],ref['pattern'])
|
||||
if key in self.cache:return self.cache[key]
|
||||
anchors=[a for a in ref.get('anchors',[]) if self.by_text.get(a['t'])]
|
||||
matches={}
|
||||
pattern=re.compile(ref['pattern'])
|
||||
if len(anchors)<3:return None
|
||||
# Distinctive pier title is the first anchor when available. Do not substitute
|
||||
# another pier's equally shaped table if the identifying title vanished.
|
||||
original=ref.get('anchors',[])
|
||||
if original and pier(original[0]['t']) and original[0] not in anchors:return None
|
||||
first=anchors[0]
|
||||
for second in anchors[1:5]:
|
||||
dx,dy=second['x']-first['x'],second['y']-first['y'];den=dx*dx+dy*dy
|
||||
if den<1e-12:continue
|
||||
span=math.sqrt(den)
|
||||
for a in self.by_text[first['t']]:
|
||||
for b in self.by_text[second['t']]:
|
||||
ux,uy=b['x']-a['x'],b['y']-a['y'];scale=(ux*dx+uy*dy)/den
|
||||
if not .001<=scale<=1000:continue
|
||||
tol=max(span*scale*.00002,1e-5)
|
||||
if math.hypot(ux-scale*dx,uy-scale*dy)>tol:continue
|
||||
tx,ty=a['x']-scale*first['x'],a['y']-scale*first['y']
|
||||
confirmed=0
|
||||
for anchor in anchors:
|
||||
x,y=tx+scale*anchor['x'],ty+scale*anchor['y']
|
||||
if any(math.hypot(e['x']-x,e['y']-y)<tol for e in self.by_text[anchor['t']]):confirmed+=1
|
||||
if confirmed<3:continue
|
||||
x,y=tx+scale*ref['x'],ty+scale*ref['y']
|
||||
for e in self.texts:
|
||||
if abs(e['x']-x)<tol and abs(e['y']-y)<tol and pattern.fullmatch(e['t']):
|
||||
matches[(e['h'],e['x'],e['y'])]=(e,confirmed)
|
||||
# Prefer the transformation supported by the most independent headings.
|
||||
if matches:
|
||||
top=max(score for _,score in matches.values())
|
||||
best=[e for e,score in matches.values() if score==top]
|
||||
value=best[0] if len(best)==1 else None
|
||||
else:value=None
|
||||
self.cache[key]=value
|
||||
return value
|
||||
|
||||
def table_rows(texts,header,xspan,left=None):
|
||||
"""Numeric column with row pitch estimated from its own first two values."""
|
||||
xs=[e for e in texts if abs(e['x']-header['x'])<xspan*.45 and e['y']<header['y'] and re.fullmatch(NUMBER,e['t'])]
|
||||
xs.sort(key=lambda e:-e['y'])
|
||||
xs=unique_entities(xs,xspan*.001)
|
||||
if not xs:return []
|
||||
first=header['y']-xs[0]['y']
|
||||
if first<=0 or first>xspan:return []
|
||||
pitch=xs[0]['y']-xs[1]['y'] if len(xs)>1 else first
|
||||
if pitch<=0 or not .4*first<pitch<2.5*first:pitch=first
|
||||
result=[]
|
||||
for e in xs:
|
||||
if len(result)>=100:break
|
||||
if result and abs((result[-1]['y']-e['y'])-pitch)>pitch*.12:break
|
||||
# A new header or table title between rows terminates the current table.
|
||||
if any(norm(t['t'])==norm(header['t']) and abs(t['x']-header['x'])<xspan*.3 and e['y']<=t['y']<header['y'] for t in texts):break
|
||||
result.append(e)
|
||||
return result
|
||||
|
||||
def bearings(texts,segments=None):
|
||||
"""Read columns from headers even if their order/positions/row pitch change."""
|
||||
out=[];issues=[]
|
||||
titles=[e for e in texts if re.fullmatch(r'P\d+\([전후](?:열)?(?:,?RAMP-A교)?\)',norm(e['t']))]
|
||||
for xh in [e for e in texts if axis_header(e['t'],'X')]:
|
||||
# Y header nearest on the same row; position can be left or right of X.
|
||||
ys=[e for e in texts if axis_header(e['t'],'Y') and abs(e['y']-xh['y'])<max(abs(e['x']-xh['x'])*.0001,1e-5)]
|
||||
if not ys:continue
|
||||
yh=min(ys,key=lambda e:abs(e['x']-xh['x']));span=abs(yh['x']-xh['x'])
|
||||
if not span:continue
|
||||
ts=[t for t in titles if 0<t['y']-xh['y']<span and abs(t['x']-xh['x'])<span*2]
|
||||
if not ts:continue
|
||||
title=min(ts,key=lambda t:distance(t,xh));p=pier(title['t'])
|
||||
headings=[e for e in texts if abs(e['y']-xh['y'])<span*.001 and abs(e['x']-xh['x'])<span*3.5]
|
||||
ah=[e for e in headings if norm(e['t']) in ['일반각도계','방위각','AZIMUTH']]
|
||||
nh=[e for e in headings if norm(e['t']) in ['구분','NO.','NO','번호']]
|
||||
if not nh:continue
|
||||
noh=min(nh,key=lambda e:abs(e['x']-xh['x']))
|
||||
next_numbers=[e['x'] for e in texts if norm(e['t']) in ['구분','NO.','NO','번호'] and abs(e['y']-xh['y'])<span*.001 and e['x']>noh['x']+span*.1]
|
||||
right=min(next_numbers) if next_numbers else float('inf')
|
||||
ah=[e for e in ah if noh['x']<e['x']<right]
|
||||
angleh=min(ah,key=lambda e:abs(e['x']-xh['x'])) if ah else None
|
||||
for x in table_rows(texts,xh,span):
|
||||
from .geometry import table_row_band
|
||||
band=table_row_band(x,segments,span)
|
||||
same=[e for e in texts if (band[0]<e['y']<band[1] if band else abs(e['y']-x['y'])<span*.001)]
|
||||
yy=[e for e in same if abs(e['x']-yh['x'])<span*.45 and re.fullmatch(NUMBER,e['t'])]
|
||||
aa=[e for e in same if angleh and abs(e['x']-angleh['x'])<span*.45 and '°' in angle_text(e['t'])]
|
||||
nn=[e for e in same if abs(e['x']-noh['x'])<span*.25 and re.fullmatch(r'\d+',e['t'])]
|
||||
yy=unique_entities(yy,span*.001);aa=unique_entities(aa,span*.001);nn=unique_entities(nn,span*.001)
|
||||
if len(yy)==len(nn)==1 and len(aa)<=1:
|
||||
row_type='전열' if '(전' in norm(title['t']) else '후열'
|
||||
out.append(dict(p=p,no=int(nn[0]['t']),x=float(x['t']),y=float(yy[0]['t']),angle=angle_text(aa[0]['t']) if aa else None,row_type=row_type,evidence=[title,xh,yh,nn[0],x,yy[0]]+([angleh,*aa] if angleh else [])))
|
||||
else:issues.append(f'{p}: 좌표표 행 대응 모호함')
|
||||
return out,issues
|
||||
|
||||
def elevations(texts):
|
||||
out=[]
|
||||
titles=[e for e in texts if pier(e['t']) and (any(t in norm(e['t']) for t in ['교각일반도','교각구조도','교각도','교각구조일반도']) or re.fullmatch(r'\(.*방향P\d+\)',norm(e['t'])) or re.fullmatch(r'P\d+(?:\([^)]*\))?',norm(e['t'])))]
|
||||
for h in [e for e in texts if elx_header(e['t'])]:
|
||||
numbers=[e for e in texts if norm(e['t']) in ['NO.','NO','번호'] and e['x']<h['x'] and abs(e['y']-h['y'])<abs(e['x']-h['x'])*.001]
|
||||
if not numbers or not titles:continue
|
||||
nh=min(numbers,key=lambda e:abs(e['x']-h['x']));span=h['x']-nh['x']
|
||||
nearest=sorted([t for t in titles if t['y']>h['y']],key=lambda e:distance(e,h))
|
||||
if not nearest:continue
|
||||
title=nearest[0];p=pier(title['t'])
|
||||
# Do not match a tiny dimension-callout EL.X to a remote table.
|
||||
if distance(title,h)>span*15:continue
|
||||
for z in table_rows(texts,h,span*.45):
|
||||
nn=[e for e in texts if abs(e['y']-z['y'])<span*.001 and abs(e['x']-nh['x'])<span*.06 and re.fullmatch(r'\d+',e['t'])]
|
||||
if len(nn)==1:out.append(dict(p=p,no=int(nn[0]['t']),z=float(z['t']),evidence=[title,h,nh,nn[0],z]))
|
||||
return out
|
||||
|
||||
def drawing_direction(text):
|
||||
m=re.match(r'^\(?([가-힣]+)방향(?:[,)]|BR|교량받침|$)',norm(text))
|
||||
return m[1] if m and m[1] not in ['교축','교축직각','횡','종','양','직각'] else None
|
||||
|
||||
def calculation_elevations(texts,segments=None):
|
||||
"""Read mortar-top and seat columns, grouping merged pier cells by NO resets.
|
||||
|
||||
All matching tables/revisions are retained; callers resolve conflicts explicitly.
|
||||
No baseline-line elevation or general drawing EL-X is used.
|
||||
"""
|
||||
out=[];issues=[]
|
||||
for zh in [e for e in texts if norm(e['t']) in ['교량받침EL','교량받침EL(몰탈상단)']]:
|
||||
seats=[e for e in texts if norm(e['t'])=='교좌면EL' and e['x']!=zh['x'] and abs(e['y']-zh['y'])<abs(e['x']-zh['x'])*.15]
|
||||
if not seats:continue
|
||||
sh=min(seats,key=lambda e:distance(e,zh));span=abs(zh['x']-sh['x'])
|
||||
mortar=[e for e in texts if norm(e['t'])=='(몰탈상단)' and abs(e['x']-zh['x'])<span*.5 and abs(e['y']-zh['y'])<span*.5]
|
||||
if not mortar and '몰탈상단' not in zh['t']:continue
|
||||
heads=[e for e in texts if abs(e['y']-zh['y'])<span*.15 and e['x']<min(zh['x'],sh['x'])]
|
||||
nhs=[e for e in heads if norm(e['t']) in ['교량받침','받침번호','NO.','NO']]
|
||||
nhs=[h for h in nhs if any(re.fullmatch(r'\d+',e['t']) and abs(e['x']-h['x'])<span*.45 and 0<h['y']-e['y']<span*2 for e in texts)]
|
||||
if not nhs:continue
|
||||
nh=max(nhs,key=lambda e:e['x'])
|
||||
phs=[e for e in heads if norm(e['t']) in ['구분','교각명','교각번호'] and e['x']<nh['x']]
|
||||
if not phs:continue
|
||||
ph=max(phs,key=lambda e:e['x'])
|
||||
center=dict(x=(ph['x']+zh['x'])/2,y=zh['y'])
|
||||
directions=[e for e in texts if drawing_direction(e['t']) and e['y']>zh['y'] and abs(e['x']-center['x'])<zh['x']-ph['x'] and e['y']-zh['y']<(zh['x']-ph['x'])]
|
||||
direction_e=min(directions,key=lambda e:distance(e,center)) if directions else None
|
||||
direction=drawing_direction(direction_e['t']) if direction_e else None
|
||||
bottom=max([e['y'] for e in texts if norm(e['t'])==norm(zh['t']) and abs(e['x']-zh['x'])<span*.5 and e['y']<zh['y']]+[-float('inf')])
|
||||
nums=sorted([e for e in texts if bottom<e['y']<zh['y'] and abs(e['x']-nh['x'])<span*.45 and re.fullmatch(r'\d+',e['t'])],key=lambda e:-e['y'])
|
||||
if not nums:continue
|
||||
pitch=nums[0]['y']-nums[1]['y'] if len(nums)>1 else zh['y']-nums[0]['y']
|
||||
if pitch<=0 or pitch>span*2:continue
|
||||
contiguous=[]
|
||||
for e in nums:
|
||||
if contiguous and abs(contiguous[-1]['y']-e['y']-pitch)>pitch*.15:break
|
||||
contiguous.append(e)
|
||||
groups=[]
|
||||
for e in contiguous:
|
||||
if not groups or int(e['t'])==1:groups.append([])
|
||||
groups[-1].append(e)
|
||||
for group in groups:
|
||||
labels=[e for e in texts if re.fullmatch(r'[PA]\d+',norm(e['t'])) and abs(e['x']-ph['x'])<span*.45 and group[-1]['y']-pitch*.49<=e['y']<=group[0]['y']+pitch*.49]
|
||||
if len(labels)!=1:
|
||||
issues.append('ELEVATION 표: 받침 번호 묶음과 교각명 대응 미확정');continue
|
||||
label=labels[0];p=norm(label['t'])
|
||||
if p.startswith('A'):continue
|
||||
for no in group:
|
||||
values={};evidence=[label,nh,no,zh,sh]+mortar+([direction_e] if direction_e else [])
|
||||
from .geometry import table_row_band
|
||||
band=table_row_band(no,segments,span)
|
||||
for field,h in [('z',zh),('seat',sh)]:
|
||||
matches=[e for e in texts if (band[0]<e['y']<band[1] if band else abs(e['y']-no['y'])<pitch*.03) and abs(e['x']-h['x'])<span*.45 and re.fullmatch(r'(?:EL[. =]*)?'+NUMBER,e['t'],re.I)]
|
||||
if len(matches)==1:
|
||||
values[field]=float(re.search(NUMBER+r'$',matches[0]['t'])[0]);evidence.append(matches[0])
|
||||
if values:out.append(dict(p=p,no=int(no['t']),direction=direction,evidence=evidence,**values))
|
||||
else:issues.append(f'{p} 받침 {no["t"]}: 몰탈상단/교좌면 값 대응 미확정')
|
||||
return out,issues
|
||||
@@ -0,0 +1,64 @@
|
||||
"""Standalone Python XLSX writer, explicitly requested by the user."""
|
||||
from openpyxl import Workbook, load_workbook
|
||||
from openpyxl.styles import Font, PatternFill, Alignment, Border, Side
|
||||
from openpyxl.comments import Comment
|
||||
from openpyxl.utils import get_column_letter
|
||||
from .core import HEADERS
|
||||
|
||||
def put(ws,cell,value):
|
||||
c=ws[cell];c.value=value
|
||||
if isinstance(value,str):c.data_type='s' # CAD text must never execute as an Excel formula.
|
||||
return c
|
||||
|
||||
def export_workbook(result,path):
|
||||
wb=Workbook();wb.remove(wb.active)
|
||||
font='맑은 고딕'
|
||||
for name,headers in HEADERS.items():
|
||||
s=wb.create_sheet(name);s.sheet_view.showGridLines=False;s.freeze_panes='D3'
|
||||
put(s,'B1',result['name']+' '+name+' 입력데이터').font=Font(name=font,size=14,bold=True)
|
||||
s.row_dimensions[1].height=30;s.row_dimensions[2].height=38
|
||||
for j,title in enumerate(headers,2):
|
||||
c=s.cell(2,j,title);c.font=Font(name=font,size=10,bold=True,color='FFFFFF');c.fill=PatternFill('solid',fgColor='203864');c.alignment=Alignment(horizontal='center',vertical='center',wrap_text=True)
|
||||
s.column_dimensions[get_column_letter(j)].width=16
|
||||
s.column_dimensions['B'].width=10;s.column_dimensions['C'].width=21
|
||||
if name!='Pier':
|
||||
s.column_dimensions['D'].width=21;s.column_dimensions['E'].width=21
|
||||
if name=='Bearing':s.column_dimensions['G'].width=23
|
||||
for cell,value in result['cells'][name].items():
|
||||
c=put(s,cell,value);c.font=Font(name=font,size=10,color='C00000');c.alignment=Alignment(horizontal='center',vertical='center')
|
||||
c.border=Border(*( [Side(style='thin',color='B7B7B7')]*4 ))
|
||||
s.row_dimensions[c.row].height=24
|
||||
if name=='Pier' and c.column in [4,5,6]:c.number_format='0.0000' if c.column==4 else '0.000'
|
||||
elif name!='Pier' and c.column in [4,5]:c.number_format='0.000000' if name=='IP' else '0.0000'
|
||||
elif name=='Bearing' and c.column==6:c.number_format='0.0000'
|
||||
note_col=len(headers)+3
|
||||
s.cell(2,note_col,'상태 / 추출 및 검토 근거').font=Font(name=font,bold=True,size=10)
|
||||
s.column_dimensions[get_column_letter(note_col)].width=100
|
||||
notes={}
|
||||
for r in result['records']:
|
||||
if r['sheet']!=name:continue
|
||||
c=s[r['cell']]
|
||||
if r['status'] not in ['추출','식별자','등록값']:
|
||||
c.fill=PatternFill('solid',fgColor='FFF2CC')
|
||||
body=[f"상태: {r['status']}",r['note'],*r['messages']]
|
||||
for e in r['evidence']:
|
||||
body.append(f"{result['sources'][e['doc']]['path']}\nhandle {e['h']:X}, ({e['x']:.4f}, {e['y']:.4f})\n{e['t']}")
|
||||
if r['status']!='식별자':c.comment=Comment('\n'.join(filter(None,body)),'도면추출 프로그램')
|
||||
if r['status'] not in ['추출','식별자']:
|
||||
notes.setdefault(c.row,[]).append(f"{r['cell']} {r['status']}: "+' / '.join(r['messages']))
|
||||
for row,messages in notes.items():
|
||||
c=s.cell(row,note_col,'; '.join(messages));c.font=Font(name=font,size=10);c.alignment=Alignment(wrap_text=True,vertical='top')
|
||||
s.row_dimensions[row].height=max(24,min(100,20*(len(c.value)//85+1)))
|
||||
last=max([int(c[1:]) for c in result['cells'][name]]+[2])+3
|
||||
for text in result.get('notes',[]):
|
||||
put(s,f'B{last}',text).font=Font(name=font,size=10);last+=1
|
||||
for d,src in result['sources'].items():
|
||||
put(s,f'B{last}',f"출처 {d}: {src['path']}").font=Font(name=font,size=10);last+=1
|
||||
wb.save(path)
|
||||
# Check the actual serialized file, not only the in-memory workbook.
|
||||
saved=load_workbook(path)
|
||||
for sheet,cells in result['cells'].items():
|
||||
for address,value in cells.items():
|
||||
if saved[sheet][address].value!=value:
|
||||
raise ValueError(f'엑셀 저장 대조 실패: {sheet}!{address}')
|
||||
saved.close()
|
||||
@@ -0,0 +1,80 @@
|
||||
"""Folder-only default interface."""
|
||||
import os
|
||||
from pathlib import Path
|
||||
import queue
|
||||
import threading
|
||||
import tkinter as tk
|
||||
from tkinter import ttk,filedialog,messagebox
|
||||
import sys
|
||||
PACKAGE = Path(__file__).resolve().parents[1]
|
||||
WORKSPACE = PACKAGE.parents[1]
|
||||
sys.path.insert(0, str(PACKAGE / 'src'))
|
||||
from bridge_drawing_extractor import extract_folder
|
||||
|
||||
def run_folder(folder, log):
|
||||
result = extract_folder(folder,
|
||||
converter_path=WORKSPACE / 'Code/giyang_extract/libredwg/dwgread.exe',
|
||||
on_progress=log)
|
||||
return result.output_dir, [(b.xlsx_path, b.data) for b in result.workbooks], result.files
|
||||
|
||||
|
||||
class AutoApp:
|
||||
def __init__(self,root):
|
||||
self.root=root;self.busy=False;self.events=queue.Queue();self.last_output=None
|
||||
root.title('도면 폴더 → 교각 입력 엑셀');root.geometry('960x640');root.minsize(760,520)
|
||||
self.folder=tk.StringVar();self.status=tk.StringVar(value='도면이 들어 있는 폴더를 선택하세요.')
|
||||
frame=ttk.Frame(root,padding=16);frame.pack(fill='both',expand=True);frame.columnconfigure(0,weight=1);frame.rowconfigure(5,weight=1)
|
||||
ttk.Label(frame,text='도면 폴더에서 자동 추출',font=('맑은 고딕',16,'bold')).grid(row=0,column=0,columnspan=2,sticky='w',pady=(0,12))
|
||||
ttk.Label(frame,text='종평면·받침배치·ELEVATION에서 추출합니다. 기존 엑셀 없이 IP 연결·500mm 단위 치수 산정.').grid(row=1,column=0,columnspan=2,sticky='w',pady=(0,12))
|
||||
ttk.Entry(frame,textvariable=self.folder).grid(row=2,column=0,sticky='ew',padx=(0,8))
|
||||
ttk.Button(frame,text='도면 폴더 선택',command=self.choose).grid(row=2,column=1)
|
||||
buttons=ttk.Frame(frame);buttons.grid(row=3,column=0,columnspan=2,sticky='w',pady=12)
|
||||
self.start_button=ttk.Button(buttons,text='자동 추출 시작',command=self.start);self.start_button.pack(side='left',padx=(0,8))
|
||||
ttk.Button(buttons,text='결과 폴더 열기',command=self.open_output).pack(side='left')
|
||||
self.progress=ttk.Progressbar(frame,mode='indeterminate');self.progress.grid(row=4,column=0,columnspan=2,sticky='ew',pady=(0,8))
|
||||
self.text=tk.Text(frame,state='disabled',wrap='word',font=('맑은 고딕',10));self.text.grid(row=5,column=0,columnspan=2,sticky='nsew')
|
||||
scroll=ttk.Scrollbar(frame,command=self.text.yview);scroll.grid(row=5,column=2,sticky='ns');self.text.configure(yscrollcommand=scroll.set)
|
||||
ttk.Label(frame,textvariable=self.status,wraplength=870).grid(row=6,column=0,columnspan=2,sticky='w',pady=10)
|
||||
ttk.Label(frame,text='결과: 선택 폴더의 _도면추출결과. 판독이 모호한 값은 공란·검토 사유로 표시합니다.',wraplength=870).grid(row=7,column=0,columnspan=2,sticky='w')
|
||||
root.after(100,self.poll)
|
||||
|
||||
def choose(self):
|
||||
folder=filedialog.askdirectory()
|
||||
if folder:self.folder.set(folder)
|
||||
|
||||
def append(self,text):
|
||||
self.text.configure(state='normal');self.text.insert('end',text+'\n');self.text.see('end');self.text.configure(state='disabled')
|
||||
|
||||
def start(self):
|
||||
if self.busy:return
|
||||
folder=self.folder.get()
|
||||
if not folder or not Path(folder).is_dir():messagebox.showerror('폴더 선택','유효한 도면 폴더를 선택하세요.');return
|
||||
self.busy=True;self.start_button.state(['disabled']);self.progress.start();self.status.set('도면을 읽고 있습니다…')
|
||||
def worker():
|
||||
try:self.events.put(('done',run_folder(folder,log=lambda s:self.events.put(('log',s)))))
|
||||
except Exception as e:self.events.put(('error',str(e)))
|
||||
threading.Thread(target=worker,daemon=True).start()
|
||||
|
||||
def poll(self):
|
||||
try:
|
||||
while True:
|
||||
kind,value=self.events.get_nowait()
|
||||
if kind=='log':self.append(value);continue
|
||||
self.busy=False;self.start_button.state(['!disabled']);self.progress.stop()
|
||||
if kind=='error':self.status.set('처리 실패 — 판독 메시지를 확인하세요.');self.append(value)
|
||||
else:
|
||||
folder,outputs,inventory=value;self.last_output=folder
|
||||
issues=sum(r['value'] is None and r['status']!='대상외' for _,result in outputs for r in result['records'])
|
||||
failed=sum(x['status']=='읽기 실패' for x in inventory)
|
||||
self.status.set(f'엑셀 {len(outputs)}개 저장 · 검토 {issues}셀 · 파일 읽기 실패 {failed}개')
|
||||
self.append('저장 위치: '+str(folder));self.append('CSV의 검토 사유와 파일판독내역.json을 확인하세요.')
|
||||
except queue.Empty:pass
|
||||
self.root.after(100,self.poll)
|
||||
|
||||
def open_output(self):
|
||||
if self.last_output and self.last_output.exists():os.startfile(str(self.last_output))
|
||||
|
||||
if __name__ == '__main__':
|
||||
root = tk.Tk()
|
||||
app = AutoApp(root)
|
||||
root.mainloop()
|
||||
Reference in new issue
Block a user