Validate DXF geometry and splines and improve viewer maintenance

This commit is contained in:
eglee committed 2026-10-22 11:28:16 +09:00
1 parent 11fd3badf6
commit 9cee9bf791
6 files changed
+560 -77

No files matched your search

+159 -11
View File
@@ -11,13 +11,20 @@ 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):
raw = Path(path).read_bytes()
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:
@@ -30,6 +37,9 @@ def read_dxf(path):
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)
@@ -243,13 +253,149 @@ def make_mesh(model):
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 len(knots) != len(points) + degree + 1 or len(points) < degree + 1:
return []
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):
@@ -263,7 +409,12 @@ def spline_points(e):
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])
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
@@ -369,14 +520,11 @@ 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
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('__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')
@@ -403,7 +551,7 @@ def main():
try:
build(args.dxf, args.output)
if args.command == 'serve':
handler = functools.partial(http.server.SimpleHTTPRequestHandler, directory=str(args.output.resolve()))
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: