Files
TDC_R2C_Drawing/r2c.py
T

419 lines
22 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
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()