Files
wingwall_3D_drawing/cad_parser.py
T

1365 lines
58 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import os
import re
import math
import json
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")