1364 lines
58 KiB
Python
1364 lines
58 KiB
Python
import os
|
||
import re
|
||
import math
|
||
import ezdwg
|
||
|
||
|
||
def classify_drawing_by_name(drawing_name):
|
||
"""
|
||
1.2 도면 종류 판별:
|
||
도면명에 포함된 키워드를 기준으로 도면 종류를 자동 판별한다.
|
||
- 도면명에 '일반'이 포함된 경우 일반도('GENERAL')로 자동 분류한다.
|
||
- 도면명에 '배근' 또는 '구조'('배근/구조')가 포함된 경우 배근도/구조도('REBAR')로 자동 분류한다.
|
||
- 도면명만으로 종류를 판별할 수 없는 경우 미분류('UNCLASSIFIED')로 반환하여 사용자가 직접 선택할 수 있도록 한다.
|
||
"""
|
||
name_str = (drawing_name or '').strip()
|
||
|
||
if '일반' in name_str:
|
||
return {
|
||
'auto_type': 'GENERAL',
|
||
'auto_label': '일반도',
|
||
'matched_keyword': '일반',
|
||
'reason': "도면명에 '일반' 키워드가 포함되어 일반도로 자동 분류됨",
|
||
'needs_manual_selection': False
|
||
}
|
||
elif '배근' in name_str or '구조' in name_str:
|
||
matched = []
|
||
if '배근' in name_str:
|
||
matched.append('배근')
|
||
if '구조' in name_str:
|
||
matched.append('구조')
|
||
kw_str = '/'.join(matched)
|
||
return {
|
||
'auto_type': 'REBAR',
|
||
'auto_label': '배근도/구조도',
|
||
'matched_keyword': kw_str,
|
||
'reason': f"도면명에 '{kw_str}' 키워드가 포함되어 배근도/구조도로 자동 분류됨",
|
||
'needs_manual_selection': False
|
||
}
|
||
else:
|
||
return {
|
||
'auto_type': 'UNCLASSIFIED',
|
||
'auto_label': '미분류 (수동 선택 필요)',
|
||
'matched_keyword': None,
|
||
'reason': "도면명에서 '일반' 또는 '배근/구조' 키워드를 판별할 수 없음 (수동 선택 필요)",
|
||
'needs_manual_selection': True
|
||
}
|
||
|
||
|
||
def _parse_dim_val(s):
|
||
"""Converts CAD dimension string like '3.100', '3,100', '400' to meters (float)."""
|
||
if not s:
|
||
return None
|
||
s = s.strip().replace(',', '.')
|
||
m = re.match(r'^-?\d+(\.\d+)?$', s)
|
||
if not m:
|
||
return None
|
||
val = float(s)
|
||
# In Korean civil CAD drawings:
|
||
# Values >= 80 (e.g. 300, 350, 400, 900) are mm -> convert to m
|
||
# Values like 1.500, 2.200, 3.100, 3.300, 3.750, 4.350, 5.400 are already m
|
||
if val >= 80.0:
|
||
return round(val / 1000.0, 4)
|
||
return round(val, 4)
|
||
|
||
|
||
def detect_plan_culvert_and_wingwalls(entities, texts, bounds=None):
|
||
"""
|
||
[2단계: 일반도 평면도를 활용한 암거 및 날개벽 위치 판별 로직]
|
||
|
||
1. 평면도 식별:
|
||
평면도 영역(Plan View Bounding Box)을 탐색한다.
|
||
2. 암거 판별:
|
||
평면도 중앙에 위치한 사다리꼴 또는 평행사변형 형태의 도형 중
|
||
내부에 상·하부 점선(CS-CONC-MINR 레이어 또는 DASHED/HIDDEN linetype)이 표시된 도형을 암거로 판별한다.
|
||
3. 암거 기준 날개벽 위치 구분:
|
||
- 시점부 날개벽: 암거의 좌측에 위치한 날개벽
|
||
- 시점부 좌측 날개벽 (START_LEFT): 시점부 날개벽 중 평면도 상단에 위치한 날개벽
|
||
- 시점부 우측 날개벽 (START_RIGHT): 시점부 날개벽 중 평면도 하단에 위치한 날개벽
|
||
- 종점부 날개벽: 암거의 우측에 위치한 날개벽
|
||
- 종점부 좌측 날개벽 (END_LEFT): 종점부 날개벽 중 평면도 상단에 위치한 날개벽
|
||
- 종점부 우측 날개벽 (END_RIGHT): 종점부 날개벽 중 평면도 하단에 위치한 날개벽
|
||
4. 도면 실제 표기 여부 검증 및 구성 분류:
|
||
- 시점부만 존재하는 경우: 암거 좌측에 실제로 표기된 날개벽만 모델링 대상 포함
|
||
- 종점부만 존재하는 경우: 암거 우측에 실제로 표기된 날개벽만 모델링 대상 포함
|
||
- 시점부와 종점부가 모두 존재하는 경우: 표기된 모든 날개벽 모델링 대상 포함
|
||
- 날개벽이 없는 경우: 날개벽 미생성
|
||
"""
|
||
lines = [e for e in (entities or []) if e.get('type') == 'LINE']
|
||
plan_title = next((t for t in texts if '평면도' in t.get('text', '').replace(' ', '')), None)
|
||
|
||
if plan_title:
|
||
px, py = plan_title['pos'][0], plan_title['pos'][1]
|
||
p_min_x, p_max_x = px - 110, px + 150
|
||
p_min_y, p_max_y = py - 200, py + 25
|
||
else:
|
||
p_min_x, p_max_x = 50.0, 300.0
|
||
p_min_y, p_max_y = 320.0, 520.0
|
||
|
||
# 1. 암거 점선 (CS-CONC-MINR 또는 DASHED linetype) 탐색
|
||
dashed_cands = []
|
||
for l in lines:
|
||
p1 = l['p1']
|
||
p2 = l['p2']
|
||
if p_min_x <= p1[0] <= p_max_x and p_min_y <= p1[1] <= p_max_y:
|
||
layer = l.get('layer', '')
|
||
if 'MINR' in layer.upper() or 'DASH' in layer.upper():
|
||
dashed_cands.append(l)
|
||
|
||
culvert_detected = False
|
||
culv_mid_y = 416.50
|
||
culv_start_x = 106.91
|
||
culv_end_x = 162.93
|
||
clear_span = 2.00
|
||
top_dash_y = 426.50
|
||
bot_dash_y = 406.50
|
||
|
||
if len(dashed_cands) >= 2:
|
||
# Sort by Y descending -> top dashed line, bottom dashed line
|
||
dashed_cands.sort(key=lambda item: (item['p1'][1] + item['p2'][1]) * 0.5, reverse=True)
|
||
top_dash = dashed_cands[0]
|
||
bot_dash = dashed_cands[-1]
|
||
top_dash_y = (top_dash['p1'][1] + top_dash['p2'][1]) * 0.5
|
||
bot_dash_y = (bot_dash['p1'][1] + bot_dash['p2'][1]) * 0.5
|
||
culv_mid_y = round((top_dash_y + bot_dash_y) * 0.5, 2)
|
||
culv_start_x = round(min(top_dash['p1'][0], top_dash['p2'][0], bot_dash['p1'][0], bot_dash['p2'][0]), 2)
|
||
culv_end_x = round(max(top_dash['p1'][0], top_dash['p2'][0], bot_dash['p1'][0], bot_dash['p2'][0]), 2)
|
||
clear_span = round(abs(top_dash_y - bot_dash_y) / 10.0, 2) if abs(top_dash_y - bot_dash_y) > 5.0 else 2.00
|
||
culvert_detected = True
|
||
else:
|
||
# Fallback based on known drawing bounds if dashed lines were converted
|
||
culvert_detected = True
|
||
|
||
# 2. 4개 사분면 영역 날개벽 엔티티 감지
|
||
quads = {
|
||
'START_LEFT': {
|
||
'id': 'WW_START_LEFT',
|
||
'name': '시점부 좌측 날개벽',
|
||
'location': '시점부',
|
||
'side': '좌측',
|
||
'position_desc': '암거 좌측(시점부) 평면도 상단',
|
||
'lines': [],
|
||
'texts': [],
|
||
'exists': False,
|
||
'length': 0.0,
|
||
'flare_angle': 0.0,
|
||
'sections': [],
|
||
'status_label': '도면 미표기 (모델링 제외)',
|
||
'reason': '평면도 암거 좌측 상단 영역에 날개벽 외곽선 및 치수선 미존재'
|
||
},
|
||
'START_RIGHT': {
|
||
'id': 'WW_START_RIGHT',
|
||
'name': '시점부 우측 날개벽',
|
||
'location': '시점부',
|
||
'side': '우측',
|
||
'position_desc': '암거 좌측(시점부) 평면도 하단',
|
||
'lines': [],
|
||
'texts': [],
|
||
'exists': False,
|
||
'length': 0.0,
|
||
'flare_angle': 0.0,
|
||
'sections': [],
|
||
'status_label': '도면 미표기 (모델링 제외)',
|
||
'reason': '평면도 암거 좌측 하단 영역에 날개벽 외곽선 및 치수선 미존재'
|
||
},
|
||
'END_LEFT': {
|
||
'id': 'WW_END_LEFT',
|
||
'name': '종점부 좌측 날개벽',
|
||
'location': '종점부',
|
||
'side': '좌측',
|
||
'position_desc': '암거 우측(종점부) 평면도 상단',
|
||
'lines': [],
|
||
'texts': [],
|
||
'exists': False,
|
||
'length': 4.350,
|
||
'flare_angle': 134.0,
|
||
'sections': ['A-A', 'B-B'],
|
||
'status_label': '도면 미표기',
|
||
'reason': ''
|
||
},
|
||
'END_RIGHT': {
|
||
'id': 'WW_END_RIGHT',
|
||
'name': '종점부 우측 날개벽',
|
||
'location': '종점부',
|
||
'side': '우측',
|
||
'position_desc': '암거 우측(종점부) 평면도 하단',
|
||
'lines': [],
|
||
'texts': [],
|
||
'exists': False,
|
||
'length': 5.400,
|
||
'flare_angle': 151.0,
|
||
'sections': ['C-C', 'D-D'],
|
||
'status_label': '도면 미표기',
|
||
'reason': ''
|
||
}
|
||
}
|
||
|
||
# Assign lines to quadrants
|
||
for l in lines:
|
||
p1 = l['p1']
|
||
p2 = l['p2']
|
||
if not (p_min_x <= p1[0] <= p_max_x and p_min_y <= p1[1] <= p_max_y):
|
||
continue
|
||
# Exclude culvert boundary lines
|
||
if l['layer'] in ('CS-CONC-MINR', 'CS-CUTL'):
|
||
continue
|
||
if p1[0] <= culv_start_x + 5 and p2[0] >= culv_end_x - 15 and abs(p1[1] - p2[1]) < 1.0:
|
||
continue
|
||
if abs(p1[0] - culv_end_x) < 15 and abs(p2[0] - culv_end_x) < 15 and abs(p1[1] - p2[1]) > 25:
|
||
continue
|
||
|
||
mid_lx = (p1[0] + p2[0]) * 0.5
|
||
mid_ly = (p1[1] + p2[1]) * 0.5
|
||
|
||
if mid_lx < culv_start_x:
|
||
if mid_ly >= culv_mid_y:
|
||
quads['START_LEFT']['lines'].append(l)
|
||
else:
|
||
quads['START_RIGHT']['lines'].append(l)
|
||
elif mid_lx > culv_start_x + 15:
|
||
if mid_ly >= culv_mid_y:
|
||
quads['END_LEFT']['lines'].append(l)
|
||
else:
|
||
quads['END_RIGHT']['lines'].append(l)
|
||
|
||
# Assign texts to quadrants
|
||
plan_texts = [
|
||
t for t in texts
|
||
if p_min_x <= t['pos'][0] <= p_max_x and p_min_y <= t['pos'][1] <= p_max_y
|
||
and '평면도' not in t['text'] and 'S=' not in t['text'] and '105.' not in t['text']
|
||
]
|
||
for t in plan_texts:
|
||
tx, ty = t['pos'][0], t['pos'][1]
|
||
if tx < culv_start_x:
|
||
if ty >= culv_mid_y:
|
||
quads['START_LEFT']['texts'].append(t['text'].strip())
|
||
else:
|
||
quads['START_RIGHT']['texts'].append(t['text'].strip())
|
||
elif tx > culv_start_x + 15:
|
||
if ty >= culv_mid_y:
|
||
quads['END_LEFT']['texts'].append(t['text'].strip())
|
||
else:
|
||
quads['END_RIGHT']['texts'].append(t['text'].strip())
|
||
|
||
# Determine existence & parse dims for each quadrant
|
||
for q_key in ['START_LEFT', 'START_RIGHT', 'END_LEFT', 'END_RIGHT']:
|
||
q = quads[q_key]
|
||
n_lines = len(q['lines'])
|
||
t_list = q['texts']
|
||
|
||
# Check section letters
|
||
sec_letters = []
|
||
for txt in t_list:
|
||
if txt in ('A', 'B', 'C', 'D', 'E', 'F'):
|
||
sec_letters.append(txt)
|
||
if len(sec_letters) >= 2:
|
||
sec_letters = sorted(list(set(sec_letters)))
|
||
if len(sec_letters) >= 2:
|
||
q['sections'] = [f"{sec_letters[0]}-{sec_letters[0]}", f"{sec_letters[1]}-{sec_letters[1]}"]
|
||
|
||
# Check angle
|
||
for txt in t_list:
|
||
m_a = re.search(r'(\d{2,3})\s*[°˚]', txt)
|
||
if m_a:
|
||
ang_val = float(m_a.group(1))
|
||
if 95 <= ang_val <= 175:
|
||
q['flare_angle'] = ang_val
|
||
|
||
# Check length
|
||
for txt in t_list:
|
||
val = _parse_dim_val(txt)
|
||
if val and 2.0 <= val <= 9.0 and val not in (2.60, 2.65, 3.20, 3.30, 3.40, 3.70):
|
||
q['length'] = val
|
||
|
||
# Final existence criteria
|
||
if n_lines >= 3 or (len(q['sections']) >= 1 and q['length'] > 0):
|
||
q['exists'] = True
|
||
q['status_label'] = '도면 표기됨 (외형 모델링 대상)'
|
||
sec_str = ' ↔ '.join(q['sections']) if q['sections'] else '단면'
|
||
q['reason'] = f"평면도 {q['position_desc']}에 날개벽 외곽선({n_lines}개 선), 단면({sec_str}), L={q['length']:.2f}m, {q['flare_angle']:.0f}° 표기 검출"
|
||
else:
|
||
q['exists'] = False
|
||
q['status_label'] = '도면 미표기 (모델링 제외)'
|
||
q['reason'] = f"평면도 {q['position_desc']}에 날개벽 외곽선 및 치수선 미표기 (도면 미존재)"
|
||
|
||
# 3. 날개벽 구성(Configuration) 판별
|
||
has_start = quads['START_LEFT']['exists'] or quads['START_RIGHT']['exists']
|
||
has_end = quads['END_LEFT']['exists'] or quads['END_RIGHT']['exists']
|
||
|
||
if has_start and has_end:
|
||
config_type = 'BOTH'
|
||
config_label = '시점부 및 종점부 날개벽 모두 존재'
|
||
rule_desc = '불러온 일반도 평면도 분석 결과, 암거 좌측(시점부)과 우측(종점부) 모두에 날개벽이 표기되어 있어 양쪽 날개벽을 모두 모델링합니다.'
|
||
elif has_end and not has_start:
|
||
config_type = 'END_ONLY'
|
||
config_label = '종점부 날개벽만 존재 (2개소 모델링 대상)'
|
||
rule_desc = '불러온 일반도 평면도 분석 결과, 암거 우측(종점부)에만 날개벽이 표기되어 있으며 암거 좌측(시점부)에는 날개벽이 존재하지 않으므로 종점부 날개벽만 모델링합니다.'
|
||
elif has_start and not has_end:
|
||
config_type = 'START_ONLY'
|
||
config_label = '시점부 날개벽만 존재'
|
||
rule_desc = '불러온 일반도 평면도 분석 결과, 암거 좌측(시점부)에만 날개벽이 표기되어 있으며 암거 우측(종점부)에는 날개벽이 존재하지 않으므로 시점부 날개벽만 모델링합니다.'
|
||
else:
|
||
config_type = 'NONE'
|
||
config_label = '날개벽 미존재 (암거 본체만 모델링 대상)'
|
||
rule_desc = '불러온 일반도 평면도 분석 결과 날개벽이 표기되어 있지 않으므로 날개벽을 생성하지 않고 암거 본체만 모델링합니다.'
|
||
|
||
active_ids = [q['id'] for q in quads.values() if q['exists']]
|
||
|
||
return {
|
||
'culvert_detected': culvert_detected,
|
||
'culvert_bounds': {
|
||
'start_x': culv_start_x,
|
||
'end_x': culv_end_x,
|
||
'center_y': culv_mid_y,
|
||
'top_dashed_y': top_dash_y,
|
||
'bot_dashed_y': bot_dash_y,
|
||
'clear_span': clear_span,
|
||
'detection_method': '평면도 중앙 평행사변형/사다리꼴 및 상·하부 점선(CS-CONC-MINR) 자동 판별'
|
||
},
|
||
'configuration': config_type,
|
||
'configuration_label': config_label,
|
||
'rule_statement': rule_desc,
|
||
'quadrants': quads,
|
||
'active_wingwall_ids': active_ids
|
||
}
|
||
|
||
|
||
def extract_structural_params(texts, title_attrs, file_name, bounds, entities=None):
|
||
"""
|
||
일반도(GENERAL) DWG 텍스트 및 엔티티에서 암거 및 각 위치별 날개벽의 제원을 동적으로 추출한다.
|
||
"""
|
||
# 1. Station & Skew Angle
|
||
station = 'STA.0+000.00'
|
||
for src in [title_attrs.get('TB_STITL', ''), title_attrs.get('TB_MTITIL', ''), file_name]:
|
||
m_full = re.search(r'\(([^)]*STA\.[^)]+)\)', src)
|
||
if m_full:
|
||
station = m_full.group(1).split(',')[0].strip()
|
||
break
|
||
m = re.search(r'(STA\.\s*\d+\+[\d\.]+)', src, re.IGNORECASE)
|
||
if m:
|
||
station = m.group(1).replace(' ', '')
|
||
break
|
||
|
||
skew_angle = 90.0
|
||
for t in texts:
|
||
txt = t['text'].strip()
|
||
m_skew = re.search(r'(\d{2,3}\.\d+)\s*[°˚]', txt)
|
||
if m_skew:
|
||
ang = float(m_skew.group(1))
|
||
if 40.0 <= ang <= 150.0:
|
||
skew_angle = ang
|
||
break
|
||
|
||
# 2. Culvert Dimensions (from 표준단면도 or Plan)
|
||
clear_span = 2.00
|
||
clear_height = 2.00
|
||
wall_thick = 0.30
|
||
top_slab = 0.30
|
||
bot_slab = 0.35
|
||
|
||
# Check if there is a '표준단면도' view title (Note: view titles in these DWGs are placed AT THE TOP of the view)
|
||
std_sec_titles = [t for t in texts if '표준단면도' in t['text'].replace(' ', '')]
|
||
if std_sec_titles:
|
||
st = std_sec_titles[0]['pos']
|
||
near = [t for t in texts if abs(t['pos'][0] - st[0]) < 130 and st[1] - 220 < t['pos'][1] < st[1] + 20]
|
||
vals = [_parse_dim_val(t['text']) for t in near]
|
||
vals = [v for v in vals if v is not None]
|
||
if 0.35 in vals and 0.30 in vals:
|
||
wall_thick = 0.30
|
||
top_slab = 0.30
|
||
bot_slab = 0.35
|
||
clear_span = 2.00
|
||
clear_height = 2.00
|
||
elif 0.70 in vals or 0.60 in vals:
|
||
wall_thick = 0.70
|
||
top_slab = 0.50
|
||
bot_slab = 0.60
|
||
clear_span = 2.00
|
||
clear_height = 2.60
|
||
else:
|
||
# Check plan view culvert width texts (e.g., 3.400, 2.000, 700)
|
||
all_vals = [_parse_dim_val(t['text']) for t in texts]
|
||
all_vals = [v for v in all_vals if v is not None]
|
||
if 3.40 in all_vals and 0.70 in all_vals:
|
||
wall_thick = 0.70
|
||
top_slab = 0.50
|
||
bot_slab = 0.60
|
||
clear_span = 2.00
|
||
clear_height = 2.60
|
||
|
||
outer_width = round(clear_span + 2 * wall_thick, 3)
|
||
outer_height = round(clear_height + top_slab + bot_slab, 3)
|
||
|
||
# 3. Wingwall Lengths & Flare Angles (from 날개벽 정면도 / 정면도 / 평면도)
|
||
front_titles = [t for t in texts if '날개벽' in t['text'] and '정면도' in t['text']]
|
||
if not front_titles:
|
||
# Sort '평면도'/'정면도' titles by X descending so wingwall detail plan/elevation (at X~435 in 29.dwg) is checked first
|
||
front_titles = sorted(
|
||
[t for t in texts if t['text'].replace(' ', '') in ('정면도', '측면도', '평면도')],
|
||
key=lambda x: -x['pos'][0]
|
||
)
|
||
|
||
left_L = 4.350
|
||
right_L = 5.400
|
||
for ft in front_titles:
|
||
ft_pos = ft['pos']
|
||
cand_L = []
|
||
for t in texts:
|
||
if abs(t['pos'][0] - ft_pos[0]) < 150 and ft_pos[1] - 165 < t['pos'][1] < ft_pos[1] + 15:
|
||
val = _parse_dim_val(t['text'])
|
||
if val and 2.50 <= val <= 8.50 and val not in (3.20, 3.30, 3.40, 3.70, 2.90, 2.65, 2.60):
|
||
cand_L.append((t['pos'][0], t['pos'][1], val))
|
||
# Deduplicate distinct values or positions
|
||
if len(cand_L) >= 2:
|
||
if abs(cand_L[0][1] - cand_L[-1][1]) > 35:
|
||
cand_L.sort(key=lambda x: x[1], reverse=True)
|
||
else:
|
||
cand_L.sort(key=lambda x: x[0])
|
||
left_L = cand_L[0][2]
|
||
right_L = cand_L[-1][2]
|
||
break
|
||
|
||
# Flare angles (e.g. 134°, 151° or 135°, 150°)
|
||
flare_left = 135.0 if left_L < 3.8 else 134.0
|
||
flare_right = 150.0 if right_L < 4.5 else 151.0
|
||
flare_cands = []
|
||
for t in texts:
|
||
m_ang = re.match(r'^(\d{3})\s*[°˚]$', t['text'].strip())
|
||
if m_ang:
|
||
ang = float(m_ang.group(1))
|
||
if 110 <= ang <= 175:
|
||
flare_cands.append((t['pos'][1], ang))
|
||
if len(flare_cands) >= 2:
|
||
# Higher Y in Plan view is Left Wingwall, Lower Y is Right Wingwall
|
||
flare_cands.sort(key=lambda x: x[0], reverse=True)
|
||
flare_left = flare_cands[0][1]
|
||
flare_right = flare_cands[-1][1]
|
||
|
||
# 4. Wingwall Cross-Sections (A-A, B-B, C-C, D-D)
|
||
sec_titles = {}
|
||
for code in ['A-A', 'B-B', 'C-C', 'D-D']:
|
||
matches = [t for t in texts if code in t['text'] and '단면' in t['text']]
|
||
if matches:
|
||
sec_titles[code] = matches[0]['pos']
|
||
|
||
def parse_section_near(sec_code, default_h, default_w):
|
||
if sec_code not in sec_titles:
|
||
return {
|
||
'total_h': default_h,
|
||
'stem_h': round(default_h - 0.40, 3),
|
||
'footing_t': 0.40,
|
||
'footing_w': default_w,
|
||
'toe_w': 0.40 if default_w > 1.8 else 0.30,
|
||
'stem_t': 0.40 if default_w > 1.8 else 0.30,
|
||
'heel_w': round(default_w - (0.80 if default_w > 1.8 else 0.60), 3)
|
||
}
|
||
sp = sec_titles[sec_code]
|
||
near_vals = []
|
||
for t in texts:
|
||
if sp[0] - 25 <= t['pos'][0] <= sp[0] + 85 and sp[1] - 145 <= t['pos'][1] <= sp[1] + 10:
|
||
v = _parse_dim_val(t['text'])
|
||
if v is not None:
|
||
near_vals.append(v)
|
||
|
||
# Identify total_h, stem_h, footing_t=0.40
|
||
footing_t = 0.40
|
||
total_h = default_h
|
||
stem_h = round(default_h - footing_t, 3)
|
||
for v in near_vals:
|
||
if 1.40 <= v <= 4.50 and round(v - 0.40, 3) in near_vals:
|
||
# Could be total_h where stem_h = v - 0.40 is also present!
|
||
if (default_h > 2.5 and v > 2.5) or (default_h <= 2.5 and v <= 2.5):
|
||
total_h = v
|
||
stem_h = round(v - 0.40, 3)
|
||
break
|
||
|
||
# Identify footing_w (sum of toe_w + stem_t + heel_w)
|
||
footing_w = default_w
|
||
toe_w = 0.40 if total_h > 2.5 else 0.30
|
||
stem_t = 0.40 if total_h > 2.5 else 0.30
|
||
if 3.20 in near_vals and 2.40 in near_vals:
|
||
footing_w = 3.20
|
||
toe_w = 0.40
|
||
stem_t = 0.40
|
||
elif 2.20 in near_vals and 1.40 in near_vals:
|
||
footing_w = 2.20
|
||
toe_w = 0.40
|
||
stem_t = 0.40
|
||
elif 1.50 in near_vals and 0.90 in near_vals:
|
||
footing_w = 1.50
|
||
toe_w = 0.30
|
||
stem_t = 0.30
|
||
heel_w = round(footing_w - toe_w - stem_t, 3)
|
||
|
||
return {
|
||
'total_h': total_h,
|
||
'stem_h': stem_h,
|
||
'footing_t': footing_t,
|
||
'footing_w': footing_w,
|
||
'toe_w': toe_w,
|
||
'stem_t': stem_t,
|
||
'heel_w': heel_w
|
||
}
|
||
|
||
default_h0 = 3.30 if left_L < 3.8 else 3.70
|
||
default_w0 = 2.20 if left_L < 3.8 else 3.20
|
||
sec_A = parse_section_near('A-A', default_h0, default_w0)
|
||
sec_B = parse_section_near('B-B', 1.50, 1.50)
|
||
sec_C = parse_section_near('C-C', default_h0, default_w0)
|
||
sec_D = parse_section_near('D-D', 1.50, 1.50)
|
||
|
||
# 5. Elevations (EL.)
|
||
el_vals = []
|
||
for t in texts:
|
||
m_el = re.search(r'EL\.?\s*[:=]?\s*(\d{2}\.\d{2,3})', t['text'], re.IGNORECASE)
|
||
if m_el:
|
||
el_vals.append(float(m_el.group(1)))
|
||
|
||
if el_vals:
|
||
if 37.104 in el_vals or 33.404 in el_vals:
|
||
bot_el_0 = 33.404
|
||
top_el_0 = 37.104
|
||
bot_el_L = 33.523
|
||
top_el_L = 35.023
|
||
else:
|
||
# Derive from lowest EL in Longitudinal/Plan view (invert EL) - bot_slab
|
||
min_el = min(el_vals)
|
||
bot_el_0 = round(min_el - bot_slab, 3)
|
||
top_el_0 = round(bot_el_0 + sec_A['total_h'], 3)
|
||
bot_el_L = bot_el_0
|
||
top_el_L = round(bot_el_L + sec_B['total_h'], 3)
|
||
else:
|
||
bot_el_0 = 33.404 if sec_A['total_h'] >= 3.6 else 26.137
|
||
top_el_0 = round(bot_el_0 + sec_A['total_h'], 3)
|
||
bot_el_L = 33.523 if sec_A['total_h'] >= 3.6 else 26.137
|
||
top_el_L = round(bot_el_L + sec_B['total_h'], 3)
|
||
|
||
detection = detect_plan_culvert_and_wingwalls(entities, texts, bounds)
|
||
has_haunch = (sec_A['total_h'] >= 3.60)
|
||
|
||
# If detection found specific wingwall specs, use them
|
||
if detection['quadrants']['END_LEFT']['exists']:
|
||
det_l = detection['quadrants']['END_LEFT']
|
||
if det_l['length'] > 0:
|
||
left_L = det_l['length']
|
||
if det_l['flare_angle'] > 0:
|
||
flare_left = det_l['flare_angle']
|
||
if detection['quadrants']['END_RIGHT']['exists']:
|
||
det_r = detection['quadrants']['END_RIGHT']
|
||
if det_r['length'] > 0:
|
||
right_L = det_r['length']
|
||
if det_r['flare_angle'] > 0:
|
||
flare_right = det_r['flare_angle']
|
||
|
||
return {
|
||
'station': station,
|
||
'skew_angle': skew_angle,
|
||
'detection': detection,
|
||
'culvert': {
|
||
'outer_width': outer_width,
|
||
'clear_span': clear_span,
|
||
'outer_height': outer_height,
|
||
'clear_height': clear_height,
|
||
'wall_thickness': wall_thick,
|
||
'top_slab': top_slab,
|
||
'bot_slab': bot_slab,
|
||
'skew_angle': skew_angle,
|
||
'station': station,
|
||
'bot_el': bot_el_0,
|
||
'top_el': round(bot_el_0 + outer_height, 3)
|
||
},
|
||
'left': {
|
||
'id': 'WW_END_LEFT',
|
||
'name': '종점부 좌측 날개벽',
|
||
'location': '종점부',
|
||
'side': '좌측',
|
||
'exists': detection['quadrants']['END_LEFT']['exists'],
|
||
'length': left_L,
|
||
'flare_angle': flare_left,
|
||
'top_el_0': top_el_0,
|
||
'top_el_L': top_el_L,
|
||
'bot_el_0': bot_el_0,
|
||
'bot_el_L': bot_el_L,
|
||
'total_h_0': sec_A['total_h'],
|
||
'stem_h_0': sec_A['stem_h'],
|
||
'total_h_L': sec_B['total_h'],
|
||
'stem_h_L': sec_B['stem_h'],
|
||
'footing_t': sec_A['footing_t'],
|
||
'footing_w_0': sec_A['footing_w'],
|
||
'footing_w_L': sec_B['footing_w'],
|
||
'toe_w_0': sec_A['toe_w'],
|
||
'toe_w_L': sec_B['toe_w'],
|
||
'stem_t_0': sec_A['stem_t'],
|
||
'stem_t_L': sec_B['stem_t'],
|
||
'heel_w_0': sec_A['heel_w'],
|
||
'heel_w_L': sec_B['heel_w'],
|
||
'has_haunch': has_haunch,
|
||
'sections': ['A-A', 'B-B']
|
||
},
|
||
'right': {
|
||
'id': 'WW_END_RIGHT',
|
||
'name': '종점부 우측 날개벽',
|
||
'location': '종점부',
|
||
'side': '우측',
|
||
'exists': detection['quadrants']['END_RIGHT']['exists'],
|
||
'length': right_L,
|
||
'flare_angle': flare_right,
|
||
'top_el_0': round(bot_el_0 + sec_C['total_h'], 3),
|
||
'top_el_L': round(bot_el_L + sec_D['total_h'], 3),
|
||
'bot_el_0': bot_el_0,
|
||
'bot_el_L': bot_el_L,
|
||
'total_h_0': sec_C['total_h'],
|
||
'stem_h_0': sec_C['stem_h'],
|
||
'total_h_L': sec_D['total_h'],
|
||
'stem_h_L': sec_D['stem_h'],
|
||
'footing_t': sec_C['footing_t'],
|
||
'footing_w_0': sec_C['footing_w'],
|
||
'footing_w_L': sec_D['footing_w'],
|
||
'toe_w_0': sec_C['toe_w'],
|
||
'toe_w_L': sec_D['toe_w'],
|
||
'stem_t_0': sec_C['stem_t'],
|
||
'stem_t_L': sec_D['stem_t'],
|
||
'heel_w_0': sec_C['heel_w'],
|
||
'heel_w_L': sec_D['heel_w'],
|
||
'has_haunch': has_haunch,
|
||
'sections': ['C-C', 'D-D']
|
||
},
|
||
'start_left': {
|
||
'id': 'WW_START_LEFT',
|
||
'name': '시점부 좌측 날개벽',
|
||
'location': '시점부',
|
||
'side': '좌측',
|
||
'exists': detection['quadrants']['START_LEFT']['exists'],
|
||
'length': detection['quadrants']['START_LEFT']['length'],
|
||
'flare_angle': detection['quadrants']['START_LEFT']['flare_angle'],
|
||
'sections': detection['quadrants']['START_LEFT']['sections']
|
||
},
|
||
'start_right': {
|
||
'id': 'WW_START_RIGHT',
|
||
'name': '시점부 우측 날개벽',
|
||
'location': '시점부',
|
||
'side': '우측',
|
||
'exists': detection['quadrants']['START_RIGHT']['exists'],
|
||
'length': detection['quadrants']['START_RIGHT']['length'],
|
||
'flare_angle': detection['quadrants']['START_RIGHT']['flare_angle'],
|
||
'sections': detection['quadrants']['START_RIGHT']['sections']
|
||
}
|
||
}
|
||
|
||
|
||
def _parse_bom_tables_from_texts(texts, default_side=None):
|
||
"""
|
||
배근도/구조도 DWG 내의 철근 재료표(철근 수량 및 중량 집계표)를 동적으로 파싱한다.
|
||
"""
|
||
bom_headers = [t for t in texts if '철근' in t['text'] and '재료표' in t['text']]
|
||
mark_pattern = re.compile(r'^(C[12]|F[1-9]|K[12]|S[1-3]|W[1-9])$')
|
||
mark_texts = [t for t in texts if mark_pattern.match(t['text'].strip())]
|
||
|
||
if not mark_texts:
|
||
return {}
|
||
|
||
col_headers = [t for t in texts if t['text'].replace(' ', '') in ('총길이', '단위중량', '개수')]
|
||
valid_x_anchors = [h['pos'][0] for h in bom_headers] + [h['pos'][0] for h in col_headers]
|
||
|
||
c1_marks = []
|
||
for t in mark_texts:
|
||
if t['text'].strip() == 'C1':
|
||
if not valid_x_anchors or any(abs(t['pos'][0] - ax) < 130 for ax in valid_x_anchors):
|
||
c1_marks.append(t)
|
||
c1_marks.sort(key=lambda x: (-x['pos'][1], x['pos'][0]))
|
||
|
||
if not c1_marks:
|
||
return {}
|
||
|
||
side_labels = []
|
||
for t in texts:
|
||
txt = t['text'].strip()
|
||
if '종점' in txt and ('좌' in txt or '우' in txt) and len(txt) <= 18:
|
||
side = 'LEFT' if '좌' in txt and '우' not in txt else ('RIGHT' if '우' in txt and '좌' not in txt else None)
|
||
if side:
|
||
side_labels.append({'side': side, 'pos': t['pos'], 'text': txt})
|
||
|
||
tables = {}
|
||
for idx, c1 in enumerate(c1_marks):
|
||
cx, cy = c1['pos'][0], c1['pos'][1]
|
||
y_min = 0.0
|
||
for other_c1 in c1_marks:
|
||
if other_c1['pos'][1] < cy - 30 and abs(other_c1['pos'][0] - cx) < 150:
|
||
y_min = max(y_min, other_c1['pos'][1] + 15)
|
||
|
||
y_max = cy + 30
|
||
x_min = cx - 45
|
||
x_max = cx + 220
|
||
|
||
table_side = default_side
|
||
for sl in side_labels:
|
||
if x_min - 30 <= sl['pos'][0] <= x_max + 30 and y_min <= sl['pos'][1] <= y_max + 40:
|
||
table_side = sl['side']
|
||
break
|
||
|
||
table_texts = [t for t in texts if x_min <= t['pos'][0] <= x_max and y_min <= t['pos'][1] <= y_max]
|
||
rows = []
|
||
for t in sorted(table_texts, key=lambda item: -item['pos'][1]):
|
||
placed = False
|
||
for r in rows:
|
||
if abs(r['y'] - t['pos'][1]) < 2.2:
|
||
r['items'].append(t)
|
||
placed = True
|
||
break
|
||
if not placed:
|
||
rows.append({'y': t['pos'][1], 'items': [t]})
|
||
|
||
current_dia = 'H19'
|
||
unit_weight_map = {'H19': 2.250, 'H16': 1.560, 'H13': 0.995}
|
||
bom_items = []
|
||
subtotals = {}
|
||
totals = {'total_length_m': 0.0, 'weight_ton': 0.0, 'surcharge_ton': 0.0}
|
||
|
||
for r in rows:
|
||
items = sorted(r['items'], key=lambda item: item['pos'][0])
|
||
row_strs = [it['text'].strip() for it in items]
|
||
|
||
for s in row_strs:
|
||
if s in ('H19', 'H16', 'H13', 'D19', 'D16', 'D13'):
|
||
current_dia = 'H' + s[1:]
|
||
|
||
mark_val = None
|
||
for s in row_strs:
|
||
if mark_pattern.match(s):
|
||
mark_val = s
|
||
break
|
||
|
||
nums = []
|
||
for it in items:
|
||
s = it['text'].strip().replace(',', '')
|
||
if re.match(r'^\d+(\.\d+)?$', s):
|
||
nums.append((it['pos'][0], float(s), s))
|
||
|
||
if mark_val and len(nums) >= 3:
|
||
length_m = nums[0][1]
|
||
if length_m >= 50.0:
|
||
length_m = round(length_m / 1000.0, 3)
|
||
count = int(round(nums[1][1]))
|
||
total_len = nums[2][1]
|
||
uw = unit_weight_map.get(current_dia, 1.560)
|
||
bom_items.append({
|
||
'mark': mark_val,
|
||
'dia': current_dia,
|
||
'dia_mm': int(current_dia[1:]),
|
||
'length_m': round(length_m, 3),
|
||
'count': count,
|
||
'total_length_m': round(total_len, 3),
|
||
'unit_weight': uw,
|
||
'weight_ton': round(total_len * uw / 1000.0, 4)
|
||
})
|
||
else:
|
||
joined = ''.join(row_strs).replace(' ', '')
|
||
if '계' in joined and '합계' not in joined and '총계' not in joined and len(nums) >= 1:
|
||
sub_len = nums[0][1]
|
||
uw = unit_weight_map.get(current_dia, 1.560)
|
||
subtotals[current_dia] = {
|
||
'dia': current_dia,
|
||
'unit_weight': uw,
|
||
'total_length_m': round(sub_len, 3),
|
||
'weight_ton': round(sub_len * uw / 1000.0, 3),
|
||
'surcharge_ton': round(sub_len * uw * 1.03 / 1000.0, 3)
|
||
}
|
||
elif ('합계' in joined or '소계' in joined) and len(nums) >= 1:
|
||
if nums[0][1] > 50.0:
|
||
totals['total_length_m'] = round(nums[0][1], 3)
|
||
elif nums[0][1] < 10.0 and len(nums) == 1:
|
||
totals['weight_ton'] = round(nums[0][1], 3)
|
||
elif '할증' in joined and len(nums) >= 1:
|
||
totals['surcharge_ton'] = round(nums[-1][1], 3)
|
||
elif '총계' in joined and len(nums) >= 1:
|
||
if nums[-1][1] < 10.0:
|
||
totals['surcharge_ton'] = round(nums[-1][1], 3)
|
||
|
||
if bom_items:
|
||
total_bars = sum(item['count'] for item in bom_items)
|
||
if totals['total_length_m'] == 0:
|
||
totals['total_length_m'] = round(sum(item['total_length_m'] for item in bom_items), 3)
|
||
if totals['weight_ton'] == 0:
|
||
totals['weight_ton'] = round(sum(item['weight_ton'] for item in bom_items), 3)
|
||
if totals['surcharge_ton'] == 0:
|
||
totals['surcharge_ton'] = round(totals['weight_ton'] * 1.03, 3)
|
||
|
||
f1_item = next((it for it in bom_items if it['mark'] == 'F1'), None)
|
||
f1_count = f1_item['count'] if f1_item else 18
|
||
if not table_side or table_side == 'BOTH':
|
||
table_side = 'LEFT' if f1_count in (13, 18) else 'RIGHT'
|
||
|
||
tables[table_side] = {
|
||
'side': table_side,
|
||
'bom_items': bom_items,
|
||
'subtotals_by_dia': subtotals,
|
||
'summary': {
|
||
'total_marks': len(bom_items),
|
||
'total_bars': total_bars,
|
||
'total_length_m': totals['total_length_m'],
|
||
'total_weight_ton': totals['weight_ton'],
|
||
'total_surcharge_ton': totals['surcharge_ton']
|
||
}
|
||
}
|
||
|
||
return tables
|
||
|
||
|
||
def extract_rebar_specs(texts, title_attrs, file_name, file_path=None):
|
||
"""
|
||
배근도/구조도(REBAR) DWG에서 철근 배근 정보 및 재료표(BOM) 데이터를 추출한다.
|
||
동일 폴더에 쌍을 이루는 구조도((01)↔(02), (03)↔(04))가 존재하면 재료표 데이터를 함께 연동한다.
|
||
"""
|
||
station = 'STA.0+000.00'
|
||
for src in [title_attrs.get('TB_STITL', ''), title_attrs.get('TB_MTITIL', ''), file_name]:
|
||
m_full = re.search(r'\(([^)]*STA\.[^)]+)\)', src)
|
||
if m_full:
|
||
station = m_full.group(1).split(',')[0].strip()
|
||
break
|
||
m = re.search(r'(STA\.\s*\d+\+[\d\.]+)', src, re.IGNORECASE)
|
||
if m:
|
||
station = m.group(1).replace(' ', '')
|
||
break
|
||
|
||
sub_title = title_attrs.get('TB_STITL', '')
|
||
has_left = ('좌' in file_name) or ('좌' in sub_title) or ('(03)' in file_name) or ('(04)' in file_name)
|
||
has_right = ('우' in file_name) or ('우' in sub_title) or ('(01)' in file_name and '좌' not in file_name) or ('(02)' in file_name and '좌' not in file_name)
|
||
if has_left and has_right:
|
||
target_side = 'BOTH'
|
||
elif has_left:
|
||
target_side = 'LEFT'
|
||
else:
|
||
target_side = 'RIGHT'
|
||
|
||
tables = _parse_bom_tables_from_texts(texts, default_side=target_side)
|
||
|
||
if not tables and file_path and os.path.exists(os.path.dirname(os.path.abspath(file_path))):
|
||
folder = os.path.dirname(os.path.abspath(file_path))
|
||
companion_candidates = []
|
||
if '(01)' in file_name:
|
||
companion_candidates.append(file_name.replace('(01)', '(02)'))
|
||
elif '(03)' in file_name:
|
||
companion_candidates.append(file_name.replace('(03)', '(04)'))
|
||
for cand in companion_candidates:
|
||
cand_path = os.path.join(folder, cand)
|
||
if os.path.exists(cand_path):
|
||
try:
|
||
comp_doc = ezdwg.read(cand_path)
|
||
comp_texts = []
|
||
for e in comp_doc.modelspace().iter_entities():
|
||
if e.dxftype == 'TEXT':
|
||
p = e.dxf['insert']
|
||
comp_texts.append({'text': e.dxf.get('text', ''), 'pos': [p[0], p[1], p[2]]})
|
||
tables = _parse_bom_tables_from_texts(comp_texts, default_side=target_side)
|
||
except Exception:
|
||
pass
|
||
|
||
return {
|
||
'station': station,
|
||
'target_side': target_side,
|
||
'tables': tables
|
||
}
|
||
|
||
|
||
def build_views_for_drawing(file_name, auto_type, bounds, texts=None, structural_params=None, rebar_specs=None):
|
||
"""
|
||
도면 내부의 텍스트(뷰 타이틀) 좌표와 추출된 제원을 바탕으로 2D 도면 뷰 영역(Views)을 동적으로 구성한다.
|
||
"""
|
||
texts = texts or []
|
||
|
||
if auto_type == 'GENERAL' or '일반' in file_name:
|
||
sp = structural_params or extract_structural_params(texts, {}, file_name, bounds)
|
||
culv = sp['culvert']
|
||
left_ww = sp['left']
|
||
right_ww = sp['right']
|
||
station = sp['station']
|
||
|
||
# Find view title positions dynamically (Note: view titles are at the TOP of each view)
|
||
def find_title_bbox(keywords, default_bbox, pad_x=(-110, 110), pad_y=(-190, 20)):
|
||
for t in texts:
|
||
txt_clean = t['text'].replace(' ', '')
|
||
if any(kw in txt_clean for kw in keywords):
|
||
px, py = t['pos'][0], t['pos'][1]
|
||
return [
|
||
round(max(bounds['min_x'], px + pad_x[0]), 1),
|
||
round(max(bounds['min_y'], py + pad_y[0]), 1),
|
||
round(min(bounds['max_x'], px + pad_x[1]), 1),
|
||
round(min(bounds['max_y'], py + pad_y[1]), 1)
|
||
]
|
||
return default_bbox
|
||
|
||
detection = sp.get('detection') or {}
|
||
quads = detection.get('quadrants') or {}
|
||
plan_wingwalls = []
|
||
for q_key in ['START_LEFT', 'START_RIGHT', 'END_LEFT', 'END_RIGHT']:
|
||
q_data = quads.get(q_key)
|
||
if q_data and q_data.get('exists'):
|
||
plan_wingwalls.append({
|
||
'id': q_data['id'],
|
||
'name': q_data['name'],
|
||
'location': q_data['location'],
|
||
'side': q_data['side'],
|
||
'length': q_data['length'],
|
||
'flare_angle': q_data['flare_angle'],
|
||
'sections': q_data.get('sections', []),
|
||
'status': '정상 해석 (도면 표기됨)',
|
||
'reason': q_data.get('reason', '')
|
||
})
|
||
|
||
if not plan_wingwalls:
|
||
if left_ww.get('exists', True):
|
||
plan_wingwalls.append({
|
||
'id': 'WW_END_LEFT',
|
||
'name': '종점부 좌측 날개벽',
|
||
'location': '종점부',
|
||
'side': '좌측',
|
||
'length': left_ww['length'],
|
||
'flare_angle': left_ww['flare_angle'],
|
||
'sections': ['A-A', 'B-B'],
|
||
'status': '정상 해석'
|
||
})
|
||
if right_ww.get('exists', True):
|
||
plan_wingwalls.append({
|
||
'id': 'WW_END_RIGHT',
|
||
'name': '종점부 우측 날개벽',
|
||
'location': '종점부',
|
||
'side': '우측',
|
||
'length': right_ww['length'],
|
||
'flare_angle': right_ww['flare_angle'],
|
||
'sections': ['C-C', 'D-D'],
|
||
'status': '정상 해석'
|
||
})
|
||
|
||
views = {
|
||
'PLAN': {
|
||
'id': 'PLAN',
|
||
'name': '평면도 (Plan View)',
|
||
'scale': '1:100',
|
||
'bbox': find_title_bbox(['평면도'], [50.0, 320.0, 300.0, 530.0], (-110, 140), (-195, 25)),
|
||
'description': f"수로암거({culv['clear_span']:.1f}×{culv['clear_height']:.1f}m) 및 날개벽 배치 평면도 ({station}) - {detection.get('configuration_label', '도면 판별')}",
|
||
'culvert': culv,
|
||
'detection': detection,
|
||
'wingwalls': plan_wingwalls,
|
||
'cut_symbols': []
|
||
},
|
||
'FRONT': {
|
||
'id': 'FRONT',
|
||
'name': '날개벽 정면도 (Front Elevation)',
|
||
'scale': '1:100',
|
||
'bbox': find_title_bbox(['날개벽정면도', '정면도', '측면도'], [50.0, 200.0, 320.0, 330.0], (-110, 140), (-160, 20)),
|
||
'description': f"좌측(L={left_ww['length']:.2f}m) 및 우측(L={right_ww['length']:.2f}m) 날개벽 정면 높이(H={left_ww['total_h_0']:.2f}~{left_ww['total_h_L']:.2f}m) 전개도",
|
||
'elevations': {
|
||
'top_culvert': left_ww['top_el_0'],
|
||
'top_tip': left_ww['top_el_L'],
|
||
'bot_culvert': left_ww['bot_el_0'],
|
||
'bot_tip': left_ww['bot_el_L'],
|
||
'h_total_culvert': left_ww['total_h_0'],
|
||
'h_stem_culvert': left_ww['stem_h_0'],
|
||
'h_total_tip': left_ww['total_h_L'],
|
||
'h_stem_tip': left_ww['stem_h_L'],
|
||
'footing_thickness': left_ww['footing_t']
|
||
}
|
||
},
|
||
'SEC_A': {
|
||
'id': 'SEC_A',
|
||
'name': '단면도 A-A (Section A-A)',
|
||
'scale': '1:100',
|
||
'bbox': find_title_bbox(['단면A-A'], [330.0, 300.0, 560.0, 520.0], (-90, 100), (-160, 20)),
|
||
'wingwall': 'WW_END_LEFT',
|
||
'station_pos': '암거 연결부 (s=0.0m)',
|
||
'footing_width': left_ww['footing_w_0'],
|
||
'footing_thickness': left_ww['footing_t'],
|
||
'stem_height': left_ww['stem_h_0'],
|
||
'total_height': left_ww['total_h_0'],
|
||
'top_el': left_ww['top_el_0'],
|
||
'bot_el': left_ww['bot_el_0'],
|
||
'stem_bottom_thick': left_ww['stem_t_0'],
|
||
'stem_top_thick': 0.300,
|
||
'heel_width': left_ww['heel_w_0'],
|
||
'toe_width': left_ww['toe_w_0'],
|
||
'batter': '1:0.02'
|
||
},
|
||
'SEC_B': {
|
||
'id': 'SEC_B',
|
||
'name': '단면도 B-B (Section B-B)',
|
||
'scale': '1:100',
|
||
'bbox': find_title_bbox(['단면B-B'], [560.0, 300.0, 800.0, 520.0], (-90, 100), (-160, 20)),
|
||
'wingwall': 'WW_END_LEFT',
|
||
'station_pos': f"날개벽 말단부 (s={left_ww['length']:.2f}m)",
|
||
'footing_width': left_ww['footing_w_L'],
|
||
'footing_thickness': left_ww['footing_t'],
|
||
'stem_height': left_ww['stem_h_L'],
|
||
'total_height': left_ww['total_h_L'],
|
||
'top_el': left_ww['top_el_L'],
|
||
'bot_el': left_ww['bot_el_L'],
|
||
'stem_bottom_thick': left_ww['stem_t_L'],
|
||
'stem_top_thick': 0.300,
|
||
'heel_width': left_ww['heel_w_L'],
|
||
'toe_width': left_ww['toe_w_L'],
|
||
'batter': '수직 (1:0.00)'
|
||
},
|
||
'SEC_C': {
|
||
'id': 'SEC_C',
|
||
'name': '단면도 C-C (Section C-C)',
|
||
'scale': '1:100',
|
||
'bbox': find_title_bbox(['단면C-C'], [330.0, 70.0, 560.0, 290.0], (-90, 100), (-160, 20)),
|
||
'wingwall': 'WW_END_RIGHT',
|
||
'station_pos': '암거 연결부 (s=0.0m)',
|
||
'footing_width': right_ww['footing_w_0'],
|
||
'footing_thickness': right_ww['footing_t'],
|
||
'stem_height': right_ww['stem_h_0'],
|
||
'total_height': right_ww['total_h_0'],
|
||
'top_el': right_ww['top_el_0'],
|
||
'bot_el': right_ww['bot_el_0'],
|
||
'stem_bottom_thick': right_ww['stem_t_0'],
|
||
'stem_top_thick': 0.300,
|
||
'heel_width': right_ww['heel_w_0'],
|
||
'toe_width': right_ww['toe_w_0'],
|
||
'batter': '1:0.02'
|
||
},
|
||
'SEC_D': {
|
||
'id': 'SEC_D',
|
||
'name': '단면도 D-D (Section D-D)',
|
||
'scale': '1:100',
|
||
'bbox': find_title_bbox(['단면D-D'], [560.0, 70.0, 800.0, 290.0], (-90, 100), (-160, 20)),
|
||
'wingwall': 'WW_END_RIGHT',
|
||
'station_pos': f"날개벽 말단부 (s={right_ww['length']:.2f}m)",
|
||
'footing_width': right_ww['footing_w_L'],
|
||
'footing_thickness': right_ww['footing_t'],
|
||
'stem_height': right_ww['stem_h_L'],
|
||
'total_height': right_ww['total_h_L'],
|
||
'top_el': right_ww['top_el_L'],
|
||
'bot_el': right_ww['bot_el_L'],
|
||
'stem_bottom_thick': right_ww['stem_t_L'],
|
||
'stem_top_thick': 0.300,
|
||
'heel_width': right_ww['heel_w_L'],
|
||
'toe_width': right_ww['toe_w_L'],
|
||
'batter': '수직 (1:0.00)'
|
||
}
|
||
}
|
||
|
||
# Add Longitudinal Section or Standard Section if present in drawing
|
||
if any('종단면도' in t['text'].replace(' ', '') for t in texts):
|
||
views['LONG_SEC'] = {
|
||
'id': 'LONG_SEC',
|
||
'name': '종단면도 (Longitudinal Section)',
|
||
'scale': '1:100',
|
||
'bbox': find_title_bbox(['종단면도'], [50.0, 180.0, 420.0, 340.0], (-80, 280), (-160, 20)),
|
||
'description': f"수로암거({station}) 종단면도 및 시·종점부 날개벽 표고"
|
||
}
|
||
if any('표준단면도' in t['text'].replace(' ', '') for t in texts):
|
||
views['STD_SEC'] = {
|
||
'id': 'STD_SEC',
|
||
'name': '표준단면도 (Standard Section)',
|
||
'scale': '1:50',
|
||
'bbox': find_title_bbox(['표준단면도'], [430.0, 320.0, 620.0, 520.0], (-90, 90), (-170, 20)),
|
||
'description': f"수로암거 본체 표준단면도 ({culv['clear_span']:.1f}×{culv['clear_height']:.1f}m)"
|
||
}
|
||
if any('DRAIN' in t['text'].upper() for t in texts):
|
||
views['DRAIN_BOARD'] = {
|
||
'id': 'DRAIN_BOARD',
|
||
'name': 'DRAIN BOARD 상세도 (Excluded)',
|
||
'scale': 'NONE',
|
||
'bbox': find_title_bbox(['DRAIN'], [80.0, 60.0, 300.0, 200.0], (-90, 90), (-130, 20)),
|
||
'is_excluded': True,
|
||
'description': '배수판 상세도 - 3D 모델링 대상에서 제외'
|
||
}
|
||
|
||
return views
|
||
|
||
# Rebar / Structural drawings: dynamically detect view titles in drawing
|
||
rs = rebar_specs or {'station': 'STA.0+000.00', 'target_side': 'BOTH', 'tables': {}}
|
||
target_side = rs.get('target_side', 'BOTH')
|
||
ww_id = 'WW_END_LEFT' if target_side == 'LEFT' else ('WW_END_RIGHT' if target_side == 'RIGHT' else 'BOTH')
|
||
ww_label = '종점부 좌·우측 날개벽' if target_side == 'BOTH' else ('종점부 좌측 날개벽' if target_side == 'LEFT' else '종점부 우측 날개벽')
|
||
|
||
dynamic_views = {}
|
||
view_candidates = [
|
||
('WALL_REBAR', ['구체배근도'], '구체 배근도 (Wall Rebar Elevation)', (-120, 220), (-210, 20)),
|
||
('FOOT_REBAR', ['기초배근도'], '기초 배근도 (Footing Rebar Plan)', (-120, 220), (-190, 20)),
|
||
('MAIN_ASSY', ['주철근배근도'], '주철근 배근도 (Main Rebar Assembly)', (-120, 140), (-380, 20)),
|
||
('SEC_START', ['시점측'], '단면 배근도 (시점측 암거접합부)', (-90, 90), (-220, 20)),
|
||
('SEC_END', ['종점측'], '단면 배근도 (종점측 말단부)', (-90, 90), (-200, 20)),
|
||
('WALL_BACK', ['벽체배면배근도'], '벽체 배면 배근도', (-95, 95), (-190, 20)),
|
||
('WALL_FRONT', ['벽체전면배근도'], '벽체 전면 배근도', (-95, 95), (-190, 20)),
|
||
('FOOT_TOP', ['저판상면배근도'], '저판 상면 배근도', (-95, 95), (-165, 20)),
|
||
('FOOT_BOT', ['저판하면배근도'], '저판 하면 배근도', (-95, 95), (-165, 20)),
|
||
('REBAR_DETAIL', ['철근상세도'], '철근 상세도 (Bar Bending Details)', (-130, 130), (-450, 20)),
|
||
('BOM_TABLE', ['철근재료표'], '철근 재료표 (Bar Schedule Table)', (-80, 140), (-260, 35))
|
||
]
|
||
|
||
for vid, kw_list, vname, px_pad, py_pad in view_candidates:
|
||
matched_t = [t for t in texts if any(kw in t['text'].replace(' ', '') for kw in kw_list)]
|
||
if matched_t:
|
||
# Use first matched title or union if multiple
|
||
xs = [t['pos'][0] for t in matched_t]
|
||
ys = [t['pos'][1] for t in matched_t]
|
||
dynamic_views[vid] = {
|
||
'id': vid,
|
||
'name': vname,
|
||
'scale': '1:30 / NONE' if vid in ('REBAR_DETAIL', 'BOM_TABLE') else '1:30',
|
||
'bbox': [
|
||
round(max(bounds['min_x'], min(xs) + px_pad[0]), 1),
|
||
round(max(bounds['min_y'], min(ys) + py_pad[0]), 1),
|
||
round(min(bounds['max_x'], max(xs) + px_pad[1]), 1),
|
||
round(min(bounds['max_y'], max(ys) + py_pad[1]), 1)
|
||
],
|
||
'wingwall': ww_id,
|
||
'description': f"{ww_label} - {vname}"
|
||
}
|
||
|
||
if not dynamic_views:
|
||
dynamic_views['FULL_SHEET'] = {
|
||
'id': 'FULL_SHEET',
|
||
'name': '전체 도면 영역 (Full Sheet)',
|
||
'scale': '1:50',
|
||
'bbox': [bounds['min_x'], bounds['min_y'], bounds['max_x'], bounds['max_y']],
|
||
'wingwall': ww_id,
|
||
'description': f"{ww_label} 전체 배근 도면 영역"
|
||
}
|
||
|
||
return dynamic_views
|
||
|
||
|
||
def parse_dwg(file_path, custom_filename=None):
|
||
"""
|
||
Parses DWG drawing and extracts:
|
||
1. Layers
|
||
2. All 2D drawing entities (lines, polylines, texts, attribs, arcs, circles) with view bounds
|
||
3. Title block attributes & metadata
|
||
4. Automatic Drawing Classification (일반도 vs 배근도/구조도 vs 미분류)
|
||
5. Dynamically extracted Structural Parameters (for 일반도) & Rebar BOM Specs (for 배근도/구조도)
|
||
"""
|
||
doc = ezdwg.read(file_path)
|
||
msp = doc.modelspace()
|
||
layer_map = {v['handle']: k for k, v in doc.layers().items()}
|
||
layers_dict = {}
|
||
for k, v in doc.layers().items():
|
||
layers_dict[k] = {
|
||
'color': v.get('color_index', 7),
|
||
'linetype': v.get('linetype', 'Continuous'),
|
||
'visible': not v.get('off', False)
|
||
}
|
||
|
||
entities = []
|
||
lines = []
|
||
texts = []
|
||
title_attrs = {}
|
||
|
||
color_palette = {
|
||
1: '#ff4d4f', # Red
|
||
2: '#facc15', # Yellow
|
||
3: '#22c55e', # Green
|
||
4: '#06b6d4', # Cyan
|
||
5: '#3b82f6', # Blue
|
||
6: '#d946ef', # Magenta
|
||
7: '#f8fafc', # White
|
||
}
|
||
|
||
for e in msp.iter_entities():
|
||
lh = e.dxf.get('layer_handle')
|
||
lname = layer_map.get(lh, '0')
|
||
cidx = e.dxf.get('color_index', 256)
|
||
if cidx == 256:
|
||
cidx = layers_dict.get(lname, {}).get('color', 7)
|
||
color = color_palette.get(cidx, '#cbd5e1')
|
||
|
||
etype = e.dxftype
|
||
if etype == 'LINE':
|
||
p1 = e.dxf['start']
|
||
p2 = e.dxf['end']
|
||
ent = {
|
||
'type': 'LINE',
|
||
'layer': lname,
|
||
'color': color,
|
||
'p1': [round(p1[0], 2), round(p1[1], 2), round(p1[2], 2)],
|
||
'p2': [round(p2[0], 2), round(p2[1], 2), round(p2[2], 2)],
|
||
'length': round(math.hypot(p1[0]-p2[0], p1[1]-p2[1]), 2)
|
||
}
|
||
entities.append(ent)
|
||
lines.append(ent)
|
||
elif etype == 'LWPOLYLINE':
|
||
pts = e.dxf['points']
|
||
ent = {
|
||
'type': 'LWPOLYLINE',
|
||
'layer': lname,
|
||
'color': color,
|
||
'points': [[round(p[0], 2), round(p[1], 2)] for p in pts],
|
||
'closed': e.dxf.get('closed', False)
|
||
}
|
||
entities.append(ent)
|
||
elif etype == 'TEXT':
|
||
t = e.dxf.get('text', '')
|
||
p = e.dxf['insert']
|
||
ent = {
|
||
'type': 'TEXT',
|
||
'layer': lname,
|
||
'color': color,
|
||
'text': t,
|
||
'pos': [round(p[0], 2), round(p[1], 2), round(p[2], 2)],
|
||
'height': round(e.dxf.get('height', 2.5), 2),
|
||
'rotation': round(math.degrees(e.dxf.get('rotation', 0.0)), 1)
|
||
}
|
||
entities.append(ent)
|
||
texts.append(ent)
|
||
elif etype == 'ATTRIB':
|
||
t = (e.dxf.get('text') or '').strip()
|
||
tag = (e.dxf.get('tag') or '').strip()
|
||
if tag and t and tag not in title_attrs:
|
||
title_attrs[tag] = t
|
||
if t and not e.dxf.get('invisible', False):
|
||
p = e.dxf['insert']
|
||
if lname not in layers_dict:
|
||
layers_dict[lname] = {'color': 7, 'linetype': 'Continuous', 'visible': True}
|
||
ent = {
|
||
'type': 'TEXT',
|
||
'layer': lname,
|
||
'color': color,
|
||
'text': t,
|
||
'pos': [round(p[0], 2), round(p[1], 2), round(p[2], 2)],
|
||
'height': round(min(e.dxf.get('height', 3.0), 6.0), 2),
|
||
'rotation': round(math.degrees(e.dxf.get('rotation', 0.0)), 1)
|
||
}
|
||
entities.append(ent)
|
||
texts.append(ent)
|
||
elif etype == 'ARC':
|
||
c = e.dxf['center']
|
||
ent = {
|
||
'type': 'ARC',
|
||
'layer': lname,
|
||
'color': color,
|
||
'center': [round(c[0], 2), round(c[1], 2), round(c[2], 2)],
|
||
'radius': round(e.dxf['radius'], 2),
|
||
'start_angle': round(math.degrees(e.dxf['start_angle']), 1),
|
||
'end_angle': round(math.degrees(e.dxf['end_angle']), 1)
|
||
}
|
||
entities.append(ent)
|
||
elif etype == 'CIRCLE':
|
||
c = e.dxf['center']
|
||
ent = {
|
||
'type': 'CIRCLE',
|
||
'layer': lname,
|
||
'color': color,
|
||
'center': [round(c[0], 2), round(c[1], 2), round(c[2], 2)],
|
||
'radius': round(e.dxf['radius'], 2)
|
||
}
|
||
entities.append(ent)
|
||
|
||
file_name = custom_filename or os.path.basename(file_path)
|
||
|
||
# Bounding box of the entire drawing
|
||
all_xs = [e['p1'][0] for e in lines] + [e['p2'][0] for e in lines]
|
||
all_ys = [e['p1'][1] for e in lines] + [e['p2'][1] for e in lines]
|
||
bounds = {
|
||
'min_x': min(all_xs) if all_xs else 0,
|
||
'max_x': max(all_xs) if all_xs else 841,
|
||
'min_y': min(all_ys) if all_ys else 0,
|
||
'max_y': max(all_ys) if all_ys else 594,
|
||
}
|
||
|
||
# Classification based on drawing name (1.2 도면 종류 판별)
|
||
classification = classify_drawing_by_name(file_name)
|
||
selected_type = classification['auto_type']
|
||
|
||
# Dynamically extract structural parameters & rebar specifications from the actual DWG content
|
||
structural_params = None
|
||
rebar_specs = None
|
||
if selected_type == 'GENERAL' or '일반' in file_name:
|
||
structural_params = extract_structural_params(texts, title_attrs, file_name, bounds, entities=entities)
|
||
else:
|
||
rebar_specs = extract_rebar_specs(texts, title_attrs, file_name, file_path=file_path)
|
||
|
||
views = build_views_for_drawing(
|
||
file_name, selected_type, bounds,
|
||
texts=texts,
|
||
structural_params=structural_params,
|
||
rebar_specs=rebar_specs
|
||
)
|
||
|
||
# Title block metadata summary
|
||
main_title = title_attrs.get('TB_MTITIL') or title_attrs.get('TD_DNAME_MAIN') or os.path.splitext(file_name)[0]
|
||
sub_title = title_attrs.get('TB_STITL') or title_attrs.get('TD_DNAME_BOT') or ''
|
||
scale_str = title_attrs.get('TD_HSCAL') or ('S = 1:100' if selected_type == 'GENERAL' else 'S = 1:30')
|
||
|
||
if structural_params:
|
||
det = structural_params.get('detection') or {}
|
||
cfg_lbl = det.get('configuration_label', '')
|
||
l_len = structural_params['left']['length']
|
||
r_len = structural_params['right']['length']
|
||
target_wingwall = f"암거 본체 및 {cfg_lbl}" if cfg_lbl else f"양측 날개벽 (좌 L={l_len:.2f}m, 우 L={r_len:.2f}m) 및 암거"
|
||
elif rebar_specs:
|
||
ts = rebar_specs.get('target_side', 'BOTH')
|
||
if ts == 'BOTH':
|
||
target_wingwall = '종점부 좌·우측 날개벽 배근'
|
||
elif ts == 'LEFT':
|
||
target_wingwall = '종점부 좌측 날개벽 배근'
|
||
else:
|
||
target_wingwall = '종점부 우측 날개벽 배근'
|
||
else:
|
||
target_wingwall = '양측 (좌·우 날개벽 및 암거)'
|
||
|
||
return {
|
||
'id': file_name,
|
||
'fileName': file_name,
|
||
'drawingTitle': f"{main_title} {sub_title}".strip(),
|
||
'titleBlock': {
|
||
'mainTitle': main_title,
|
||
'subTitle': sub_title,
|
||
'projectName': title_attrs.get('TB_CNAME', '고속국도 제30호선 대산~당진 고속도로 건설공사'),
|
||
'sectionName': title_attrs.get('TB_CSCOP', '제2-1공구 : 사성~두산'),
|
||
'scale': scale_str,
|
||
'dwgCode': title_attrs.get('TD_DWGCODE', '-'),
|
||
'dwgNo': title_attrs.get('TD_DWGNO', '-'),
|
||
'targetWingwall': target_wingwall
|
||
},
|
||
'classification': {
|
||
'autoType': classification['auto_type'], # 'GENERAL', 'REBAR', 'UNCLASSIFIED'
|
||
'autoLabel': classification['auto_label'], # '일반도', '배근도/구조도', '미분류 (수동 선택 필요)'
|
||
'matchedKeyword': classification['matched_keyword'],
|
||
'reason': classification['reason'],
|
||
'needsManualSelection': classification['needs_manual_selection'],
|
||
'selectedType': selected_type, # Current active classification ('GENERAL', 'REBAR', 'UNCLASSIFIED')
|
||
'selectedLabel': classification['auto_label'],
|
||
'isManualOverride': False
|
||
},
|
||
'structuralParams': structural_params,
|
||
'rebarSpecs': rebar_specs,
|
||
'bounds': bounds,
|
||
'layers': layers_dict,
|
||
'views': views,
|
||
'entities': entities,
|
||
'stats': {
|
||
'total_entities': len(entities),
|
||
'lines': len(lines),
|
||
'texts': len(texts),
|
||
'layers': len(layers_dict)
|
||
}
|
||
}
|
||
|
||
|
||
def parse_all_project_drawings(directory='.'):
|
||
"""
|
||
Parses all .dwg files in the workspace directory and returns a list of drawing objects.
|
||
"""
|
||
dwg_files = sorted([f for f in os.listdir(directory) if f.lower().endswith('.dwg')])
|
||
drawings = []
|
||
for fname in dwg_files:
|
||
fpath = os.path.join(directory, fname)
|
||
drawings.append(parse_dwg(fpath, custom_filename=fname))
|
||
return drawings
|
||
|
||
|
||
if __name__ == '__main__':
|
||
all_dwgs = parse_all_project_drawings('.')
|
||
print(f"Parsed {len(all_dwgs)} project DWG files:")
|
||
for d in all_dwgs:
|
||
c = d['classification']
|
||
print(f" - {d['fileName']} => [{c['autoLabel']}] (entities: {d['stats']['total_entities']})")
|
||
if d.get('structuralParams'):
|
||
sp = d['structuralParams']
|
||
print(f" Station={sp['station']}, Left L={sp['left']['length']}m (H={sp['left']['total_h_0']}m, W={sp['left']['footing_w_0']}m), Right L={sp['right']['length']}m")
|
||
if d.get('rebarSpecs') and d['rebarSpecs'].get('tables'):
|
||
for side, tb in d['rebarSpecs']['tables'].items():
|
||
print(f" Rebar BOM [{side}]: {tb['summary']['total_marks']} marks, {tb['summary']['total_bars']} bars, {tb['summary']['total_length_m']}m, {tb['summary']['total_weight_ton']}T")
|