commit 5ddc97c076b8c295ae44b79f8dd4a949fdf0e506 Author: Antigravity Agent Date: Wed Oct 21 18:51:57 2026 +0900 feat(parser): add 2D CAD DWG/DXF parsing and drawing classification engine diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3210348 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +__pycache__/ +*.py[cod] +*$py.class +.venv/ +venv/ +*.tmp +.DS_Store diff --git a/bundle_data.py b/bundle_data.py new file mode 100644 index 0000000..32105ef --- /dev/null +++ b/bundle_data.py @@ -0,0 +1,57 @@ +import json +import os +import cad_parser +import model_3d +import exporters + +os.makedirs('static/data', exist_ok=True) + +# 1. Parse all DWG drawings in current workspace and sibling project folders +known_drawings_map = {} +workspace_drawings = cad_parser.parse_all_project_drawings('.') +for d in workspace_drawings: + known_drawings_map[d['fileName']] = d + +parent_dir = '..' +sibling_sets = {'STA.13+611.21': workspace_drawings} +if os.path.exists(parent_dir): + for sub in sorted(os.listdir(parent_dir)): + sub_path = os.path.join(parent_dir, sub) + if os.path.isdir(sub_path) and os.path.abspath(sub_path) != os.path.abspath('.'): + sub_dwgs = cad_parser.parse_all_project_drawings(sub_path) + if sub_dwgs: + for d in sub_dwgs: + known_drawings_map[d['fileName']] = d + st = (sub_dwgs[0].get('structuralParams') or {}).get('station') or (sub_dwgs[0].get('rebarSpecs') or {}).get('station') or sub + sibling_sets[st] = sub_dwgs + +with open('all_drawings_data.json', 'w', encoding='utf-8') as f: + json.dump(list(known_drawings_map.values()), f, ensure_ascii=False, indent=2) + +# 2. Generate 3D exterior + rebar models for each drawing set +known_models_map = {} +for st_key, dwg_list in sibling_sets.items(): + known_models_map[st_key] = model_3d.generate_full_model(drawings_list=dwg_list) + +default_model = known_models_map.get('STA.13+611.21') or next(iter(known_models_map.values())) +with open('model_3d.json', 'w', encoding='utf-8') as f: + json.dump(default_model, f, ensure_ascii=False, indent=2) + +# 3. Bundle JS datasets for offline standalone lookup by uploaded filename +with open('static/data/drawing_data.js', 'w', encoding='utf-8') as f: + f.write('window.KNOWN_DRAWINGS_MAP = ' + json.dumps(known_drawings_map, ensure_ascii=False) + ';\n') + f.write('window.DEFAULT_DRAWING_DATA = null;\n') + f.write('window.DEFAULT_DRAWINGS_LIST = [];\n') + +with open('static/data/model_3d.js', 'w', encoding='utf-8') as f: + f.write('window.KNOWN_MODELS_MAP = ' + json.dumps(known_models_map, ensure_ascii=False) + ';\n') + f.write('window.DEFAULT_MODEL_3D = ' + json.dumps(default_model, ensure_ascii=False) + ';\n') + +with open('wingwall_model.obj', 'w', encoding='utf-8') as f: + f.write(exporters.export_obj(default_model)) +with open('wingwall_model.stl', 'w', encoding='utf-8') as f: + f.write(exporters.export_stl(default_model)) +with open('wingwall_model.dxf', 'w', encoding='utf-8') as f: + f.write(exporters.export_dxf_3d(default_model)) + +print(f"Bundled {len(known_drawings_map)} DWG drawings and {len(known_models_map)} 3D models into static/data/.") diff --git a/cad_parser.py b/cad_parser.py new file mode 100644 index 0000000..18a20d3 --- /dev/null +++ b/cad_parser.py @@ -0,0 +1,1364 @@ +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") diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..9d51a45 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,5 @@ +ezdxf>=1.4.3 +ezdwg>=0.16.3 +flask>=3.0.0 +flask-cors>=4.0.0 +numpy>=1.24.0