Files
TDC_R2C_Drawing/r2c.py
T

570 lines
30 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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)
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'<text x="{x}" y="{-y}" font-size="{number(e, 40, 35)}" fill="{color}" transform="rotate({angle} {x} {-y})">{html.escape(text)}</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'<g transform="translate({x},{-y}) rotate({-number(e, 50)}) scale({number(e, 41, 1)},{number(e, 42, 1)})">{content}</g>'
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'<polyline points="{p}" {style}/>'
return ''
content = ''.join(render(e) for e in entities)
return f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -5940 16820 5940" aria-label="DXF 원본 벡터 도면"><rect x="0" y="-5940" width="16820" height="5940" fill="#f7fafb"/><g font-family="system-ui,sans-serif">{content}</g></svg>', 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'<polygon points="{points}" fill="{color}" stroke="{color}" stroke-width=".45"/>')
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()