diff --git a/README.md b/README.md index 4d69eee..af81367 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ cd /home/b19203/3d-drawing-web python3 r2c.py serve ``` -브라우저에서 **http://localhost:8000** 을 엽니다. 서버 종료는 `Ctrl+C`입니다. 실행할 때마다 DXF에서 모델과 웹페이지를 다시 생성합니다. 생성된 루트 `index.html`은 더블클릭해서 직접 열 수도 있습니다. +브라우저에서 **http://localhost:8000** 을 엽니다. 서버 종료는 `Ctrl+C`입니다. 실행할 때마다 DXF에서 모델과 웹페이지를 다시 생성합니다. 생성된 루트 `index.html`을 직접 열어 기본 도면의 규격별 모델을 볼 수도 있습니다. **DXF 파일 첨부 변환은 `serve`로 실행한 웹사이트에서 사용합니다.** 프로그램을 수정한 경우 기존 서버를 종료하고 다시 실행하세요. 다른 포트 또는 로컬 네트워크에 게시하려면 다음처럼 실행합니다. @@ -21,13 +21,34 @@ python3 r2c.py serve --host 0.0.0.0 --port 8080 다른 기기에서는 이 PC의 IP 주소와 포트로 접속합니다. WSL의 네트워크 모드 및 Windows 방화벽 설정에 따라 접근 범위가 달라집니다. 서버는 생성된 `dist/`만 제공하므로 저장소의 Git 파일이나 프로그램 소스를 노출하지 않습니다. +## 규격별 보기 + +상단 **규격 선택** 또는 변화치수표의 **내경 D**를 누르면 Ø700·800·900·1,000·1,100·1,200·1,350의 7개 R2C 모델을 볼 수 있습니다. 선택한 규격의 외형 치수·관 중심 높이·사각 입구·원형 유로·소켓이 함께 바뀌며, 다운로드하는 OBJ와 근거 JSON에도 현재 규격을 반영합니다. 원본 변화치수표의 20개 항목을 모두 표시합니다. + +Ø1,000은 원본에 실제로 그려진 단면과 원호를 유지합니다. 나머지는 각 행의 치수로 생성하는 파라메트릭 모델이며, 단순한 비례 확대·축소를 하지 않습니다. 원심력 콘크리트관은 종단 폴리라인이 그려진 원본 규격만 제공하며 R2C 규격에 맞춰 임의로 늘리지 않습니다. + +치수표의 일부 행은 L1과 `L2−L3−10 mm 모따기`가 일치하지 않습니다. 모델은 소켓 전체 길이 L2, 끝단 위치 L3, 도면의 10×10 모따기 기준을 사용하고 표기 차이를 화면에 안내합니다. Ø700은 끝단 모따기를 확장하면 하부 외곽과 간섭하므로, 끝단 모따기 표현을 생략하고 D2 내경을 유지합니다. 이 차이도 해당 규격을 선택하면 표시됩니다. + +## 웹사이트에서 DXF 첨부 + +1. `python3 r2c.py serve`로 실행한 사이트에 접속합니다. +2. **DXF 파일 첨부** 버튼을 누르거나 첨부 영역에 파일을 놓습니다. +3. 변환 후 새 파일의 규격 목록·치수표·원본 벡터 도면·3D 모형이 함께 바뀝니다. +4. **기본 도면으로** 버튼으로 시작할 때의 도면으로 돌아올 수 있습니다. + +최대 10 MB의 ASCII DXF를 지원하며 UTF-8/CP949 인코딩을 읽습니다. 첨부 파일은 서버 메모리에서만 분석하고 디스크에 저장하지 않습니다. 원본 샘플 DXF와 다른 사용자의 화면을 덮어쓰지 않습니다. 지원하지 않는 도면, 손상된 파일, 잘못된 치수는 오류로 알리고 이전 모델을 유지합니다. + +이 기능은 **R2C 일반도의 단면 A-A/B-B와 변화치수표를 포함하는 도면 형식**을 대상으로 합니다. 임의의 2D 도면을 자동으로 입체화하거나 바이너리 DXF/DWG를 변환하지는 않습니다. + +서버 API는 `POST /api/convert`이며 파일 바이트를 요청 본문으로 보내고 `X-Filename` 헤더에 URL 인코딩한 파일명을 전달합니다. 성공 시 `{model, drawingSvg}`, 실패 시 `{error}` JSON을 반환합니다. + ## 정적 호스팅에 게시 ```bash python3 r2c.py build ``` -생성된 **`dist/` 폴더 전체**를 정적 웹 호스팅의 게시 폴더로 지정하면 됩니다. 빌드 후 웹 서버 측 Python이나 CAD 소프트웨어는 필요하지 않습니다. HTML은 모델과 벡터 도면을 포함하고 외부 CDN을 사용하지 않습니다. +생성된 **`dist/` 폴더 전체**를 정적 웹 호스팅의 게시 폴더로 지정하면 됩니다. 빌드 후 웹 서버 측 Python이나 CAD 소프트웨어는 필요하지 않습니다. HTML은 모델과 벡터 도면을 포함하고 외부 CDN을 사용하지 않습니다. **정적 호스팅만으로는 새 DXF 첨부 변환 API를 제공하지 않습니다.** 규격 선택은 정적으로도 동작하지만, 파일 첨부까지 운영하려면 Python 서버를 실행하고 `/api/convert`가 같은 사이트 주소로 연결되도록 구성하세요. 입력 및 출력 경로를 바꾸려면 다음 옵션을 사용합니다. @@ -41,7 +62,8 @@ python3 r2c.py build --dxf './drawing/R2C_일반도_20260601.dxf' --output ./dis - 마우스 회전, 휠 확대, 방향키 회전, 입구/소켓/측면/평면 시점 - 덮개 분리, 상부를 제거한 내부 보기, 윤곽선 표시 - DXF 벡터 도면 확대·이동 및 A-A/B-B 단면 비교 -- 7개 규격의 변화치수표와 현재 Ø1,000 규격 표시 +- 변화치수표 7개 규격 선택 및 규격별 3D 모델·치수·OBJ 갱신 +- 웹사이트 DXF 파일 첨부/드래그 앤드 드롭, 새 도면 변환, 기본 도면 복원 - OBJ 모델, 원본 DXF, 추출 근거 JSON 다운로드 - JavaScript 실행 전에도 미리 생성한 SVG 3D 형상 표시 @@ -68,9 +90,10 @@ R2C의 A-A 외곽선과 치수표가 일치하는지 검증한 후 단면을 연 ## 파일 구성 및 수정 -- `r2c.py`: DXF 파서, 치수 추출, 모델 생성, SVG 변환, 웹 서버 +- `r2c.py`: DXF 파서, 치수 추출, 규격별 모델 생성, SVG 변환, 첨부 변환 API, 웹 서버 - `web/viewer.html`: 웹페이지 원본 템플릿 -- `tests/test_r2c.py`: 치수/단면/폐곡면/정적 패키지 검증 +- `web/viewer.js`: 화면 조작·첨부·도면 영역 맞춤 코드. 빌드 시 HTML 안에 포함됩니다. +- `tests/test_r2c.py`: 규격별 치수·폐곡면, 원본 형상 유지, 정적 패키지, 첨부 API 성공·실패 검증 - `dist/`: 웹 게시 산출물 (`index.html`, `drawing.svg`, `model.json`, `model.obj`, 원본 DXF) - `index.html`: 직접 열기용으로 함께 생성하는 단일 HTML @@ -79,3 +102,5 @@ R2C의 A-A 외곽선과 치수표가 일치하는지 검증한 후 단면을 연 ```bash python3 -m unittest discover -s tests -v ``` + +첨부 도면의 모든 내부 단면은 외곽 안에 있고 양수의 벽 두께를 유지해야 합니다. SPLINE의 차수·노트·가중치가 잘못된 파일은 변환 오류로 반환합니다. 원본 도면의 전체 영역은 실제 SVG 경계에서 계산하고, 부분 영역은 도면 표제 위치를 기준으로 맞춥니다. diff --git a/index.html b/index.html index 7c80502..b10241f 100644 --- a/index.html +++ b/index.html @@ -6,25 +6,35 @@
R2
R2C 도면 스튜디오DXF → 단면 재구성 → 웹 3D
DXF 엔티티 추출원본 DXF ↓
DRAWING / RECONSTRUCTION

사각 입구에서 원형 관로까지

R2C관의 전환부와 소켓을 원본 단면으로 확인하세요.

모든 치수는 mm 기준
원본 A-A / B-B 단면 및 변화치수표 반영
+
DXF 파일을 첨부하거나 여기에 놓으세요

R2C 일반도의 단면·변화치수표를 읽어 바로 3D로 변환합니다. · 최대 10 MB

첨부 파일은 변환 후 서버에 저장하지 않습니다.

+
원본 단면선택한 규격의 변화치수를 적용합니다.
SECTION-BASED 3DR2C · Ø1,000
기본 시점X 길이 / Y 폭 / Z 높이 · 단위 mm
드래그 회전 · 휠 확대 · 방향키 회전
-
+
원본 DXF 벡터 도면과 비교
드래그 이동 · 휠 확대
DD1▣ 원심력 철근콘크리트관 Ø1000 일반 B형821,000821,1641202,5004096165165821,000821,1681,1721,3321,150상세 AS=1 : 101001,300100L2Lh4h5h1h2h3H▣ 평면도b4b3b4b2b1b2BD2Hh6h7h1Bb4b3b4DHh6h7DD1D2▣ 측면도AABB▣ 단면 A-A▣ 단면 B-BBb1b2b1b2DDL2L3L1Chamfer10*10L3100DD1D22035551▣ 변화치수표7008009001,0001,1001,2001,3508809801,0801,1801,2801,3801,5308839831,0831,1831,2831,3831,5331,6051,6101,6151,6201,6251,6301,635758085861001041081,1001,3001,4401,5401,7801,8802,0301001501501502002002001001001201201401401409101,1101,2101,3101,5101,6101,76095951151151351351351,0681,2341,3721,4861,6691,7831,94911813415216617919320915020020020025025025010010012012014014014015520520520525525525595951151151351351354685346026667297938846007007708209409901,065DD1D2LL1Bb1b2b3b4Hh1h2h3h4h5h6h7안지름길이폭높이IN.LETOUT.LET원심력 철근콘크리트관Ø1000 일반 B형1,6201,6201,486L▣ 소켓부 상세▣ R2C관R2C관R2C관105110115120125130135L230303024252627L3
DXF의 선·원·원호·타원·스플라인·문자·치수 블록을 표시합니다. 해치 채움과 치수 화살표 SOLID는 생략했습니다.
-
변화치수표 · 원본에서 추출한 7개 규격
DLBHD1D2L1L2L3
3D 모델은 실제 단면이 그려진 Ø1,000 규격입니다. 다른 규격은 치수표 비교용입니다.
+
변화치수표 · 규격을 누르면 3D에 반영
DD1D2LL1L2L3Bb1b2b3b4Hh1h2h3h4h5h6h7
내경 D를 누르면 해당 규격의 R2C 모델과 치수가 함께 변경됩니다. 원형 콘크리트관은 실제 종단이 있는 원본 규격으로 표시합니다.

Python 표준 라이브러리로 생성한 독립 실행형 웹 뷰어 · 외부 CDN 및 추가 패키지 불필요

- + diff --git a/r2c.py b/r2c.py index 166a725..9c0583e 100644 --- a/r2c.py +++ b/r2c.py @@ -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: diff --git a/tests/test_r2c.py b/tests/test_r2c.py index e77b7fc..43cbcef 100644 --- a/tests/test_r2c.py +++ b/tests/test_r2c.py @@ -78,6 +78,8 @@ class DrawingReconstructionTests(unittest.TestCase): self.assertNotIn('__INITIAL_SCENE__', source) self.assertNotIn('__MODEL_JSON__', source) self.assertNotIn('__DRAWING_SVG__', source) + self.assertNotIn('__VIEWER_JS__', source) + self.assertIn('function drawingRegions()', source) before_script = source.split(' :root{--bg:#101b28;--panel:#172635;--border:#2b4052;--fg:#edf3f6;--muted:#9caebb;--mint:#8de1cf;--gold:#edc28a}*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--fg);font:14px/1.55 system-ui,-apple-system,"Segoe UI",sans-serif}button,a,select{font:inherit}button,select{cursor:pointer}button:focus-visible,a:focus-visible,select:focus-visible{outline:2px solid var(--mint);outline-offset:3px}button{color:inherit}a{color:var(--mint)}.wrap{max-width:1600px;margin:auto;padding:0 28px 30px}header{display:flex;justify-content:space-between;align-items:center;gap:16px;height:82px;border-bottom:1px solid var(--border)}.brand{display:flex;align-items:center;gap:12px}.symbol{width:39px;height:39px;border:1px solid #428379;color:var(--mint);border-radius:11px;display:grid;place-items:center;font-weight:800}.brand strong{display:block;font-size:15px}.brand small{color:var(--muted);font-size:11px}.actions{display:flex;gap:8px;align-items:center}.badge{font-size:10px;color:var(--mint);padding:5px 10px;border:1px solid #345f5b;border-radius:20px}.link,.btn{border:1px solid var(--border);background:var(--panel);padding:8px 12px;border-radius:7px;text-decoration:none;font-size:12px;color:var(--fg);white-space:nowrap}.btn:hover,.link:hover{background:#24394a}.btn.active{background:#204a49;border-color:#4a8b7f;color:#bcf4e6}h1{font-size:25px;font-weight:650;letter-spacing:-.04em;margin:4px 0 6px}.intro{display:flex;justify-content:space-between;align-items:end;margin:25px 0 19px;gap:20px}.eyebrow{color:var(--mint);font-size:10px;font-weight:700;letter-spacing:.15em}.subtitle{color:var(--muted);font-size:12px;margin:0}.intro-note{text-align:right;color:var(--muted);font-size:11px}.workspace{display:grid;grid-template-columns:minmax(0,1fr) 300px;gap:18px}.main{min-width:0}.toolbar{display:flex;justify-content:space-between;align-items:center;gap:10px;margin-bottom:10px}.seg{display:flex;gap:5px;flex-wrap:wrap}.seg .btn{padding:6px 10px;font-size:11px}.stage{position:relative;isolation:isolate;height:clamp(420px,61vh,760px);border:1px solid #465662;border-radius:12px;overflow:hidden;background:radial-gradient(ellipse at 45% 38%,#f5f7f6,#dfe7e9)}#scene{display:block;position:absolute;inset:0;width:100%;height:100%;touch-action:none;cursor:grab}#scene:active{cursor:grabbing}.stage-label{position:absolute;left:17px;top:15px;pointer-events:none;font-size:10px;color:#526673;letter-spacing:.12em;z-index:2}.stage-label strong{display:block;font-size:12px;color:#283e49;letter-spacing:.03em;margin-top:3px}.stage-bottom{position:absolute;left:17px;bottom:14px;color:#526673;font-size:10px;pointer-events:none}.stage-status{position:absolute;right:14px;top:14px;border:1px solid #b7c9cc;background:#f9fcfaca;padding:5px 9px;border-radius:7px;color:#35635f;font-size:10px;pointer-events:none}.zoom{position:absolute;right:13px;bottom:13px;display:flex;gap:5px}.zoom button{background:#f5f8f8e8;border:1px solid #b5c6cd;border-radius:6px;color:#334b59;width:31px;height:30px;font-weight:650}.viewbar{display:flex;justify-content:space-between;align-items:center;gap:8px;margin-top:10px}.help{font-size:10px;color:var(--muted)}.error{position:absolute;inset:55px 15px auto;z-index:5;padding:12px;color:#7b2f22;background:#ffefdf;border-radius:7px;font-size:12px}.error[hidden]{display:none}.cards{display:flex;flex-direction:column;gap:12px}.card{padding:16px;border:1px solid var(--border);border-radius:10px;background:var(--panel)}.card h2{font-size:12px;margin:0 0 13px;display:flex;justify-content:space-between;align-items:center}.small-tag{font-size:9px;font-weight:400;letter-spacing:.05em;color:var(--mint)}.dims{margin:0;display:grid;grid-template-columns:1fr auto;gap:8px;font-size:12px}.dims dt{color:var(--muted)}.dims dd{margin:0;font-variant-numeric:tabular-nums}.dims dd.main-value{color:var(--mint);font-weight:650}.rule{border:0;border-top:1px solid var(--border);margin:13px 0}.micro{font-size:11px;color:var(--muted);margin:0;line-height:1.7}.legend{display:flex;gap:8px;align-items:center;font-size:11px;margin-bottom:7px}.swatch{width:10px;height:10px;border-radius:3px;background:#bcc8c9}.swatch.bottom{background:#81979d}.swatch.inner{background:#6b8189}.card .btn{width:100%;margin-top:12px;text-align:center}.limits{color:var(--muted);font-size:10px;line-height:1.7;margin-top:10px}.drawing-card{border:1px solid var(--border);border-radius:10px;margin-top:22px;background:var(--panel);overflow:hidden}.drawing-card>summary{cursor:pointer;list-style:none;display:flex;justify-content:space-between;gap:12px;padding:16px;font-size:13px;font-weight:600}.drawing-card>summary::after{content:'+';color:var(--mint)}.drawing-card[open]>summary::after{content:'−'}.drawing-controls{display:flex;gap:7px;flex-wrap:wrap;align-items:center;padding:0 16px 12px}.drawing-controls select{background:var(--bg);border:1px solid var(--border);color:var(--fg);border-radius:6px;padding:6px 9px;font-size:11px}.drawing-viewport{height:440px;background:#f7fafb;overflow:hidden}.drawing-viewport svg{display:block;width:100%;height:100%;touch-action:none;cursor:grab}.drawing-note{padding:9px 16px;font-size:10px;color:var(--muted)}.table-scroll{overflow:auto;padding:0 16px 16px}table{border-collapse:collapse;width:100%;font-size:11px}th,td{text-align:right;border-bottom:1px solid var(--border);padding:8px;white-space:nowrap}th{color:var(--muted);font-weight:500}tr.current{background:#20413f;color:#c3f4e8}.foot{font-size:10px;color:#8096a6;margin:17px 0 0}.foot code{font-size:10px}noscript p{position:absolute;bottom:40px;left:17px;color:#755e34;font-size:11px;z-index:3} @media(max-width:1050px){.workspace{grid-template-columns:minmax(0,1fr) 270px}.wrap{padding:0 17px 25px}.intro-note{display:none}.viewbar{align-items:start;flex-direction:column}.stage{height:540px}}@media(max-width:740px){header{height:72px}.badge{display:none}.wrap{padding:0 12px 20px}.workspace{grid-template-columns:1fr}h1{font-size:21px}.intro{margin-top:20px}.stage{height:440px}.cards{display:grid;grid-template-columns:1fr 1fr}.card:last-child{grid-column:1/-1}.drawing-viewport{height:350px}.toolbar{align-items:start}.brand small{display:none}}@media(max-width:440px){.cards{grid-template-columns:1fr}.stage{height:390px}.toolbar{flex-direction:column}.actions .link{font-size:10px;padding:7px}.help{display:none}} + +.upload-zone{border:1px dashed #456477;background:#152735;border-radius:10px;padding:16px;display:flex;gap:14px;align-items:center;justify-content:space-between;margin-bottom:18px}.upload-zone.dragover{border-color:var(--mint);background:#1b403d}.upload-zone strong{font-size:13px}.upload-zone p{margin:3px 0 0;font-size:11px;color:var(--muted)}.upload-controls{display:flex;gap:8px;flex-shrink:0}.upload-message{margin:0 0 12px;color:var(--mint);font-size:12px;overflow-wrap:anywhere}.upload-message.error{position:static;background:#492c29;color:#ffd5c8;padding:10px}.upload-message[hidden]{display:none}.specbar{display:flex;align-items:center;gap:10px;flex-wrap:wrap;padding:10px 12px;background:var(--panel);border:1px solid var(--border);border-radius:8px;margin-bottom:12px}.specbar label{font-size:12px;color:var(--muted)}.specbar select{background:#10212b;color:var(--fg);border:1px solid #496879;border-radius:6px;padding:6px 12px;min-width:160px}.specbar select:disabled{opacity:.6}.specnote{font-size:10px;color:var(--muted)}.spec-warning{font-size:10px;line-height:1.65;color:var(--gold);margin:9px 0 0}.spec-cell{border:0;color:var(--mint);background:transparent;padding:2px;cursor:pointer;font-size:11px;text-decoration:underline;text-underline-offset:3px}.btn:disabled{cursor:wait;opacity:.55}.actions{flex-wrap:wrap} +@media(max-width:740px){.upload-zone{align-items:flex-start;flex-direction:column}.upload-controls{width:100%}.upload-controls .btn{flex:1}.specbar select{min-width:120px}.specnote{flex-basis:100%}}
R2
R2C 도면 스튜디오DXF → 단면 재구성 → 웹 3D
DXF 엔티티 추출원본 DXF ↓
DRAWING / RECONSTRUCTION

사각 입구에서 원형 관로까지

R2C관의 전환부와 소켓을 원본 단면으로 확인하세요.

모든 치수는 mm 기준
원본 A-A / B-B 단면 및 변화치수표 반영
+
DXF 파일을 첨부하거나 여기에 놓으세요

R2C 일반도의 단면·변화치수표를 읽어 바로 3D로 변환합니다. · 최대 10 MB

첨부 파일은 변환 후 서버에 저장하지 않습니다.

+
원본 단면선택한 규격의 변화치수를 적용합니다.
SECTION-BASED 3DR2C · Ø1,000
기본 시점__INITIAL_SCENE__X 길이 / Y 폭 / Z 높이 · 단위 mm
드래그 회전 · 휠 확대 · 방향키 회전
-
+
원본 DXF 벡터 도면과 비교
드래그 이동 · 휠 확대
__DRAWING_SVG__
DXF의 선·원·원호·타원·스플라인·문자·치수 블록을 표시합니다. 해치 채움과 치수 화살표 SOLID는 생략했습니다.
-
변화치수표 · 원본에서 추출한 7개 규격
DLBHD1D2L1L2L3
3D 모델은 실제 단면이 그려진 Ø1,000 규격입니다. 다른 규격은 치수표 비교용입니다.
+
변화치수표 · 규격을 누르면 3D에 반영
DD1D2LL1L2L3Bb1b2b3b4Hh1h2h3h4h5h6h7
내경 D를 누르면 해당 규격의 R2C 모델과 치수가 함께 변경됩니다. 원형 콘크리트관은 실제 종단이 있는 원본 규격으로 표시합니다.

Python 표준 라이브러리로 생성한 독립 실행형 웹 뷰어 · 외부 CDN 및 추가 패키지 불필요

diff --git a/web/viewer.js b/web/viewer.js new file mode 100644 index 0000000..818bff9 --- /dev/null +++ b/web/viewer.js @@ -0,0 +1,132 @@ +window.addEventListener('error',function(event){const box=document.getElementById('renderError');box.hidden=false;box.textContent='화면 실행 오류: '+event.message;}); +(()=>{ +'use strict'; +const initialDocument=JSON.parse(document.getElementById('modelData').textContent),scene=document.getElementById('scene'); +let sourceDocument=initialDocument,data=initialDocument,selectedDiameter=initialDocument.diameter; +const initialDrawing=document.querySelector('.drawing-viewport').innerHTML,initialSource=document.getElementById('sourceLink').getAttribute('href'); +let sourceUrl=null,uploadBusy=false; +const specification=document.getElementById('specification'); +const tableKeys=['D','D1','D2','L','L1','L2','L3','B','b1','b2','b3','b4','H','h1','h2','h3','h4','h5','h6','h7']; +let model='r2c',mode='closed',yaw=.87,pitch=.40,zoom=1,wire=false,drag=null,pending=false; +const fmt=n=>Math.round(n).toLocaleString('ko-KR'); +const presets={iso:[.87,.40],inlet:[Math.PI/2,0],outlet:[-Math.PI/2,0],side:[0,0],top:[0,Math.PI/2]}; +function active(selector,key,value){document.querySelectorAll(selector).forEach(b=>{const on=b.dataset[key]===value;b.classList.toggle('active',on);b.setAttribute('aria-pressed',String(on));});} +function rotate(p){const x=p[0],y=p[1],z=p[2]-data.height/2,c=Math.cos(yaw),s=Math.sin(yaw),cp=Math.cos(pitch),sp=Math.sin(pitch),h=x*c-y*s,d=x*s+y*c;return [h,d*cp-z*sp,z*cp+d*sp];} +function polygons(){ + const mesh=data.meshes[model],lift=mode==='exploded'&&model==='r2c'?data.height*.64:0,result=[]; + mesh.faces.forEach((face,i)=>{const group=mesh.groups[i];if(mode==='cutaway'&&group.part==='upper')return;const pts=face.map(index=>{const p=mesh.vertices[index];return [p[0],p[1],p[2]+(group.part==='upper'?lift:0)];});result.push({pts,part:group.part,surface:group.surface});}); + if(model==='r2c'&&mode!=='closed'){ + for(let i=0;i[p[0],p[1],p[2]+lift]),part:'upper',surface:'end'}); + }} + } + return result; +} +function shading(poly){const a=poly.pts[0],b=poly.pts[1],c=poly.pts[2],u=b.map((n,i)=>n-a[i]),v=c.map((n,i)=>n-a[i]);let normal=[u[1]*v[2]-u[2]*v[1],u[2]*v[0]-u[0]*v[2],u[0]*v[1]-u[1]*v[0]],len=Math.hypot(...normal)||1;normal=normal.map(n=>n/len);const light=Math.max(.48,.78+normal[0]*-.22+normal[1]*-.25+normal[2]*.28);let base=poly.part==='upper'?[194,204,203]:[150,171,176];if(poly.surface==='inner')base=poly.part==='upper'?[152,171,175]:[123,150,159];return 'rgb('+base.map(n=>Math.min(255,Math.round(n*light))).join(',')+')';} +function render(){ + pending=false;const polys=polygons(),all=polys.flatMap(poly=>poly.pts.map(rotate)),xs=all.map(p=>p[0]),ys=all.map(p=>p[2]),minX=Math.min(...xs),maxX=Math.max(...xs),minY=Math.min(...ys),maxY=Math.max(...ys),scale=Math.min(780/(maxX-minX||1),490/(maxY-minY||1))*zoom; + const project=p=>{const r=rotate(p);return [500+(r[0]-(minX+maxX)/2)*scale,345-(r[2]-(minY+maxY)/2)*scale];}; + const grid=''; + const sorted=polys.map(poly=>({poly,depth:poly.pts.reduce((sum,p)=>sum+rotate(p)[1],0)/poly.pts.length})).sort((a,b)=>b.depth-a.depth); + let markup=grid; + for(const {poly} of sorted){const pts=poly.pts.map(project).map(p=>p.map(n=>n.toFixed(2)).join(',')).join(' '),color=shading(poly);markup+='';} + // Subtle front and rear perimeter lines retain readable openings without a dense wire grid. + scene.innerHTML=markup;document.getElementById('status').textContent=(mode==='exploded'?'덮개 분리':mode==='cutaway'?'내부 보기':'전체 형상')+' · '+Math.round(zoom*100)+'%'; +} +function schedule(){if(!pending){pending=true;requestAnimationFrame(render);}} +function info(){const d=data.dimensions,values=model==='r2c'?[['관로 내경 D','Ø'+fmt(data.diameter)],['부재 길이 L',fmt(data.length)],['외형 폭 B',fmt(data.width)],['외형 높이 H',fmt(data.height)],['관 중심 높이 h6',fmt(data.axisHeight)],['입구 폭 b3',fmt(d.b3)],['소켓 길이 L2',fmt(d.L2)]]:[['관로 내경','Ø'+fmt(data.diameter)],['유효 길이',fmt(data.pipeDimensions.nominalLength)],['전체 길이',fmt(Math.max(...data.pipeProfile.map(p=>p[0])))],['본체 외경','Ø'+fmt(data.pipeDimensions.outerDiameter)],['본체 두께',fmt(data.pipeDimensions.wallThickness)]];document.getElementById('dimensions').innerHTML=values.map((v,i)=>'
'+v[0]+'
'+v[1]+' mm
').join('');document.getElementById('modelName').textContent=(model==='r2c'?'R2C관':'원심력 콘크리트관')+' · Ø'+fmt(data.diameter);document.getElementById('shapeNote').textContent=model==='r2c'?'사각 입구 → Ø'+fmt(data.diameter)+' 원형 유로 → 단차 소켓':'원본 DXF 종단 폴리라인을 회전한 실제 단면 형상';} +function reset(){[yaw,pitch]=presets.iso;zoom=1;active('[data-view]','view','iso');schedule();} +function updateTable(){document.getElementById('sizeTable').innerHTML=sourceDocument.table.map(row=>''+tableKeys.map(key=>key==='D'?'':''+fmt(row[key])+'').join('')+'').join('');} +function applySelection(){ + const variant=sourceDocument.variants.find(v=>v.diameter===selectedDiameter); + if(!variant)throw Error('선택한 규격의 모델을 찾지 못했습니다.'); + data=model==='pipe'?sourceDocument:{...sourceDocument,...variant}; + specification.value=String(selectedDiameter);specification.disabled=model==='pipe'; + document.querySelectorAll('[data-mode]').forEach(b=>b.disabled=model==='pipe'&&b.dataset.mode==='exploded'); + document.getElementById('specBasis').textContent=model==='pipe'?'원본 종단':data.basis==='drawn'?'원본 단면':'치수표 재구성'; + document.getElementById('specNotice').textContent=model==='pipe'?'원심력관은 원본 Ø'+fmt(sourceDocument.diameter)+' 규격만 제공합니다.':data.reconstructionNote; + document.getElementById('specWarning').textContent=model==='pipe'?'':(data.warnings||[]).join(' '); + document.getElementById('limits').textContent=sourceDocument.limitations.join(' '); + info();updateTable();schedule(); +} +function selectSpecification(value){selectedDiameter=Number(value);model='r2c';active('[data-model]','model',model);applySelection();} +specification.addEventListener('change',e=>selectSpecification(e.target.value)); +document.getElementById('sizeTable').addEventListener('click',e=>{const button=e.target.closest('[data-spec]');if(button)selectSpecification(button.dataset.spec);}); +document.querySelectorAll('[data-model]').forEach(b=>b.addEventListener('click',()=>{model=b.dataset.model;mode='closed';active('[data-model]','model',model);active('[data-mode]','mode',mode);applySelection();reset();})); +document.querySelectorAll('[data-mode]').forEach(b=>b.addEventListener('click',()=>{mode=b.dataset.mode;active('[data-mode]','mode',mode);schedule();})); +document.querySelectorAll('[data-view]').forEach(b=>b.addEventListener('click',()=>{[yaw,pitch]=presets[b.dataset.view];active('[data-view]','view',b.dataset.view);schedule();})); +document.getElementById('wire').addEventListener('click',e=>{wire=!wire;e.currentTarget.classList.toggle('active',wire);e.currentTarget.setAttribute('aria-pressed',String(wire));schedule();}); +document.getElementById('zoomIn').addEventListener('click',()=>{zoom=Math.min(2.5,zoom*1.2);schedule();});document.getElementById('zoomOut').addEventListener('click',()=>{zoom=Math.max(.5,zoom/1.2);schedule();});document.getElementById('reset').addEventListener('click',reset); +scene.addEventListener('pointerdown',e=>{drag={x:e.clientX,y:e.clientY,yaw,pitch};scene.setPointerCapture(e.pointerId);});scene.addEventListener('pointermove',e=>{if(!drag)return;yaw=drag.yaw+(e.clientX-drag.x)*.008;pitch=Math.max(-1.5,Math.min(1.5,drag.pitch+(e.clientY-drag.y)*.006));active('[data-view]','view','');schedule();});scene.addEventListener('pointerup',()=>drag=null);scene.addEventListener('pointercancel',()=>drag=null);scene.addEventListener('wheel',e=>{e.preventDefault();zoom=Math.max(.5,Math.min(2.5,zoom*Math.exp(-e.deltaY*.001)));schedule();},{passive:false});scene.addEventListener('keydown',e=>{if(!['ArrowLeft','ArrowRight','ArrowUp','ArrowDown'].includes(e.key))return;e.preventDefault();yaw+=e.key==='ArrowLeft'?-.1:e.key==='ArrowRight'?.1:0;pitch=Math.max(-1.5,Math.min(1.5,pitch+(e.key==='ArrowUp'?.1:e.key==='ArrowDown'?-.1:0)));schedule();}); +function save(name,text,type){const url=URL.createObjectURL(new Blob([text],{type})),a=document.createElement('a');a.href=url;a.download=name;a.click();setTimeout(()=>URL.revokeObjectURL(url),1000);} +document.getElementById('download').addEventListener('click',()=>{const mesh=data.meshes[model],lines=['# DXF reconstructed model; units: mm'];for(const p of mesh.vertices)lines.push('v '+p.join(' '));for(const f of mesh.faces)lines.push('f '+f.map(i=>i+1).join(' '));save(model+'-D'+data.diameter+'.obj',lines.join('\n')+'\n','text/plain');});document.getElementById('evidence').addEventListener('click',()=>{const report={...data};delete report.meshes;delete report.variants;save('dxf-evidence-D'+data.diameter+'.json',JSON.stringify(report,null,2),'application/json');}); +let drawing=document.querySelector('.drawing-viewport svg'); +let regions={},vb=[],drawingDrag=null; +function drawingRegions(){ + // Measure rendered vectors, including block transforms and text. + const bounds=drawing.querySelector('g').getBBox(); + const pad=100; + const all=[bounds.x-pad,bounds.y-pad,bounds.width+2*pad,bounds.height+2*pad]; + const labels=Array.from(drawing.querySelectorAll('text')); + const find=text=>labels.find(label=>label.textContent.includes(text)); + const around=(label,width,height)=>{ + if(!label)return all.slice(); + const box=label.getBBox(); + return [box.x-200,box.y-100,width+400,height+500]; + }; + const aa=find('단면 A-A'),bb=find('단면 B-B'); + let sections=all.slice(); + if(aa&&bb){ + const a=around(aa,sourceDocument.width,sourceDocument.height); + const b=around(bb,sourceDocument.width,sourceDocument.height); + const x=Math.min(a[0],b[0]),y=Math.min(a[1],b[1]); + sections=[x,y,Math.max(a[0]+a[2],b[0]+b[2])-x,Math.max(a[1]+a[3],b[1]+b[3])-y]; + } + regions={all,r2c:around(find('측면도'),sourceDocument.length,sourceDocument.height), + sections,pipe:around(find('일반 B형'),sourceDocument.pipeDimensions.overallLength,sourceDocument.pipeDimensions.outerDiameter)}; + vb=regions[document.getElementById('drawingView').value].slice(); + applyView(); +} +function applyView(){drawing.setAttribute('viewBox',vb.join(' '));}document.getElementById('drawingView').addEventListener('change',e=>{vb=regions[e.target.value].slice();applyView();});document.getElementById('drawingReset').addEventListener('click',()=>{vb=regions[document.getElementById('drawingView').value].slice();applyView();});function bindDrawing(){drawing.addEventListener('pointerdown',e=>{drawingDrag={x:e.clientX,y:e.clientY,v:vb.slice()};drawing.setPointerCapture(e.pointerId);});drawing.addEventListener('pointermove',e=>{if(!drawingDrag)return;const r=drawing.getBoundingClientRect(),s=Math.max(drawingDrag.v[2]/r.width,drawingDrag.v[3]/r.height);vb=[drawingDrag.v[0]-(e.clientX-drawingDrag.x)*s,drawingDrag.v[1]-(e.clientY-drawingDrag.y)*s,drawingDrag.v[2],drawingDrag.v[3]];applyView();});drawing.addEventListener('pointerup',()=>drawingDrag=null);drawing.addEventListener('pointercancel',()=>drawingDrag=null);drawing.addEventListener('wheel',e=>{e.preventDefault();const f=Math.exp(Math.max(-.35,Math.min(.35,e.deltaY*.001))),width=Math.max(200,Math.min(40000,vb[2]*f)),ratio=width/vb[2];vb=[vb[0]+vb[2]*(1-ratio)/2,vb[1]+vb[3]*(1-ratio)/2,width,vb[3]*ratio];applyView();},{passive:false});} + +function documentInfo(){ + specification.innerHTML=sourceDocument.variants.map(v=>'').join(''); + document.getElementById('sourceNote').textContent=sourceDocument.source+' · SHA-256 '+sourceDocument.sha256.slice(0,16)+'… · 첨부 변환은 Python 서버에서 실행'; + applySelection(); +} +function uploadMessage(text,error=false){const el=document.getElementById('uploadMessage');el.textContent=text;el.hidden=!text;el.classList.toggle('error',error);} +function replaceDocument(documentData,svg,file){ + sourceDocument=documentData;selectedDiameter=documentData.diameter;model='r2c';mode='closed'; + if(sourceUrl){URL.revokeObjectURL(sourceUrl);sourceUrl=null;} + if(file){sourceUrl=URL.createObjectURL(file);document.getElementById('sourceLink').href=sourceUrl;document.getElementById('sourceLink').download=file.name;}else{document.getElementById('sourceLink').href=initialSource;document.getElementById('sourceLink').download=initialDocument.source;} + document.querySelector('.drawing-viewport').innerHTML=svg;drawing=document.querySelector('.drawing-viewport svg');drawingDrag=null;bindDrawing(); + active('[data-model]','model',model);active('[data-mode]','mode',mode);documentInfo();if(drawing.closest('details').open)drawingRegions();reset(); +} +async function upload(file){ + if(!file||uploadBusy)return; + if(!/\.dxf$/i.test(file.name)){uploadMessage('.dxf 파일을 선택해 주세요.',true);return;} + if(file.size>10*1024*1024){uploadMessage('파일은 10 MB 이하로 첨부해 주세요.',true);return;} + if(location.protocol==='file:'){uploadMessage('파일 첨부 변환은 웹 서버에서 사용할 수 있습니다. python3 r2c.py serve 실행 후 http://localhost:8000 에 접속해 주세요.',true);return;} + uploadBusy=true;document.getElementById('uploadButton').disabled=true;document.getElementById('sampleReset').disabled=true;uploadMessage(file.name+' · 도면과 규격을 분석하고 있습니다…'); + const controller=new AbortController(),timer=setTimeout(()=>controller.abort(),30000); + try{ + const response=await fetch('/api/convert',{method:'POST',headers:{'Content-Type':'application/octet-stream','X-Filename':encodeURIComponent(file.name)},body:file,signal:controller.signal}); + if(!(response.headers.get('Content-Type')||'').includes('application/json'))throw Error('첨부 변환 API가 없습니다. python3 r2c.py serve로 실행한 사이트에서 첨부해 주세요.'); + const payload=await response.json();if(!response.ok)throw Error(payload.error||'도면 변환에 실패했습니다.'); + if(!payload.model?.variants?.length||!payload.drawingSvg?.startsWith('fileInput.click());fileInput.addEventListener('change',e=>upload(e.target.files[0])); +dropZone.addEventListener('dragover',e=>{e.preventDefault();dropZone.classList.add('dragover');});dropZone.addEventListener('dragleave',()=>dropZone.classList.remove('dragover'));dropZone.addEventListener('drop',e=>{e.preventDefault();dropZone.classList.remove('dragover');upload(e.dataTransfer.files[0]);}); +document.getElementById('sampleReset').addEventListener('click',()=>{replaceDocument(initialDocument,initialDrawing,null);uploadMessage('기본 도면으로 돌아왔습니다.');}); +bindDrawing();documentInfo();render(); +// Hidden SVGs have no measurable bounds until their details panel opens. +document.querySelector('.drawing-card').addEventListener('toggle',event=>{ + if(event.currentTarget.open)drawingRegions(); +}); +})();