572 lines
30 KiB
Python
572 lines
30 KiB
Python
#!/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'<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()
|