Build DXF-based R2C 3D viewer and local web server

This commit is contained in:
eglee committed 2026-10-20 21:23:57 +09:00
1 parent 277c7005ab
commit 11fd3badf6
8 files changed
+74912 -103

No files matched your search

+418
View File
@@ -0,0 +1,418 @@
#!/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'<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):
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()