#!/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 from urllib.parse import unquote, urlsplit ROOT = Path(__file__).resolve().parent DEFAULT_DXF = ROOT / 'drawing' / 'R2C_일반도_20260601.dxf' MAX_UPLOAD_BYTES = 10 * 1024 * 1024 def read_dxf(path): return read_dxf_bytes(Path(path).read_bytes()) def read_dxf_bytes(raw): if len(raw) > MAX_UPLOAD_BYTES: raise ValueError('DXF 파일은 10 MB 이하로 첨부해 주세요.') 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 10 <= code <= 59 or 110 <= code <= 149 or 210 <= code <= 239: if not math.isfinite(float(value)): raise ValueError('DXF에 유효하지 않은 좌표나 치수가 있습니다.') 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) if not records or records[-1]['type'] != 'EOF' or records[-1]['tags']: raise ValueError('DXF 종료 표시(EOF)가 없습니다. 파일이 끝까지 저장되었는지 확인해 주세요.') 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 validate_profiles(profiles, width, height, axis_height, length): """Reject inner sections that leave no wall or fall outside the member.""" for profile in profiles: if not 0 <= profile['x'] <= length: raise ValueError('관로 단면 위치가 부재 길이를 벗어납니다.') if profile['kind'] == 'circle': sizes = [profile['radius']] * 3 else: sizes = [profile['halfWidth'], profile['top'], profile['bottom']] limits = [width / 2, height - axis_height, axis_height] if any(not math.isfinite(size) or size <= 0 or size >= limit for size, limit in zip(sizes, limits)): raise ValueError('관로 단면이 외곽과 간섭하거나 유효한 벽 두께가 없습니다.') def specification_model(base, row): """Use nominal table dimensions, retaining the drawn specification unchanged.""" values = list(row.values()) if len(row) != 20 or any(not math.isfinite(v) or v <= 0 for v in values): raise ValueError('변화치수표의 20개 치수는 모두 유효한 양수여야 합니다.') for total, parts in [('B', ['b3', 'b4', 'b4']), ('H', ['h6', 'h7'])]: if abs(row[total] - sum(row[key] for key in parts)) > .1: raise ValueError(f'Ø{row["D"]:g} 규격의 {total} 합계가 치수표와 일치하지 않습니다.') if abs(row['h6'] - row['h1'] - row['D'] / 2) > .1: raise ValueError(f'Ø{row["D"]:g} 규격의 관 중심 높이가 h1+D/2와 일치하지 않습니다.') geometry_warnings = [] if row['D'] == base['diameter']: profiles = [dict(p) for p in base['profiles']] basis = 'drawn' else: r = row['D'] / 2 lead = base['profiles'][1]['x'] tail = base['profiles'][3]['x'] - base['profiles'][2]['x'] socket_start = row['L'] - row['L2'] # Chamfer 10*10 is a drawing detail, not a scaled overall dimension. chamfer = 10.0 end_radius = row['D2'] / 2 + row['L3'] clearance = min(row['B'] / 2, row['h6'], row['h7']) if end_radius >= clearance: geometry_warnings.append(f'끝단 모따기 확장 반지름 {end_radius:g} mm가 외곽 여유 {clearance:g} mm와 간섭합니다. 이 규격은 끝단 모따기를 3D에서 생략하고 D2 내경을 유지했습니다.') end_radius = row['D2'] / 2 profiles = [ {'x': 0, 'kind': 'rectangle', 'halfWidth': row['b3'] / 2, 'top': r + row['h4'], 'bottom': r}, {'x': lead, 'kind': 'rectangle', 'halfWidth': r + row['b1'], 'top': r + row['h2'], 'bottom': r}, {'x': socket_start - tail, 'kind': 'circle', 'radius': r}, {'x': socket_start, 'kind': 'circle', 'radius': r}, {'x': socket_start, 'kind': 'circle', 'radius': row['D1'] / 2 - chamfer}, {'x': socket_start + chamfer, 'kind': 'circle', 'radius': row['D1'] / 2}, {'x': row['L'] - row['L3'], 'kind': 'circle', 'radius': row['D2'] / 2}, {'x': row['L'], 'kind': 'circle', 'radius': end_radius}, ] basis = 'table' if any(b['x'] < a['x'] for a, b in zip(profiles, profiles[1:])): raise ValueError(f'Ø{row["D"]:g} 규격의 변환부/소켓 길이 순서가 올바르지 않습니다.') if row['B'] - 2 * row['b2'] < row['D'] or row['h7'] <= row['D'] / 2: raise ValueError('변화치수표에서 외곽과 관로 치수가 맞지 않습니다.') validate_profiles(profiles, row['B'], row['H'], row['h6'], row['L']) # L1 differs between the nominal table and the dimensioned socket detail. warnings = geometry_warnings socket_nominal = row['L2'] - row['L3'] - 10 if abs(socket_nominal - row['L1']) > .1: warnings.append(f'L1 표기 {row["L1"]:g} mm와 L2−L3−모따기10 계산값 {socket_nominal:g} mm가 다릅니다. 소켓 전체 길이는 L2, 끝단은 L3를 기준으로 재구성했습니다.') result = { 'diameter': row['D'], 'dimensions': dict(row), 'width': row['B'], 'height': row['H'], 'length': row['L'], 'axisHeight': row['h6'], 'profiles': profiles, 'basis': basis, 'warnings': warnings, } if basis == 'table': result['reconstructionNote'] = '치수표 명목값으로 재구성한 규격입니다. 원본에서 읽은 100 mm 입구/연결 구간과 10×10 모따기를 공통 적용했습니다.' else: result['reconstructionNote'] = 'DXF에 실제로 그려진 단면과 소켓 원호를 사용한 규격입니다.' mesh_input = {**base, **result} result['meshes'] = {'r2c': make_mesh(mesh_input)['r2c']} return result def prepare_document(raw, filename): entities, blocks, raw = read_dxf_bytes(raw) model = extract_model(entities, raw) model['source'] = Path(filename.replace('\\', '/')).name model['meshes'] = make_mesh(model) model['variants'] = [specification_model(model, row) for row in model['table']] svg, omissions = drawing_svg(entities, blocks) model['drawingOmissions'] = omissions return {'model': model, 'drawingSvg': svg} class ViewerHandler(http.server.SimpleHTTPRequestHandler): """Static viewer plus in-memory DXF conversion; uploaded files are never saved.""" def send_json(self, status, payload): raw = json.dumps(payload, ensure_ascii=False, allow_nan=False, separators=(',', ':')).encode('utf-8') self.send_response(status) self.send_header('Content-Type', 'application/json; charset=utf-8') self.send_header('Content-Length', str(len(raw))) self.send_header('Cache-Control', 'no-store') self.end_headers() self.wfile.write(raw) def do_POST(self): if urlsplit(self.path).path != '/api/convert': self.send_json(404, {'error': '변환 API 경로가 올바르지 않습니다.'}) return try: length = int(self.headers.get('Content-Length', '0')) except ValueError: self.send_json(400, {'error': '파일 크기 정보가 올바르지 않습니다.'}) return if length <= 0: self.send_json(400, {'error': 'DXF 파일을 선택해 주세요.'}) return if length > MAX_UPLOAD_BYTES: self.close_connection = True self.send_json(413, {'error': 'DXF 파일은 10 MB 이하로 첨부해 주세요.'}) return filename = unquote(self.headers.get('X-Filename', 'drawing.dxf')) if not filename.lower().endswith('.dxf'): self.send_json(415, {'error': '.dxf 파일만 첨부할 수 있습니다.'}) return raw = self.rfile.read(length) if len(raw) != length: self.send_json(400, {'error': '첨부 파일 전송이 완료되지 않았습니다.'}) return try: payload = prepare_document(raw, filename) except (ValueError, UnicodeError, StopIteration, IndexError, KeyError, OverflowError) as error: message = str(error) or '필수 R2C 단면이나 치수표를 찾지 못했습니다.' self.send_json(422, {'error': 'DXF를 변환할 수 없습니다. ' + message}) return self.send_json(200, payload) 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 (degree < 1 or len(knots) != len(points) + degree + 1 or len(points) < degree + 1 or len(weights) != len(points) or any(w <= 0 for w in weights) or any(b < a for a, b in zip(knots, knots[1:]))): raise ValueError('SPLINE의 차수, 제어점, 가중치 또는 노트가 올바르지 않습니다.') if knots[degree] >= knots[len(points)]: raise ValueError('SPLINE의 유효 노트 구간이 없습니다.') 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] if not math.isfinite(w) or w <= 0: raise ValueError('SPLINE 가중치 계산 결과가 올바르지 않습니다.') sample = [x / w, y / w] if not all(math.isfinite(value) for value in sample): raise ValueError('SPLINE 좌표 계산 결과가 유효하지 않습니다.') result.append(sample) 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, port=8000): if not isinstance(port, int) or not 1 <= port <= 65535: raise ValueError('포트는 1~65535 사이의 정수여야 합니다.') dxf, output = Path(dxf).resolve(), Path(output).resolve() if output == ROOT or output == ROOT / 'drawing': raise ValueError('출력 폴더는 dist 같은 별도 폴더로 지정해 주세요.') payload = prepare_document(dxf.read_bytes(), dxf.name) model, svg = payload['model'], payload['drawingSvg'] output.mkdir(parents=True, exist_ok=True) template = (ROOT / 'web' / 'viewer.html').read_text(encoding='utf-8') template = template.replace('__SERVER_PORT__', str(port)).replace('http://localhost:8000/', f'http://localhost:{port}/') template = template.replace('__VIEWER_JS__', (ROOT / 'web' / 'viewer.js').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='0.0.0.0', help='수신 주소 (기본: 0.0.0.0, 로컬 전용: 127.0.0.1)') parser.add_argument('--port', type=int, default=8000) args = parser.parse_args() try: build(args.dxf, args.output, port=args.port) if args.command == 'serve': handler = functools.partial(ViewerHandler, 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()