Update Pier_v1 project

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
CopilotandCopilot committed 2026-11-03 12:04:58 +09:00
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
@@ -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()