#!/usr/bin/env python3 """Extract this R2C general-arrangement DXF, reconstruct its sections, and serve a static viewer.""" import argparse from collections import Counter, defaultdict import functools import hashlib import html import http.server import json import math from pathlib import Path import re import shutil ROOT = Path(__file__).resolve().parent DEFAULT_DXF = ROOT / 'drawing' / 'R2C_일반도_20260601.dxf' def read_dxf(path): raw = Path(path).read_bytes() if raw.startswith(b'AutoCAD Binary DXF'): raise ValueError('ASCII DXF로 저장해 주세요. 바이너리 DXF는 지원하지 않습니다.') try: text = raw.decode('utf-8-sig') except UnicodeDecodeError: text = raw.decode('cp949') lines = text.splitlines() if len(lines) % 2: raise ValueError('DXF 그룹 코드/값 쌍이 올바르지 않습니다.') records, section, current = [], '', None for i in range(0, len(lines), 2): code, value = int(lines[i].strip()), lines[i + 1].strip() if code == 0: if current: records.append(current) current = {'type': value, 'section': section, 'tags': []} elif current: current['tags'].append((code, value)) if current['type'] == 'SECTION' and code == 2: section = value current['section'] = section if current: records.append(current) entities = [r for r in records if r['section'] == 'ENTITIES'] blocks, block = {}, None for record in records: if record['section'] != 'BLOCKS': continue if record['type'] == 'BLOCK': block = tag(record, 2) blocks[block] = [] elif record['type'] == 'ENDBLK': block = None elif block: blocks[block].append(record) return entities, blocks, raw def tag(e, code, default=None): return next((v for c, v in e['tags'] if c == code), default) def number(e, code, default=0): return float(tag(e, code, default)) def point(e, base=10): return [number(e, base), number(e, base + 10)] def clean_text(value): value = re.sub(r'\\U\+([0-9a-fA-F]{4})', lambda m: chr(int(m[1], 16)), value) value = re.sub(r'%%[cC]', 'Ø', value).replace('\\P', ' ') value = re.sub(r'\\[A-Za-z][^;]*;', '', value) return value.replace('{', '').replace('}', '') def vertices(e): out = [] for code, value in e['tags']: if code == 10: out.append([float(value), 0.0]) elif code == 20 and out: out[-1][1] = float(value) return out def extract_model(entities, raw): texts = [e for e in entities if e['type'] in ('TEXT', 'MTEXT')] headers = [e for e in texts if tag(e, 1) in ('D', 'D1', 'D2', 'L', 'L1', 'L2', 'L3', 'B', 'b1', 'b2', 'b3', 'b4', 'H', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'h7') and tag(e, 8) == '0'] if len(headers) != 20: raise ValueError('R2C 변화치수표의 20개 열을 찾지 못했습니다. 이 도면용 추출 규칙을 확인해 주세요.') header_y = number(headers[0], 20) table = defaultdict(dict) for e in texts: text = tag(e, 1, '') if tag(e, 8) != '0' or not re.fullmatch(r'[\d,]+', text) or not header_y - 600 < number(e, 20) < header_y - 20: continue nearest = min(headers, key=lambda h: abs(number(e, 10) - number(h, 10))) if abs(number(e, 10) - number(nearest, 10)) < 130: table[round(number(e, 20), 1)][tag(nearest, 1)] = float(text.replace(',', '')) rows = sorted((row for row in table.values() if len(row) == 20), key=lambda r: r['D']) title = next((clean_text(tag(e, 1)) for e in texts if '%%C' in tag(e, 1, '') and '일반' in tag(e, 1, '')), '') diameter_match = re.search(r'Ø\s*(\d+)', title) if not diameter_match: raise ValueError('원심력관의 지름 표기를 찾지 못했습니다.') diameter = float(diameter_match[1]) dims = next((r for r in rows if r['D'] == diameter), None) if dims is None: raise ValueError('현재 도면 지름과 일치하는 치수표 행이 없습니다.') label = next((e for e in texts if '단면 A-A' in tag(e, 1, '')), None) if not label: raise ValueError('단면 A-A를 찾지 못했습니다.') arcs = [e for e in entities if e['type'] == 'ARC' and tag(e, 8) == '외형선1'] aa = [e for e in arcs if number(label, 10) - 200 < number(e, 10) < number(label, 10) + 1800 and number(label, 20) - 2000 < number(e, 20) < number(label, 20)] bore = next((e for e in aa if abs(number(e, 40) - diameter / 2) < .01), None) if bore is None: raise ValueError('단면 A-A의 원형 관로를 찾지 못했습니다.') cx, cy = point(bore) section_lines = [e for e in entities if e['type'] == 'LINE' and tag(e, 8) == '외형선1' and cx - 900 < number(e, 10) < cx + 900 and cy - 800 < number(e, 20) < cy + 1000] coords = [p for e in section_lines for p in (point(e), point(e, 11))] bounds = [min(p[0] for p in coords), min(p[1] for p in coords), max(p[0] for p in coords), max(p[1] for p in coords)] width, height, axis = bounds[2] - bounds[0], bounds[3] - bounds[1], cy - bounds[1] if abs(width - dims['B']) > .1 or abs(height - dims['H']) > .1: raise ValueError('단면 외곽선과 치수표 값이 일치하지 않습니다.') # Inlet verticals expose the rectangular opening; the next inner edges are the lead-in. verticals = [e for e in section_lines if abs(number(e, 10) - number(e, 11)) < .01 and abs(number(e, 20) - number(e, 21)) > 100] inner = [e for e in verticals if cx - width / 2 + 50 < number(e, 10) < cx - diameter / 2] left = min(number(e, 10) for e in inner) inset = min(number(e, 10) for e in inner if number(e, 10) > left + 1) upper = max(max(number(e, 20), number(e, 21)) for e in inner if abs(number(e, 10) - left) < .01) - cy inner_top = max(max(number(e, 20), number(e, 21)) for e in inner if abs(number(e, 10) - inset) < .01) - cy bb_label = next(e for e in texts if '단면 B-B' in tag(e, 1, '')) bb = [e for e in arcs if number(bb_label, 10) - 200 < number(e, 10) < number(bb_label, 10) + 1800] radii = sorted({round(number(e, 40), 6) for e in bb}) if len(radii) != 5: raise ValueError('소켓부의 5개 원호 반지름을 찾지 못했습니다.') length = dims['L'] # Station positions come from the dimension block below the side elevation. labels = [e for e in texts if '측면도' in tag(e, 1, '')] side_label = labels[0] side = [e for e in entities if e['type'] == 'LINE' and tag(e, 8) == '외형선1' and number(side_label, 10) - 100 < number(e, 10) < number(side_label, 10) + length + 200 and number(side_label, 20) - height - 550 < number(e, 20) < number(side_label, 20)] start = min(number(e, 10) for e in side) hidden = [e for e in entities if e['type'] == 'LINE' and tag(e, 8) == '숨은선' and start - .1 <= number(e, 10) <= start + length + .1 and bounds[1] - 1 <= number(e, 20) <= bounds[3] + 1] stations = sorted({round(number(e, 10) - start, 6) for e in hidden if abs(number(e, 10) - number(e, 11)) < .01}) if len(stations) != 5: raise ValueError('측면도 소켓/변환 구간의 5개 위치를 찾지 못했습니다.') a, b, c, d, end_chamfer = stations end = length profiles = [ {'x': 0, 'kind': 'rectangle', 'halfWidth': cx - left, 'top': upper, 'bottom': diameter / 2}, {'x': a, 'kind': 'rectangle', 'halfWidth': cx - inset, 'top': inner_top, 'bottom': diameter / 2}, {'x': b, 'kind': 'circle', 'radius': diameter / 2}, {'x': c, 'kind': 'circle', 'radius': diameter / 2}, {'x': c, 'kind': 'circle', 'radius': radii[1]}, {'x': d, 'kind': 'circle', 'radius': radii[2]}, {'x': end_chamfer, 'kind': 'circle', 'radius': radii[3]}, {'x': end, 'kind': 'circle', 'radius': radii[4]}, ] pipe_circle = next(e for e in entities if e['type'] == 'CIRCLE' and abs(number(e, 40) - diameter / 2) < .01) pipe_axis = number(pipe_circle, 20) pipe = next(e for e in entities if e['type'] == 'LWPOLYLINE' and tag(e, 8) == 'CS-CONC-MAJR' and int(number(e, 70)) & 1 and all(p[1] > pipe_axis for p in vertices(e)) and min(p[0] for p in vertices(e)) < number(pipe_circle, 10)) pts = vertices(pipe) minimum = min(p[0] for p in pts) radial = [[round(x - minimum, 6), round(y - pipe_axis, 6)] for x, y in pts] result = { 'source': DEFAULT_DXF.name, 'sha256': hashlib.sha256(raw).hexdigest(), 'title': 'R2C관 · 사각–원형 전환관', 'diameter': diameter, 'dimensions': dims, 'width': width, 'height': height, 'length': length, 'axisHeight': axis, 'profiles': profiles, 'pipeProfile': radial, 'pipeDimensions': pipe_dimensions(radial), 'table': rows, 'counts': dict(Counter(e['type'] for e in entities if e['type'] not in ('SECTION', 'ENDSEC', 'EOF'))), 'evidence': {'sectionA': tag(bore, 5), 'sectionB': [tag(e, 5) for e in bb], 'pipeProfile': tag(pipe, 5), 'stations': stations}, 'limitations': ['2D 일반도 A-A/B-B와 치수표를 이용한 단면 기반 재구성입니다.', '사각–원형 전환 면은 단면 사이를 직선 보간했습니다. 철근과 개스킷은 모델에 포함하지 않았습니다.', '상·하부 접합부의 작은 홈은 원본 2D 도면에서 확인할 수 있으며 3D에서는 생략했습니다.'], } return result def pipe_dimensions(profile): inner = min(p[1] for p in profile) horizontal = [(abs(a[0] - b[0]), a[1]) for a, b in zip(profile, profile[1:] + profile[:1]) if abs(a[1] - b[1]) < .001] nominal = max(length for length, radius in horizontal if abs(radius - inner) < .001) outer = max((item for item in horizontal if item[1] > inner + 1), key=lambda item: item[0])[1] return {'nominalLength': nominal, 'overallLength': max(p[0] for p in profile), 'outerDiameter': outer * 2, 'wallThickness': outer - inner} def ray_rectangle(angle, half_width, top, bottom): c, s = math.cos(angle), math.sin(angle) t = min(half_width / max(abs(c), 1e-12), (top if s >= 0 else bottom) / max(abs(s), 1e-12)) return [t * c, t * s] def make_mesh(model): # Include rectangle corners and horizontal seam intersections in the angular sampling. angles = {round(i * math.tau / 96, 12) for i in range(96)} for p in model['profiles']: if p['kind'] == 'rectangle': for yy in [-p['halfWidth'], p['halfWidth']]: for zz in [-p['bottom'], p['top']]: angles.add(round(math.atan2(zz, yy) % math.tau, 12)) for yy in [-model['width'] / 2, model['width'] / 2]: for zz in [-model['axisHeight'], model['height'] - model['axisHeight']]: angles.add(round(math.atan2(zz, yy) % math.tau, 12)) angles = sorted(angles) verts, faces, groups = [], [], [] def ring(x, shape): indices = [] for angle in angles: if shape['kind'] == 'circle': yy, zz = shape['radius'] * math.cos(angle), shape['radius'] * math.sin(angle) else: yy, zz = ray_rectangle(angle, shape['halfWidth'], shape['top'], shape['bottom']) indices.append(len(verts)) verts.append([round(x - model['length'] / 2, 6), round(yy, 6), round(zz + model['axisHeight'], 6)]) return indices def join(a, b, kind): for i, angle in enumerate(angles): j = (i + 1) % len(angles) faces.append([a[i], a[j], b[j], b[i]]) groups.append({'part': 'upper' if math.sin(angle + .00001) >= 0 else 'lower', 'surface': kind}) outer = {'kind': 'rectangle', 'halfWidth': model['width'] / 2, 'top': model['height'] - model['axisHeight'], 'bottom': model['axisHeight']} oa, ob = ring(0, outer), ring(model['length'], outer) join(oa, ob, 'outer') rings = [ring(p['x'], p) for p in model['profiles']] for a, b in zip(rings, rings[1:]): join(b, a, 'inner') join(rings[0], oa, 'end') join(ob, rings[-1], 'end') r2c = {'vertices': verts, 'faces': faces, 'groups': groups} # Revolve the actual closed longitudinal pipe polyline; no guessed wall thickness. verts, faces, groups = [], [], [] radial = model['pipeProfile'] pipe_length = max(p[0] for p in radial) for x, r in radial: for a in angles: verts.append([x - pipe_length / 2, r * math.cos(a), r * math.sin(a) + model['axisHeight']]) n = len(angles) for k in range(len(radial)): for i, a in enumerate(angles): j, kk = (i + 1) % n, (k + 1) % len(radial) faces.append([k * n + i, k * n + j, kk * n + j, kk * n + i]) groups.append({'part': 'upper' if math.sin(a + .00001) >= 0 else 'lower', 'surface': 'pipe'}) return {'r2c': r2c, 'pipe': {'vertices': verts, 'faces': faces, 'groups': groups}} def spline_points(e): points = vertices(e) knots = [float(v) for c, v in e['tags'] if c == 40] weights = [float(v) for c, v in e['tags'] if c == 41] or [1.0] * len(points) degree = int(number(e, 71, 3)) if len(knots) != len(points) + degree + 1 or len(points) < degree + 1: return [] homogeneous = [[p[0] * w, p[1] * w, w] for p, w in zip(points, weights)] result = [] for step in range(65): u = knots[degree] + (knots[len(points)] - knots[degree]) * step / 64 span = len(points) - 1 if step == 64 else next(i for i in range(degree, len(points)) if knots[i] <= u < knots[i + 1]) d = [homogeneous[j + span - degree][:] for j in range(degree + 1)] for r in range(1, degree + 1): for j in range(degree, r - 1, -1): k = j + span - degree denom = knots[j + 1 + span - r] - knots[k] alpha = (u - knots[k]) / denom if denom else 0 d[j] = [(1 - alpha) * a + alpha * b for a, b in zip(d[j - 1], d[j])] x, y, w = d[degree] result.append([x / w, y / w]) return result def drawing_svg(entities, blocks): counts = Counter() def render(e, depth=0): if depth > 8: return '' typ, layer = e['type'], tag(e, 8, '0') if layer == 'Defpoints' or int(number(e, 67)) == 1: return '' color = '#cc6c49' if layer in ('CS-DIML', '치수', 'Dims') else '#7b98a7' if layer == '숨은선' else '#263c4b' style = f'fill="none" stroke="{color}" stroke-width="4" stroke-linejoin="round"' if layer == '숨은선': style += ' stroke-dasharray="18 12"' points = None if typ == 'LINE': points = [point(e), point(e, 11)] elif typ == 'LWPOLYLINE': points = vertices(e) if int(number(e, 70)) & 1: points += points[:1] elif typ in ('ARC', 'CIRCLE'): cx, cy = point(e) r = number(e, 40) a, b = math.radians(number(e, 50)), math.radians(number(e, 51, 360)) if b <= a: b += math.tau points = [[cx + r * math.cos(a + (b - a) * i / 96), cy + r * math.sin(a + (b - a) * i / 96)] for i in range(97)] elif typ == 'ELLIPSE': cx, cy = point(e) ax, ay = point(e, 11) ratio, a, b = number(e, 40), number(e, 41), number(e, 42, math.tau) points = [[cx + ax * math.cos(t) - ay * ratio * math.sin(t), cy + ay * math.cos(t) + ax * ratio * math.sin(t)] for t in [a + (b - a) * i / 96 for i in range(97)]] elif typ == 'SPLINE': points = spline_points(e) elif typ in ('TEXT', 'MTEXT'): text = ''.join(v for c, v in e['tags'] if c in (1, 3)) text = clean_text(text) x, y = point(e) angle = -number(e, 50) return f'{html.escape(text)}' elif typ in ('INSERT', 'DIMENSION'): content = ''.join(render(item, depth + 1) for item in blocks.get(tag(e, 2), [])) if typ == 'INSERT': x, y = point(e) return f'{content}' return content elif typ in ('HATCH', 'POINT', 'SOLID', 'SECTION', 'ENDSEC', 'EOF'): counts[typ] += 1 return '' else: counts[typ] += 1 return '' if points: p = ' '.join(f'{x:.3f},{-y:.3f}' for x, y in points) return f'' return '' content = ''.join(render(e) for e in entities) return f'{content}', dict(counts) def obj_text(mesh): lines = ['# R2C DXF section reconstruction; millimetres'] lines.extend('v ' + ' '.join(str(v) for v in p) for p in mesh['vertices']) for face in mesh['faces']: lines.append('f ' + ' '.join(str(i + 1) for i in face)) return '\n'.join(lines) + '\n' def initial_scene(model): """Pre-render real mesh polygons so opening HTML never depends on JS to show a model.""" yaw, pitch = .87, .40 def rotate(p): x, y, z = p[0], p[1], p[2] - model['height'] / 2 h, d = x * math.cos(yaw) - y * math.sin(yaw), x * math.sin(yaw) + y * math.cos(yaw) return [h, d * math.cos(pitch) - z * math.sin(pitch), z * math.cos(pitch) + d * math.sin(pitch)] mesh = model['meshes']['r2c'] rotated = [rotate(p) for p in mesh['vertices']] xs, ys = [p[0] for p in rotated], [p[2] for p in rotated] min_x, max_x, min_y, max_y = min(xs), max(xs), min(ys), max(ys) scale = min(780 / (max_x - min_x), 490 / (max_y - min_y)) result = [] order = sorted(range(len(mesh['faces'])), key=lambda i: -sum(rotated[v][1] for v in mesh['faces'][i]) / 4) for i in order: f, group = mesh['faces'][i], mesh['groups'][i] a, b, c = [mesh['vertices'][v] for v in f[:3]] u, v = [b[k] - a[k] for k in range(3)], [c[k] - a[k] for k in range(3)] n = [u[1] * v[2] - u[2] * v[1], u[2] * v[0] - u[0] * v[2], u[0] * v[1] - u[1] * v[0]] length = math.sqrt(sum(x * x for x in n)) or 1 n = [x / length for x in n] light = max(.48, .78 - n[0] * .22 - n[1] * .25 + n[2] * .28) base = [194, 204, 203] if group['part'] == 'upper' else [150, 171, 176] if group['surface'] == 'inner': base = [152, 171, 175] if group['part'] == 'upper' else [123, 150, 159] color = 'rgb(' + ','.join(str(min(255, round(x * light))) for x in base) + ')' points = ' '.join(f'{500 + (rotated[v][0] - (min_x + max_x) / 2) * scale:.2f},{345 - (rotated[v][2] - (min_y + max_y) / 2) * scale:.2f}' for v in f) result.append(f'') return ''.join(result) def build(dxf=DEFAULT_DXF, output=ROOT / 'dist', update_entry=True): dxf, output = Path(dxf).resolve(), Path(output).resolve() if output == ROOT or output == ROOT / 'drawing': raise ValueError('출력 폴더는 dist 같은 별도 폴더로 지정해 주세요.') entities, blocks, raw = read_dxf(dxf) model = extract_model(entities, raw) model['source'] = dxf.name model['meshes'] = make_mesh(model) svg, omissions = drawing_svg(entities, blocks) model['drawingOmissions'] = omissions output.mkdir(parents=True, exist_ok=True) template = (ROOT / 'web' / 'viewer.html').read_text(encoding='utf-8') template = template.replace('__INITIAL_SCENE__', initial_scene(model)).replace('__DXF_HREF__', './drawing/' + html.escape(dxf.name, quote=True)) page = template.replace('__MODEL_JSON__', json.dumps(model, ensure_ascii=False, separators=(',', ':')).replace('<', '\\u003c')).replace('__DRAWING_SVG__', svg) (output / 'index.html').write_text(page, encoding='utf-8') (output / 'model.json').write_text(json.dumps(model, ensure_ascii=False, indent=2), encoding='utf-8') (output / 'model.obj').write_text(obj_text(model['meshes']['r2c']), encoding='utf-8') (output / 'drawing.svg').write_text(svg, encoding='utf-8') (output / 'drawing').mkdir(exist_ok=True) shutil.copyfile(dxf, output / 'drawing' / dxf.name) if update_entry: (ROOT / 'index.html').write_text(page, encoding='utf-8') print(f'생성 완료: {output / "index.html"}') print(f'R2C: {model["length"]:g} × {model["width"]:g} × {model["height"]:g} mm / 내경 Ø{model["diameter"]:g}') return model def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument('command', choices=['build', 'serve'], nargs='?', default='serve') parser.add_argument('--dxf', type=Path, default=DEFAULT_DXF) parser.add_argument('--output', type=Path, default=ROOT / 'dist') parser.add_argument('--host', default='127.0.0.1', help='다른 기기에서 접속하려면 0.0.0.0') parser.add_argument('--port', type=int, default=8000) args = parser.parse_args() try: build(args.dxf, args.output) if args.command == 'serve': handler = functools.partial(http.server.SimpleHTTPRequestHandler, directory=str(args.output.resolve())) with http.server.ThreadingHTTPServer((args.host, args.port), handler) as server: print(f'웹 뷰어: http://{args.host}:{args.port} (종료: Ctrl+C)', flush=True) try: server.serve_forever() except KeyboardInterrupt: pass except (OSError, ValueError, StopIteration) as error: parser.exit(1, f'오류: {error}\n') if __name__ == '__main__': main()