#!/usr/bin/env python3 """BLOCK8 단면 구조선과 블록 내부 콘크리트선을 모델 데이터로 변환한다.""" import json import math from collections import defaultdict from pathlib import Path ROOT = Path(__file__).resolve().parents[1] SOURCE = ROOT / "public/drawings/광터지하차도_BLOCK8.dxf" OUTPUT = ROOT / "src/model/block8.json" def read_pairs(path): lines = path.read_text(encoding="utf-8-sig").splitlines() if len(lines) % 2: raise ValueError("DXF 그룹 코드 줄 수가 짝수가 아닙니다.") return [(int(lines[i].strip()), lines[i + 1].strip()) for i in range(0, len(lines), 2)] def records(pairs, start, end): result = [] current = None for code, value in pairs[start:end]: if code == 0: if current: result.append(current) current = {"type": value.strip(), "data": []} elif current: current["data"].append((code, value.strip())) if current: result.append(current) return result def parse_sections(pairs, section_name): begin = next(i for i, (code, value) in enumerate(pairs) if code == 0 and value.strip() == "SECTION" and pairs[i + 1][0] == 2 and pairs[i + 1][1].strip() == section_name) end = next(i for i in range(begin + 2, len(pairs)) if pairs[i][0] == 0 and pairs[i][1].strip() == "ENDSEC") return begin, end def parse_blocks(pairs): begin, end = parse_sections(pairs, "BLOCKS") blocks = {} i = begin + 2 while i < end: if pairs[i][0] != 0 or pairs[i][1].strip() != "BLOCK": i += 1 continue block_start = i j = i + 1 while j < end and not (pairs[j][0] == 0 and pairs[j][1].strip() == "ENDBLK"): j += 1 header_end = next((k for k in range(block_start + 1, j) if pairs[k][0] == 0), j) header = pairs[block_start + 1:header_end] header_values = [value.strip() for code, value in header] name_pos = next(k for k, (code, value) in enumerate(header) if code == 100 and value.strip() == "AcDbBlockBegin") name = next(value.strip() for code, value in header[name_pos + 1:] if code == 2) base = [] for code, value in header[name_pos + 1:]: if code in (10, 20, 30): base.append(float(value)) if len(base) == 3: break base += [0.0] * (3 - len(base)) blocks[name] = {"base": base, "entities": records(pairs, header_end, j)} i = j + 1 return blocks def parse_modelspace(pairs): begin, end = parse_sections(pairs, "ENTITIES") return records(pairs, begin + 2, end) def props(entity): result = defaultdict(list) for code, value in entity["data"]: result[code].append(value) return result def first(p, code, default=None): return p[code][0] if code in p else default def layer(entity): return first(props(entity), 8, "0") def line_points(entity): p = props(entity) return [(float(first(p, 10)), float(first(p, 20))), (float(first(p, 11)), float(first(p, 21)))] def normalized_segment(entity, center_x, y0, el0): points = line_points(entity) points.sort(key=lambda p: (round(p[0], 6), p[1])) profile = [[(x - center_x) / 1000, el0 + (y - y0) / 1000] for x, y in points] key = (layer(entity), round((points[0][0] - center_x) / 1000, 3), round((points[1][0] - center_x) / 1000, 3)) average = sum(y for x, y in profile) / 2 slope = profile[1][1] - profile[0][1] return key, (average, slope), profile def cross_section(entities, y_bounds, center_x, y0, el0): grouped = defaultdict(list) for entity in entities: if entity["type"] != "LINE" or layer(entity) not in ("CS-CONC-MAJR", "CS-CONC-MINR"): continue points = line_points(entity) if not all(y_bounds[0] <= y <= y_bounds[1] for x, y in points): continue if not all(1_280_000 <= x <= 1_310_000 for x, y in points): continue key, sort_key, profile = normalized_segment(entity, center_x, y0, el0) grouped[key].append({"profile": profile, "sort": sort_key, "handle": first(props(entity), 5, "")}) return grouped def arc_points(entity, steps=16): p = props(entity) cx, cy = float(first(p, 10)), float(first(p, 20)) radius = float(first(p, 40)) start = math.radians(float(first(p, 50))) end = math.radians(float(first(p, 51))) while end < start: end += 2 * math.pi return [(cx + radius * math.cos(start + (end - start) * i / steps), cy + radius * math.sin(start + (end - start) * i / steps)) for i in range(steps + 1)] def block_entity_points(entity): p = props(entity) if entity["type"] == "LINE": return [(float(first(p, 10)), float(first(p, 20))), (float(first(p, 11)), float(first(p, 21)))] if entity["type"] == "ARC": return arc_points(entity, 16) return [] def transform_block(points, insert, base): p = props(insert) ix, iy = float(first(p, 10)), float(first(p, 20)) sx, sy = float(first(p, 41, "1")), float(first(p, 42, "1")) angle = math.radians(float(first(p, 50, "0"))) result = [] for x, y in points: x, y = (x - base[0]) * sx, (y - base[1]) * sy result.append((ix + x * math.cos(angle) - y * math.sin(angle), iy + x * math.sin(angle) + y * math.cos(angle))) return result def intersect_line_arc(line, arc): (x0, y0), (x1, y1) = line p = props(arc) cx, cy, radius = float(first(p, 10)), float(first(p, 20)), float(first(p, 40)) dx, dy = x1 - x0, y1 - y0 fx, fy = x0 - cx, y0 - cy aa = dx * dx + dy * dy bb = 2 * (fx * dx + fy * dy) cc = fx * fx + fy * fy - radius * radius disc = bb * bb - 4 * aa * cc if disc < 0: return [] roots = [(-bb - math.sqrt(disc)) / (2 * aa), (-bb + math.sqrt(disc)) / (2 * aa)] start, end = float(first(p, 50)), float(first(p, 51)) points = [] for t in roots: if not 0 <= t <= 1: continue x, y = x0 + t * dx, y0 + t * dy theta = math.degrees(math.atan2(y - cy, x - cx)) % 360 if start - 0.01 <= theta <= end + 0.01: points.append((theta, x, y)) return points def alignment_units(entities): arcs = [] for entity in entities: p = props(entity) if entity["type"] == "ARC" and first(p, 8) == "CS-CNTL" and abs(float(first(p, 40, "0")) - 1_700_000) < 1: arcs.append(entity) cross_lines = [] for entity in entities: if entity["type"] != "LINE" or layer(entity) != "CS-CONC-MAJR": continue (x0, y0), (x1, y1) = line_points(entity) if -90_000 < y0 < -60_000 and -90_000 < y1 < -60_000 and abs(y1 - y0) > 19_000 and abs(y1 - y0) < 21_000 and abs(x1 - x0) < 200: cross_lines.append(entity) if len(arcs) != 2: raise ValueError(f"평면 중심선 원호 수가 예상과 다릅니다: {len(arcs)}") center_x_origin = sum(float(first(props(arc), 10)) for arc in arcs) / len(arcs) center_y_origin = float(first(props(arcs[0]), 20)) - float(first(props(arcs[0]), 40)) units = [] for index, arc in enumerate(sorted(arcs, key=lambda e: float(first(props(e), 10)))): acx = float(first(props(arc), 10)) candidates = [line for line in cross_lines if abs(sum(x for x, y in line_points(line)) / 2 - acx) < 12_000] intersections = [] boundary_handles = [] for line in candidates: found = intersect_line_arc(line_points(line), arc) if found: intersections.extend(found) boundary_handles.append(first(props(line), 5, "")) intersections.sort() if len(intersections) != 2: raise ValueError(f"평면 중심선 {index + 1}의 구조선 교점이 2개가 아닙니다: {len(intersections)}") theta0, theta1 = intersections[0][0], intersections[1][0] radius_m = float(first(props(arc), 40)) / 1000 length = radius_m * math.radians(theta1 - theta0) units.append({ "id": f"alignment-{index + 1}", "name": "BLOCK8 기준 중심선", "center": [(acx - center_x_origin) / 1000, (float(first(props(arc), 20)) - center_y_origin) / 1000], "planOrigin": [center_x_origin, center_y_origin], "radius": radius_m, "startAngle": theta0, "endAngle": theta1, "length": length, "centerlineHandle": first(props(arc), 5, ""), "boundaryHandles": boundary_handles, }) # 평면도와 상부평면도에 반복된 중심선은 같은 구조물의 도면 표현이다. # 첫 번째 중심선을 모델 배치 기준으로 사용하고 두 번째는 검산에만 쓴다. return units[:1] def add_block_parts(parts, entities, blocks, center_x, sections): block_name = "A$C2017021411375356" block = blocks[block_name] concrete_entities = [e for e in block["entities"] if layer(e) in ("CS-CONC-MAJR", "CS-CONC-MINR") and e["type"] in ("LINE", "ARC")] inserts = [] for entity in entities: if entity["type"] != "INSERT" or first(props(entity), 2) != block_name: continue p = props(entity) x, y = float(first(p, 10)), float(first(p, 20)) if 1_280_000 < x < 1_310_000 and any(abs(y - sy) < 100 for sy in (-76_987.31555584459, -96_306.66955584464)): inserts.append(entity) # 각 단면에서 좌우 삽입점 한 개씩 확인한다. section_inserts = [] for section in sections: chosen = [e for e in inserts if abs(float(first(props(e), 20)) - section["insertY"]) < 1] chosen.sort(key=lambda e: float(first(props(e), 10))) if len(chosen) != 2: raise ValueError(f"블록 내부 콘크리트 삽입점 수가 예상과 다릅니다: {len(chosen)}") section_inserts.append(chosen) result = [] for side_idx, side_name in enumerate(("left", "right")): start_insert, end_insert = section_inserts[0][side_idx], section_inserts[1][side_idx] for entity_idx, block_entity in enumerate(concrete_entities): start_raw = transform_block(block_entity_points(block_entity), start_insert, block["base"]) end_raw = transform_block(block_entity_points(block_entity), end_insert, block["base"]) start_profile = [[(x - center_x) / 1000, sections[0]["elTop"] + (y - sections[0]["yTop"]) / 1000] for x, y in start_raw] end_profile = [[(x - center_x) / 1000, sections[1]["elTop"] + (y - sections[1]["yTop"]) / 1000] for x, y in end_raw] result.append({ "id": f"insert-{side_name}-{entity_idx + 1:02d}", "name": f"블록 내부 윤곽 {side_name} {entity_idx + 1:02d}", "layer": layer(block_entity), "group": "insert", "sourceHandle": first(props(block_entity), 5, ""), "sourceInsertHandles": [first(props(start_insert), 5, ""), first(props(end_insert), 5, "")], "entityType": block_entity["type"], "a": start_profile, "b": end_profile, }) return result def main(): pairs = read_pairs(SOURCE) blocks = parse_blocks(pairs) entities = parse_modelspace(pairs) center_x = 1_296_487.109248054 section_defs = [ {"id": "A", "station": 319.000, "yBounds": (-80_000, -70_000), "y0": -78_059.37855593952, "el0": 101.777, "yTop": -72_621.37855593952, "elTop": 107.215, "centerY": -77_742.31539793951, "insertY": -76_987.31555584459}, {"id": "B", "station": 339.000, "yBounds": (-99_000, -89_000), "y0": -97_286.37855593952, "el0": 101.292, "yTop": -90_721.37855593952, "elTop": 107.857, "centerY": -97_061.6690599395, "insertY": -96_306.66955584464}, ] grouped_a = cross_section(entities, section_defs[0]["yBounds"], center_x, section_defs[0]["y0"], section_defs[0]["el0"]) grouped_b = cross_section(entities, section_defs[1]["yBounds"], center_x, section_defs[1]["y0"], section_defs[1]["el0"]) if set(grouped_a) != set(grouped_b): raise ValueError("A-A/B-B 구조선의 X 범위/레이어 구성이 일치하지 않습니다.") parts = [] line_no = defaultdict(int) for key in sorted(grouped_a): a_list = sorted(grouped_a[key], key=lambda item: (item["sort"][0], item["sort"][1])) b_list = sorted(grouped_b[key], key=lambda item: (item["sort"][0], item["sort"][1])) if len(a_list) != len(b_list): raise ValueError(f"단면 대응 선 개수가 다릅니다: {key}") for a_item, b_item in zip(a_list, b_list): source_layer = key[0] group = "major" if source_layer == "CS-CONC-MAJR" else "minor" line_no[group] += 1 parts.append({ "id": f"{group}-{line_no[group]:03d}", "name": f"{source_layer} 구조선 {line_no[group]:03d}", "layer": source_layer, "group": group, "sourceHandles": [a_item["handle"], b_item["handle"]], "entityType": "LINE", "a": a_item["profile"], "b": b_item["profile"], }) if line_no != {"major": 108, "minor": 6}: raise ValueError(f"구조선 수 확인 실패: {dict(line_no)}") parts += add_block_parts(parts, entities, blocks, center_x, section_defs) units = alignment_units(entities) for unit in units: unit["parts"] = [dict(part, id=f"{unit['id']}-{part['id']}") for part in parts] data = { "source": "광터지하차도_BLOCK8.dxf", "unit": "m", "coordinateNote": "CAD 좌표 차이를 치수 문자열로 검산해 mm로 해석. 표고는 종단/단면 EL 주석에 연결.", "sections": [ {"id": "A-A", "station": 319.000, "centerEL": section_defs[0]["elTop"] + (section_defs[0]["centerY"] - section_defs[0]["yTop"]) / 1000, "topEL": 107.215, "baseTopEL": 101.777, "baseBottomEL": 100.877, "sourceHandles": ["STA.0K+319.000", "CS-CNTL center mark", "EL.102.094", "EL.107.215", "EL.100.877"]}, {"id": "B-B", "station": 339.000, "centerEL": section_defs[1]["elTop"] + (section_defs[1]["centerY"] - section_defs[1]["yTop"]) / 1000, "topEL": 107.857, "baseTopEL": 101.292, "baseBottomEL": 100.392, "sourceHandles": ["STA.0K+339.000", "CS-CNTL center mark", "EL.101.517", "EL.107.857", "EL.100.392"]}, ], "sectionStationRange": [319.000, 339.000], "modelStationRange": [319.010, 338.990], "modelLength": 19.980, "sectionTrim": 0.010, "sectionLineCount": {"CS-CONC-MAJR": 108, "CS-CONC-MINR": 6}, "expandedBlock": {"name": "A$C2017021411375356", "layerCounts": {"CS-CONC-MAJR": 18, "CS-CONC-MINR": 0}, "curveCount": 2, "insertCount": 4}, "profileWidth": {"dimensionText": 20.300, "outerLineEnvelope": 20.500, "status": "dimension value and outer contour differ; retain separately"}, "units": units, } OUTPUT.parent.mkdir(parents=True, exist_ok=True) OUTPUT.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") print(json.dumps({"units": len(units), "lengths": [round(u["length"], 6) for u in units], "partsPerUnit": len(parts), "layers": dict(line_no), "expandedBlockPartsPerSide": 20}, ensure_ascii=False)) if __name__ == "__main__": main()