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

+30 -5
View File
@@ -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 경계에서 계산하고, 부분 영역은 도면 표제 위치를 기준으로 맞춥니다.
+101 -11
View File
@@ -6,25 +6,35 @@
<style>
: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:#f7fafbLine truncated
@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%}}
</style>
</head>
<body><div class="wrap">
<header><div class="brand"><div class="symbol">R2</div><div><strong>R2C 도면 스튜디오</strong><small>DXF → 단면 재구성 → 웹 3D</small></div></div><div class="actions"><span class="badge">DXF 엔티티 추출</span><a class="link" id="sourceLink" href="./drawing/R2C_일반도_20260601.dxf" download>원본 DXF ↓</a><button class="btn" id="download">3D 모델 ↓</button></div></header>
<div class="intro"><div><div class="eyebrow">DRAWING / RECONSTRUCTION</div><h1>사각 입구에서 원형 관로까지</h1><p class="subtitle">R2C관의 전환부와 소켓을 원본 단면으로 확인하세요.</p></div><div class="intro-note">모든 치수는 mm 기준<br>원본 A-A / B-B 단면 및 변화치수표 반영</div></div>
<section class="upload-zone" id="dropZone" aria-label="DXF 첨부 영역"><div><strong>DXF 파일을 첨부하거나 여기에 놓으세요</strong><p>R2C 일반도의 단면·변화치수표를 읽어 바로 3D로 변환합니다. · 최대 10 MB</p><p>첨부 파일은 변환 후 서버에 저장하지 않습니다.</p></div><div class="upload-controls"><input type="file" id="dxfFile" accept=".dxf" hidden><button class="btn" id="uploadButton">DXF 파일 첨부</button><button class="btn" id="sampleReset">기본 도면으로</button></div></section><p class="upload-message" id="uploadMessage" role="status" aria-live="polite" hidden></p>
<div class="specbar"><label for="specification">규격 선택</label><select id="specification" aria-label="R2C 관로 내경 규격"></select><span class="small-tag" id="specBasis">원본 단면</span><span class="specnote" id="specNotice">선택한 규격의 변화치수를 적용합니다.</span></div>
<div class="workspace"><section class="main"><div class="toolbar"><div class="seg" aria-label="표시할 부재"><button class="btn active" data-model="r2c">R2C관</button><button class="btn" data-model="pipe">원심력 콘크리트관</button></div><div class="seg" aria-label="분해 표시"><button class="btn active" data-mode="closed">전체</button><button class="btn" data-mode="exploded">덮개 분리</button><button class="btn" data-mode="cutaway">내부 보기</button></div></div>
<div class="stage"><div class="stage-label">SECTION-BASED 3D<strong id="modelName">R2C · Ø1,000</strong></div><span class="stage-status" id="status">기본 시점</span><svg id="scene" viewBox="0 0 1000 700" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="DXF에서 재구성한 R2C관 3D 모델" tabindex="0"><polygon points="487.27,400.31 485.50,399.73 536.42,363.38 536.89,364.17" fill="rgb(84,96,99)" stroke="rgb(84,96,99)" stroke-width=".45"/><polygon points="485.50,399.73 485.50,384.86 532.68,356.63 536.42,363.38" fill="rgb(84,96,99)" stroke="rgb(84,96,99)" stroke-width=".45"/><polygon points="485.50,384.86 485.50,384.57 532.61,356.48 532.68,356.63" fill="rgb(84,96,99)" stroke="rgb(84,96,99)" stroke-width=".45"/><polygon points="485.50,384.57 485.50,383.95 532.45,356.18 532.61,356.48" fill="rgb(84,96,99)" stroke="rgb(84,96,99)" stroke-width=".45"/><polygon points="502.91,405.44 487.27,400.31 536.89,364.17 541.50,371.62" fill="rgb(84,96,99)" stroke="rgb(84,96,99)" stroke-width=".45"/><polygon points="485.50,383.95 485.50,369.17 528.68,348.59 532.45,356.18" fill="rgb(84,96,99)" stroke="rgb(84,96,99)" stroke-width=".45"/><polygon points="485.50,369.17 485.50,355.06 525.12,340.51 528.68,348.59" fill="rgb(84,96,99)" stroke="rgb(84,96,99)" stroke-width=".45"/><polygon points="516.62,409.95 502.91,405.44 541.50,371.62 546.42,378.79" fill="rgb(84,96,99)" stroke="rgb(84,96,99)" stroke-width=".45"/><polygon points="485.50,355.06 485.50,341.98 521.94,332.30 525.12,340.51" fill="rgb(84,96,99)" stroke="rgb(84,96,99)" stroke-width=".45"/><polygon points="485.50,341.98 485.50,329.72 519.15,323.98 521.94,332.30" fill="rgb(84,96,99)" stroke="rgb(84,96,99)" stroke-width=".45"/><polygon points="528.84,413.97 516.62,409.95 546.42,378.79 551.63,385.66" fill="rgb(84,96,99)" stroke="rgb(84,96,99)" stroke-width=".45"/><polygon points="485.50,329.72 485.50,318.11 516.78,315.59 519.15,323.98" fill="rgb(84,96,99)" stroke="rgb(84,96,99)" stroke-width=".45"/><polygon points="485.50,318.11 485.50,307.01 514.82,307.17 516.78,315.59" fill="rgb(84,96,99)" stroke="rgb(84,96,99)" stroke-width=".45"/><polygon points="539.89,417.60 528.84,413.97 551.63,385.66 557.12,392.20" fill="rgb(84,96,99)" stroke="rgb(84,96,99)" stroke-width=".45"/><polygon points="485.50,307.01 485.50,296.29 513.29,298.76 514.82,307.17" fill="rgb(84,96,99)" stroke="rgb(84,96,99)" stroke-width=".45"/><polygon points="485.50,296.29 485.50,285.84 512.19,290.37 513.29,298.76" fill="rgb(84,96,99)" stroke="rgb(84,96,99)" stroke-width=".45"/><polygon points="550.02,420.92 539.89,417.60 557.12,392.20 562.84,398.37" fill="rgb(84,96,99)" stroke="rgb(84,96,99)" stroke-width=".45"/><polygon points="485.50,285.84 485.50,275.57 511.53,282.07 512.19,290.37" fill="rgb(84,96,99)" stroke="rgb(84,96,99)" stroke-width=".45"/><polygon points="559.41,424.01 550.02,420.92 562.84,398.37 568.79,404.16" fill="rgb(84,96,99)" stroke="rgb(84,96,99)" stroke-width=".45"/><polygon points="485.50,275.57 485.50,265.39 511.31,273.87 511.53,282.07" fill="rgb(84,96,99)" stroke="rgb(84,96,99)" stroke-width=".45"/><polygon points="528.68,348.59 525.12,340.51 525.27,340.78 528.69,348.53" fill="rgb(108,131,139)" stroke="rgb(108,131,139)" stroke-width=".45"/><polygon points="532.45,356.18 528.68,348.59 528.69,348.53 532.31,355.83" fill="rgb(108,132,140)" stroke="rgb(108,132,140)" stroke-width=".45"/><polygon points="532.61,356.48 532.45,356.18 532.31,355.83 532.46,356.11" fill="rgb(108,132,140)" stroke="rgb(108,132,140)" stroke-width=".45"/><polygon points="532.68,356.63 532.61,356.48 532.46,356.11 532.53,356.25" fill="rgb(108,132,140)" stroke="rgb(108,132,140)" stroke-width=".45"/><polygon points="525.12,340.51 521.94,332.30 522.21,332.89 525.27,340.78" fill="rgb(107,131,138)" stroke="rgb(107,131,138)" stroke-width=".45"/><polygon points="536.42,363.38 532.68,356.63 532.53,356.25 536.12,362.74" fill="rgb(109,132,140)" stroke="rgb(109,132,140)" stroke-width=".45"/><polygon points="536.89,364.17 536.42,363.38 536.12,362.74 536.57,363.50" fill="rgb(109,133,141)" stroke="rgb(109,133,141)" stroke-width=".45"/><polygon points="521.94,332.30 519.15,323.98 519.54,324.90 522.21,332.89" fill="rgb(106,130,137)" stroke="rgb(106,130,137)" stroke-width=".45"/><polygon points="541.50,371.62 536.89,364.17 536.57,363.50 541.00,370.65" fill="rgb(109,133,141)" stroke="rgb(109,133,141)" stroke-width=".45"/><polygon points="519.15,323.98 516.78,315.59 517.26,316.85 519.54,324.90" fill="rgb(105,128,136)" stroke="rgb(105,128,136)" stroke-width=".45"/><polygon points="546.42,378.79 541.50,371.62 541.00,370.65 545.73,377.54" fill="rgb(109,133,141)" stroke="rgb(109,133,141)" stroke-width=".45"/><polygon points="568.20,426.90 559.41,424.01 568.79,404.16 574.93,409.53" fill="rgb(84,96,99)" stroke="rgb(84,96,99)" stroke-width=".45"/><polygon points="516.78,315.59 514.82,307.17 515.38,308.76 517.26,316.85" fill="rgb(104,127,135)" stroke="rgb(104,127,135)" stroke-width=".45"/><polygon points="485.50,265.39 485.50,255.21 511.53Line truncated
<div class="viewbar"><div class="seg" aria-label="시점"><button class="btn active" data-view="iso">등각</button><button class="btn" data-view="inlet">입구 A-A</button><button class="btn" data-view="outlet">소켓 B-B</button><button class="btn" data-view="side">측면</button><button class="btn" data-view="top">평면</button><button class="btn" id="wire" aria-pressed="false">윤곽선</button></div><span class="help">드래그 회전 · 휠 확대 · 방향키 회전</span></div></section>
<aside class="cards"><section class="card"><h2>도면에서 읽은 치수 <span class="small-tag">DXF VERIFIED</span></h2><dl class="dims" id="dimensions"></dl><hr class="rule"><p class="micro" id="shapeNote">사각 입구 → Ø1,000 원형 유로 → 단차 소켓</p></section><section class="card"><h2>부재와 내부 형상</h2><div class="legend"><i class="swatch"></i>상부 덮개</div><div class="legend"><i class="swatch bottom"></i>하부 본체</div><div class="legend"><i class="swatch inner"></i>전환부·관로·소켓</div><p class="micro">덮개 분리 또는 내부 보기를 선택하면 사각–원형 전환 면이 드러납니다.</p></section><section class="card"><h2>재구성 기준 <span class="small-tag">A-A / B-B</span></h2><p class="micro">외곽선·원호·치수표에서 단면과 소켓 위치를 추출합니다. 원심력관은 실제 종단 폴리라인을 회전시켜 만듭니다.</p><div class="limits" id="limits"></div><button class="btn" id="evidence">추출 근거 JSON ↓</button></section></aside></div>
<aside class="cards"><section class="card"><h2>도면에서 읽은 치수 <span class="small-tag">DXF VERIFIED</span></h2><dl class="dims" id="dimensions"></dl><hr class="rule"><p class="micro" id="shapeNote">사각 입구 → Ø1,000 원형 유로 → 단차 소켓</p></section><section class="card"><h2>부재와 내부 형상</h2><div class="legend"><i class="swatch"></i>상부 덮개</div><div class="legend"><i class="swatch bottom"></i>하부 본체</div><div class="legend"><i class="swatch inner"></i>전환부·관로·소켓</div><p class="micro">덮개 분리 또는 내부 보기를 선택하면 사각–원형 전환 면이 드러납니다.</p></section><section class="card"><h2>재구성 기준 <span class="small-tag">A-A / B-B</span></h2><p class="micro">외곽선·원호·치수표에서 단면과 소켓 위치를 추출합니다. 원심력관은 실제 종단 폴리라인을 회전시켜 만듭니다.</p><div class="limits" id="limits"></div><p class="spec-warning" id="specWarning"></p><button class="btn" id="evidence">추출 근거 JSON ↓</button></section></aside></div>
<details class="drawing-card"><summary>원본 DXF 벡터 도면과 비교</summary><div class="drawing-controls"><select id="drawingView" aria-label="도면 영역"><option value="all">전체 일반도</option><option value="r2c">R2C 평면·측면·단면</option><option value="sections">A-A / B-B 단면</option><option value="pipe">원심력관 종단·단면</option></select><button class="btn" id="drawingReset">영역 맞춤</button><span class="micro">드래그 이동 · 휠 확대</span></div><div class="drawing-viewport"><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"><polyline points="10666.486,-4695.680 10556.486,-4695.680" fill="none" stroke="#cc6c49" stroke-width="4" stroke-linejoin="round"/><polyline points="10666.486,-3695.680 10556.486,-3695.680" fill="none" stroke="#cc6c49" stroke-width="4" stroke-linejoin="round"/><polyline points="10566.486,-4685.680 10566.486,-3705.680" fill="none" stroke="#cc6c49" stroke-width="4" stroke-linejoin="round"/><g transform="translate(10566.4858629485,-4695.680351079151) rotate(-90.0) scale(10.0,10.0)"><polyline points="-0.250,-0.000 0.250,-0.000 -0.250,-0.000" fill="none" stroke="#263c4b" stroke-width="4" stroke-linejoin="round"/><polyline points="-0.500,-0.000 -1.000,-0.000" fill="none" stroke="#263c4b" stroke-width="4" stroke-linejoin="round"/></g><g transform="translate(10566.4858629485,-3695.680351078918) rotate(-270.0) scale(10.0,10.0)"><polyline points="-0.250,-0.000 0.250,-0.000 -0.250,-0.000" fill="none" stroke="#263c4b" stroke-width="4" stroke-linejoin="round"/><polyline points="-0.500,-0.000 -1.000,-0.000" fill="none" stroke="#263c4b" stroke-width="4" stroke-linejoin="round"/></g><text x="10536.47083288838" y="-4195.680351079034" font-size="30.0" fill="#cc6c49" transform="rotate(-0.0 10536.47083288838 -4195.680351079034)">D</text><polyline points="10766.486,-4785.386 10656.486,-4785.386" fill="none" stroke="#cc6c49" stroke-width="4" stroke-linejoin="round"/><polyline points="10766.486,-3605.975 10656.486,-3605.975" fill="none" stroke="#cc6c49" stroke-width="4" stroke-linejoin="round"/><polyline points="10666.486,-4775.386 10666.486,-3615.975" fill="none" stroke="#cc6c49" stroke-width="4" stroke-linejoin="round"/><g transform="translate(10666.4858629485,-4785.38574328777) rotate(-90.0) scale(10.0,10.0)"><polyline points="-0.250,-0.000 0.250,-0.000 -0.250,-0.000" fill="none" stroke="#263c4b" stroke-width="4" stroke-linejoin="round"/><polyline points="-0.500,-0.000 -1.000,-0.000" fill="none" stroke="#263c4b" stroke-width="4" stroke-linejoin="round"/></g><g transform="translate(10666.4858629485,-3605.97495887056) rotate(-270.0) scale(10.0,10.0)"><polyline points="-0.250,-0.000 0.250,-0.000 -0.250,-0.000" fill="none" stroke="#263c4b" stroke-width="4" stroke-linejoin="round"/><polyline points="-0.500,-0.000 -1.000,-0.000" fill="none" stroke="#263c4b" stroke-width="4" stroke-linejoin="round"/></g><text x="10636.47083288838" y="-4195.680351079165" font-size="30.0" fill="#cc6c49" transform="rotate(-0.0 10636.47083288838 -4195.680351079165)">D1</text><polyline points="970.038,-4513.028 970.038,-3345.028" fill="none" stroke="#cc6c49" stroke-width="4" stroke-linejoin="round"/><polyline points="874.038,-4515.028 874.038,-3343.028" fill="none" stroke="#cc6c49" stroke-width="4" stroke-linejoin="round"/><polyline points="850.038,-4539.028 850.038,-3319.028" fill="none" stroke="#cc6c49" stroke-width="4" stroke-linejoin="round"/><text x="788.3174449396392" y="-5049.357368415149" font-size="100.0" fill="#263c4b" transform="rotate(-0.0 788.3174449396392 -5049.357368415149)">▣ 원심력 철근콘크리트관 Ø1000 일반 B형</text><polyline points="3470.038,-3429.028 970.038,-3429.028 970.038,-3345.028 874.038,-3343.028 850.038,-3319.028 850.038,-3263.028 1015.038,-3263.028 1180.038,-3347.028 3430.038,-3347.028 3430.038,-3354.028 3470.038,-3354.028 3470.038,-3429.028" fill="none" stroke="#263c4b" stroke-width="4" stroke-linejoin="round"/><polyline points="3470.038,-4429.028 970.038,-4429.028 970.038,-4513.028 874.038,-4515.028 850.038,-4539.028 850.038,-4595.028 1015.038,-4595.028 1180.038,-4511.028 3430.038,-4511.028 3430.038,-4504.028 3470.038,-4504.028 3470.038,-4429.028" fill="none" stroke="#263c4b" stroke-width="4" stroke-linejoin="round"/><polyline points="970.038,-4513.028 781.162,-4513.028" fill="none" stroke="#cc6c49" stroke-width="4" stroke-linejoin="round"/><polyline points="756.863,-3929.028 3570.038,-3929.028" fill="none" stroke="#cc6c49" stroke-width="4" stroke-linejoin="round"/><polyline points="850.038,-4695.028 850.038,-4595.028" fill="none" stroke="#cc6c49" stroke-width="4" stroke-linejoin="round"/><polyline points="1015.038,-4695.028 1015.038,-4595.028" fill="none" stroke="#cc6c49" stroke-width="4" stroke-linejoin="round"/><polyline points="1180.038,-4695.028 1180.038,-4511.028" fill="nLine truncated
<details class="drawing-card"><summary>변화치수표 · 원본에서 추출한 7개 규격</summary><div class="table-scroll"><table><thead><tr><th>D</th><th>L</th><th>B</th><th>H</th><th>D1</th><th>D2</th><th>L1</th><th>L2</th><th>L3</th></tr></thead><tbody id="sizeTable"></tbody></table></div><div class="drawing-note">3D 모델은 실제 단면이 그려진 Ø1,000 규격입니다. 다른 규격은 치수표 비교용입니다.</div></details>
<details class="drawing-card"><summary>변화치수표 · 규격을 누르면 3D에 반영</summary><div class="table-scroll"><table><thead><tr><th>D</th><th>D1</th><th>D2</th><th>L</th><th>L1</th><th>L2</th><th>L3</th><th>B</th><th>b1</th><th>b2</th><th>b3</th><th>b4</th><th>H</th><th>h1</th><th>h2</th><th>h3</th><th>h4</th><th>h5</th><th>h6</th><th>h7</th></tr></thead><tbody id="sizeTable"></tbody></table></div><div class="drawing-note">내경 D를 누르면 해당 규격의 R2C 모델과 치수가 함께 변경됩니다. 원형 콘크리트관은 실제 종단이 있는 원본 규격으로 표시합니다.</div></details>
<p class="foot" id="sourceNote">Python 표준 라이브러리로 생성한 독립 실행형 웹 뷰어 · 외부 CDN 및 추가 패키지 불필요</p>
</div>
<script id="modelData" type="application/json">{"source":"R2C_일반도_20260601.dxf","sha256":"24ecb64f2a9875bed65c0e152b1361463ec11767d687bd337744ed75ef144b32","title":"R2C관 · 사각–원형 전환관","diameter":1000.0,"dimensions":{"D":1000.0,"D1":1180.0,"D2":1183.0,"L":1620.0,"L1":86.0,"B":1540.0,"b1":150.0,"b2":120.0,"b3":1310.0,"b4":115.0,"H":1486.0,"h1":166.0,"h2":200.0,"h3":120.0,"h4":205.0,"h5":115.0,"h6":666.0,"h7":820.0,"L2":120.0,"L3":24.0},"width":1540.0,"height":1486.0,"length":1620.0,"axisHeight":666.0,"profiles":[{"x":0,"kind":"rectangle","halfWidth":655.0000000004202,"top":705.0,"bottom":500.0},{"x":100.0,"kind":"rectangle","halfWidth":650.0000000003911,"top":700.0,"bottom":500.0},{"x":1400.0,"kind":"circle","radius":500.0},{"x":1500.0,"kind":"circle","radius":500.0},{"x":1500.0,"kind":"circle","radius":579.705392},{"x":1509.998477,"kind":"circle","radius":589.879916},{"x":1596.003655,"kind":"circle","radius":591.381142},{"x":1620.0,"kind":"circle","radius":615.8}],"pipeProfile":[[2620.0,500.0],[120.0,500.0],[120.0,584.0],[24.0,586.0],[0.0,610.0],[0.0,666.0],[165.0,666.0],[330.0,582.0],[2580.0,582.0],[2580.0,575.0],[2620.0,575.0]],"pipeDimensions":{"nominalLength":2500.0,"overallLength":2620.0,"outerDiameter":1164.0,"wallThickness":82.0},"table":[{"D":700.0,"D1":880.0,"D2":883.0,"L":1605.0,"L1":75.0,"B":1100.0,"b1":100.0,"b2":100.0,"b3":910.0,"b4":95.0,"H":1068.0,"h1":118.0,"h2":150.0,"h3":100.0,"h4":155.0,"h5":95.0,"h6":468.0,"h7":600.0,"L2":105.0,"L3":30.0},{"D":800.0,"D1":980.0,"D2":983.0,"L":1610.0,"L1":80.0,"B":1300.0,"b1":150.0,"b2":100.0,"b3":1110.0,"b4":95.0,"H":1234.0,"h1":134.0,"h2":200.0,"h3":100.0,"h4":205.0,"h5":95.0,"h6":534.0,"h7":700.0,"L2":110.0,"L3":30.0},{"D":900.0,"D1":1080.0,"D2":1083.0,"L":1615.0,"L1":85.0,"B":1440.0,"b1":150.0,"b2":120.0,"b3":1210.0,"b4":115.0,"H":1372.0,"h1":152.0,"h2":200.0,"h3":120.0,"h4":205.0,"h5":115.0,"h6":602.0,"h7":770.0,"L2":115.0,"L3":30.0},{"D":1000.0,"D1":1180.0,"D2":1183.0,"L":1620.0,"L1":86.0,"B":1540.0,"b1":150.0,"b2":120.0,"b3":1310.0,"b4":115.0,"H":1486.0,"h1":166.0,"h2":200.0,"h3":120.0,"h4":205.0,"h5":115.0,"h6":666.0,"h7":820.0,"L2":120.0,"L3":24.0},{"D":1100.0,"D1":1280.0,"D2":1283.0,"L":1625.0,"L1":100.0,"B":1780.0,"b1":200.0,"b2":140.0,"b3":1510.0,"b4":135.0,"H":1669.0,"h1":179.0,"h2":250.0,"h3":140.0,"h4":255.0,"h5":135.0,"h6":729.0,"h7":940.0,"L2":125.0,"L3":25.0},{"D":1200.0,"D1":1380.0,"D2":1383.0,"L":1630.0,"L1":104.0,"B":1880.0,"b1":200.0,"b2":140.0,"b3":1610.0,"b4":135.0,"H":1783.0,"h1":193.0,"h2":250.0,"h3":140.0,"h4":255.0,"h5":135.0,"h6":793.0,"h7":990.0,"L2":130.0,"L3":26.0},{"D":1350.0,"D1":1530.0,"D2":1533.0,"L":1635.0,"L1":108.0,"B":2030.0,"b1":200.0,"b2":140.0,"b3":1760.0,"b4":135.0,"H":1949.0,"h1":209.0,"h2":250.0,"h3":140.0,"h4":255.0,"h5":135.0,"h6":884.0,"h7":1065.0,"L2":135.0,"L3":27.0}],"counts":{"DIMENSION":76,"HATCH":11,"LINE":521,"TEXT":184,"LWPOLYLINE":12,"CIRCLE":3,"SPLINE":12,"ARC":17,"ELLIPSE":27,"MTEXT":2,"INSERT":3,"SOLID":2},"evidence":{"sectionA":"44F","sectionB":["356","357","35B","35C","35D","36F","370","37F","380","381"],"pipeProfile":"195","stations":[100.0,1400.0,1500.0,1509.998477,1596.003655]},"limitations":["2D 일반도 A-A/B-B와 치수표를 이용한 단면 기반 재구성입니다.","사각–원형 전환 면은 단면 사이를 직선 보간했습니다. 철근과 개스킷은 모델에 포함하지 않았습니다.","상·하부 접합부의 작은 홈은 원본 2D 도면에서 확인할 수 있으며 3D에서는 생략했습니다."],"meshes":{"r2c":{"vertices":[[-810.0,770.0,666.0],[-810.0,770.0,716.468466],[-810.0,770.0,767.372423],[-810.0,770.0,819.162523],[-810.0,770.0,872.320878],[-810.0,770.0,927.379779],[-810.0,770.0,984.944443],[-810.0,770.0,1045.721978],[-810.0,770.0,1110.559707],[-810.0,770.0,1180.497551],[-810.0,770.0,1256.841781],[-810.0,770.0,1341.271877],[-810.0,770.0,1436.0],[-810.0,770.0,1486.0],[-810.0,761.843972,1486.0],[-810.0,761.428571,1486.0],[-810.0,719.1207,1486.0],[-810.0,629.20813,1486.0],[-810.0,547.906483,1486.0],[-810.0,473.427221,1486.0],[-810.0,404.379249,1486.0],[-810.0,339.655121,1486.0],[-810.0,278.352492,1486.0],[-810.0,219.718338,1486.0],[-810.0,163.108141,1486.0],[-810.0,107.955048,1486.0],[-810.0,53.74564,1486.0],[-810.0,-0.0,1486.0],[-810.0,-53.74564,1486.0],[-810.0,-107.955048,1486.0],[-810.0,-163.108141,1486.0],[-810.0,-219.718338,1486.0],[-810.0,-278.352492,1486.0],[-810.0,-339.655121,1486.0],[-810.0,-404.379249,1486.0],[-810.0,-473.427221,1486.0],[-810.0,-547.906483,1486.0],[-810.0,-629.20813,1486.0],[-810.0,-719.1207,1486.0],[-810.0,-761.428571,1486.0],[-810.0,-761.843972,1486.0],[-810.0,-770.0,1486.0],[-810.0,-770.0,1436.0],[-810.0,-770.0,1341.271877],[-810.0,-770.0,1256.841781],[-810.0,-770.0,1180.497551],[-810.0,-770.0,1110.559707],[-810.0,-770.0,1045.721978],[-810.0,-770.0,984.944443],[-810.0,-770.0,927.379779],[-810.0,-770.0,872.320878],[-810.0,-770.0,819.162523],[-810.0,-770.0,767.372423],[-810.0,-770.0,716.468466],[-810.0,Line truncated
<script id="modelData" type="application/json">{"source":"R2C_일반도_20260601.dxf","sha256":"24ecb64f2a9875bed65c0e152b1361463ec11767d687bd337744ed75ef144b32","title":"R2C관 · 사각–원형 전환관","diameter":1000.0,"dimensions":{"D":1000.0,"D1":1180.0,"D2":1183.0,"L":1620.0,"L1":86.0,"B":1540.0,"b1":150.0,"b2":120.0,"b3":1310.0,"b4":115.0,"H":1486.0,"h1":166.0,"h2":200.0,"h3":120.0,"h4":205.0,"h5":115.0,"h6":666.0,"h7":820.0,"L2":120.0,"L3":24.0},"width":1540.0,"height":1486.0,"length":1620.0,"axisHeight":666.0,"profiles":[{"x":0,"kind":"rectangle","halfWidth":655.0000000004202,"top":705.0,"bottom":500.0},{"x":100.0,"kind":"rectangle","halfWidth":650.0000000003911,"top":700.0,"bottom":500.0},{"x":1400.0,"kind":"circle","radius":500.0},{"x":1500.0,"kind":"circle","radius":500.0},{"x":1500.0,"kind":"circle","radius":579.705392},{"x":1509.998477,"kind":"circle","radius":589.879916},{"x":1596.003655,"kind":"circle","radius":591.381142},{"x":1620.0,"kind":"circle","radius":615.8}],"pipeProfile":[[2620.0,500.0],[120.0,500.0],[120.0,584.0],[24.0,586.0],[0.0,610.0],[0.0,666.0],[165.0,666.0],[330.0,582.0],[2580.0,582.0],[2580.0,575.0],[2620.0,575.0]],"pipeDimensions":{"nominalLength":2500.0,"overallLength":2620.0,"outerDiameter":1164.0,"wallThickness":82.0},"table":[{"D":700.0,"D1":880.0,"D2":883.0,"L":1605.0,"L1":75.0,"B":1100.0,"b1":100.0,"b2":100.0,"b3":910.0,"b4":95.0,"H":1068.0,"h1":118.0,"h2":150.0,"h3":100.0,"h4":155.0,"h5":95.0,"h6":468.0,"h7":600.0,"L2":105.0,"L3":30.0},{"D":800.0,"D1":980.0,"D2":983.0,"L":1610.0,"L1":80.0,"B":1300.0,"b1":150.0,"b2":100.0,"b3":1110.0,"b4":95.0,"H":1234.0,"h1":134.0,"h2":200.0,"h3":100.0,"h4":205.0,"h5":95.0,"h6":534.0,"h7":700.0,"L2":110.0,"L3":30.0},{"D":900.0,"D1":1080.0,"D2":1083.0,"L":1615.0,"L1":85.0,"B":1440.0,"b1":150.0,"b2":120.0,"b3":1210.0,"b4":115.0,"H":1372.0,"h1":152.0,"h2":200.0,"h3":120.0,"h4":205.0,"h5":115.0,"h6":602.0,"h7":770.0,"L2":115.0,"L3":30.0},{"D":1000.0,"D1":1180.0,"D2":1183.0,"L":1620.0,"L1":86.0,"B":1540.0,"b1":150.0,"b2":120.0,"b3":1310.0,"b4":115.0,"H":1486.0,"h1":166.0,"h2":200.0,"h3":120.0,"h4":205.0,"h5":115.0,"h6":666.0,"h7":820.0,"L2":120.0,"L3":24.0},{"D":1100.0,"D1":1280.0,"D2":1283.0,"L":1625.0,"L1":100.0,"B":1780.0,"b1":200.0,"b2":140.0,"b3":1510.0,"b4":135.0,"H":1669.0,"h1":179.0,"h2":250.0,"h3":140.0,"h4":255.0,"h5":135.0,"h6":729.0,"h7":940.0,"L2":125.0,"L3":25.0},{"D":1200.0,"D1":1380.0,"D2":1383.0,"L":1630.0,"L1":104.0,"B":1880.0,"b1":200.0,"b2":140.0,"b3":1610.0,"b4":135.0,"H":1783.0,"h1":193.0,"h2":250.0,"h3":140.0,"h4":255.0,"h5":135.0,"h6":793.0,"h7":990.0,"L2":130.0,"L3":26.0},{"D":1350.0,"D1":1530.0,"D2":1533.0,"L":1635.0,"L1":108.0,"B":2030.0,"b1":200.0,"b2":140.0,"b3":1760.0,"b4":135.0,"H":1949.0,"h1":209.0,"h2":250.0,"h3":140.0,"h4":255.0,"h5":135.0,"h6":884.0,"h7":1065.0,"L2":135.0,"L3":27.0}],"counts":{"DIMENSION":76,"HATCH":11,"LINE":521,"TEXT":184,"LWPOLYLINE":12,"CIRCLE":3,"SPLINE":12,"ARC":17,"ELLIPSE":27,"MTEXT":2,"INSERT":3,"SOLID":2},"evidence":{"sectionA":"44F","sectionB":["356","357","35B","35C","35D","36F","370","37F","380","381"],"pipeProfile":"195","stations":[100.0,1400.0,1500.0,1509.998477,1596.003655]},"limitations":["2D 일반도 A-A/B-B와 치수표를 이용한 단면 기반 재구성입니다.","사각–원형 전환 면은 단면 사이를 직선 보간했습니다. 철근과 개스킷은 모델에 포함하지 않았습니다.","상·하부 접합부의 작은 홈은 원본 2D 도면에서 확인할 수 있으며 3D에서는 생략했습니다."],"meshes":{"r2c":{"vertices":[[-810.0,770.0,666.0],[-810.0,770.0,716.468466],[-810.0,770.0,767.372423],[-810.0,770.0,819.162523],[-810.0,770.0,872.320878],[-810.0,770.0,927.379779],[-810.0,770.0,984.944443],[-810.0,770.0,1045.721978],[-810.0,770.0,1110.559707],[-810.0,770.0,1180.497551],[-810.0,770.0,1256.841781],[-810.0,770.0,1341.271877],[-810.0,770.0,1436.0],[-810.0,770.0,1486.0],[-810.0,761.843972,1486.0],[-810.0,761.428571,1486.0],[-810.0,719.1207,1486.0],[-810.0,629.20813,1486.0],[-810.0,547.906483,1486.0],[-810.0,473.427221,1486.0],[-810.0,404.379249,1486.0],[-810.0,339.655121,1486.0],[-810.0,278.352492,1486.0],[-810.0,219.718338,1486.0],[-810.0,163.108141,1486.0],[-810.0,107.955048,1486.0],[-810.0,53.74564,1486.0],[-810.0,-0.0,1486.0],[-810.0,-53.74564,1486.0],[-810.0,-107.955048,1486.0],[-810.0,-163.108141,1486.0],[-810.0,-219.718338,1486.0],[-810.0,-278.352492,1486.0],[-810.0,-339.655121,1486.0],[-810.0,-404.379249,1486.0],[-810.0,-473.427221,1486.0],[-810.0,-547.906483,1486.0],[-810.0,-629.20813,1486.0],[-810.0,-719.1207,1486.0],[-810.0,-761.428571,1486.0],[-810.0,-761.843972,1486.0],[-810.0,-770.0,1486.0],[-810.0,-770.0,1436.0],[-810.0,-770.0,1341.271877],[-810.0,-770.0,1256.841781],[-810.0,-770.0,1180.497551],[-810.0,-770.0,1110.559707],[-810.0,-770.0,1045.721978],[-810.0,-770.0,984.944443],[-810.0,-770.0,927.379779],[-810.0,-770.0,872.320878],[-810.0,-770.0,819.162523],[-810.0,-770.0,767.372423],[-810.0,-770.0,716.468466],[-810.0,Line truncated
<script>
window.addEventListener('error',function(event){const box=document.getElementById('renderError');box.hidden=false;box.textContent='화면 실행 오류: '+event.message;});
(()=>{
'use strict';
const data=JSON.parse(document.getElementById('modelData').textContent),scene=document.getElementById('scene');
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]};
@@ -54,20 +64,100 @@ function render(){
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)=>'<dt>'+v[0]+'</dt><dd class="'+(i===0?'main-value':'')+'">'+v[1]+' <small>mm</small></dd>').join('');document.getElementById('modelName').textContent=(model==='r2c'?'R2C관':'원심력 콘크리트관')+' · Ø'+fmt(data.diameter);document.getElementById('shapeNote').textContent=model==='r2c'?'사각 입구 → Ø1,000 원형 유로 → 단차 소켓':'종단 폴리라인 195를 관 중심축으로 회전한 실제 단면 형상';}
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)=>'<dt>'+v[0]+'</dt><dd class="'+(i===0?'main-value':'')+'">'+v[1]+' <small>mm</small></dd>').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();}
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);document.querySelectorAll('[data-mode]').forEach(x=>x.disabled=model==='pipe'&&x.dataset.mode==='exploded');info();reset();}));
function updateTable(){document.getElementById('sizeTable').innerHTML=sourceDocument.table.map(row=>'<tr class="'+(row.D===selectedDiameter?'current':'')+'">'+tableKeys.map(key=>key==='D'?'<td><button class="spec-cell" data-spec="'+row.D+'" aria-label="내경 '+row.D+' 규격 보기">'+fmt(row.D)+'</button></td>':'<td>'+fmt(row[key])+'</td>').join('')+'</tr>').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+'.obj',lines.join('\n')+'\n','text/plain');});document.getElementById('evidence').addEventListener('click',()=>{const report={...data};delete report.meshes;save('dxf-evidence.json',JSON.stringify(report,null,2),'application/json');});
document.getElementById('sizeTable').innerHTML=data.table.map(row=>'<tr class="'+(row.D===data.diameter?'current':'')+'">'+['D','L','B','H','D1','D2','L1','L2','L3'].map(key=>'<td>'+fmt(row[key])+'</td>').join('')+'</tr>').join('');document.getElementById('limits').textContent=data.limitations.join(' ');document.getElementById('sourceNote').textContent=data.source+' · SHA-256 '+data.sha256.slice(0,16)+'… · 외부 라이브러리 없이 생성';
const drawing=document.querySelector('.drawing-viewport svg'),regions={all:[0,-5940,16820,5940],r2c:[8750,-5500,7900,5000],sections:[11350,-2800,4200,1950],pipe:[550,-4900,4800,1900]};let vb=regions.all.slice(),drawingDrag=null;
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();});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});
info();render();
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=>'<option value="'+v.diameter+'">Ø '+fmt(v.diameter)+' mm</option>').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('<svg'))throw Error('변환 결과가 올바르지 않습니다.');
replaceDocument(payload.model,payload.drawingSvg,file);uploadMessage(file.name+' · '+payload.model.variants.length+'개 규격을 불러왔습니다.');
}catch(error){uploadMessage(error.name==='AbortError'?'변환 시간이 초과되었습니다. 파일 크기와 도면 형식을 확인해 주세요.':error.message,true);}
finally{clearTimeout(timer);uploadBusy=false;document.getElementById('uploadButton').disabled=false;document.getElementById('sampleReset').disabled=false;document.getElementById('dxfFile').value='';}
}
const fileInput=document.getElementById('dxfFile'),dropZone=document.getElementById('dropZone');
document.getElementById('uploadButton').addEventListener('click',()=>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();
});
})();
</script>
</body></html>
+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:
+130
View File
@@ -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('<script')[0]
self.assertGreater(before_script.count('<polygon'), 900)
self.assertTrue((out / 'drawing' / r2c.DEFAULT_DXF.name).is_file())
@@ -110,6 +112,134 @@ class DrawingReconstructionTests(unittest.TestCase):
self.assertIn(status, response.split(b'\r\n')[0])
self.assertIn(content, response)
def test_every_table_specification_has_its_own_closed_mesh(self):
for row in self.model['table']:
with self.subTest(diameter=row['D']):
variant = r2c.specification_model(self.model, row)
self.assertEqual(variant['dimensions'], row)
mesh = variant['meshes']['r2c']
for axis, key in enumerate(['L', 'B', 'H']):
self.assertAlmostEqual(max(p[axis] for p in mesh['vertices']) - min(p[axis] for p in mesh['vertices']), row[key], places=5)
self.assertAlmostEqual(variant['axisHeight'], row['h6'])
self.assertAlmostEqual(variant['profiles'][0]['halfWidth'] * 2, row['b3'])
self.assertAlmostEqual(variant['profiles'][2]['radius'] * 2, row['D'])
edges = Counter()
for face in mesh['faces']:
for a, b in zip(face, face[1:] + face[:1]):
edges[tuple(sorted((a, b)))] += 1
self.assertTrue(all(n == 2 for n in edges.values()))
self.assertTrue(all(math.isfinite(n) for point in mesh['vertices'] for n in point))
def test_drawn_specification_retains_exact_source_profiles(self):
v = r2c.specification_model(self.model, self.model['dimensions'])
self.assertEqual(v['basis'], 'drawn')
self.assertEqual(v['profiles'], self.model['profiles'])
self.assertEqual(v['meshes']['r2c'], self.meshes['r2c'])
self.assertEqual(v['warnings'], [])
def test_nominal_specifications_report_conflicting_socket_dimensions(self):
v = r2c.specification_model(self.model, self.model['table'][0])
self.assertEqual(v['basis'], 'table')
self.assertTrue(v['warnings'])
self.assertAlmostEqual(v['profiles'][3]['x'], v['length'] - v['dimensions']['L2'])
self.assertAlmostEqual(v['profiles'][-2]['x'], v['length'] - v['dimensions']['L3'])
self.assertNotAlmostEqual(v['length'] / self.model['length'], v['width'] / self.model['width'])
def test_invalid_table_is_rejected(self):
bad = dict(self.model['table'][0], B=100)
with self.assertRaises(ValueError):
r2c.specification_model(self.model, bad)
def api_request(self, raw, filename='upload.dxf', length=None, path='/api/convert'):
class Handler(r2c.ViewerHandler):
def log_message(self, *args):
pass
handler = object.__new__(Handler)
handler.path = path
handler.command = 'POST'
handler.request_version = 'HTTP/1.1'
handler.requestline = 'POST ' + path + ' HTTP/1.1'
handler.headers = {'Content-Length': str(len(raw) if length is None else length), 'X-Filename': quote(filename)}
handler.rfile = io.BytesIO(raw)
handler.wfile = io.BytesIO()
handler.do_POST()
header, body = handler.wfile.getvalue().split(b'\r\n\r\n', 1)
return int(header.split()[1]), json.loads(body)
def test_upload_converts_actual_file_and_provides_all_specs(self):
status, payload = self.api_request(self.raw, '첨부 도면.dxf')
self.assertEqual(status, 200)
self.assertEqual(payload['model']['source'], '첨부 도면.dxf')
self.assertEqual(len(payload['model']['variants']), 7)
self.assertEqual(payload['model']['sha256'], self.model['sha256'])
self.assertTrue(payload['drawingSvg'].startswith('<svg'))
self.assertEqual(r2c.DEFAULT_DXF.read_bytes(), self.raw)
def test_second_upload_uses_uploaded_table_instead_of_default_file(self):
changed = self.raw.replace(b'1,605', b'1,607')
self.assertNotEqual(changed, self.raw)
status, payload = self.api_request(changed, 'another.dxf')
self.assertEqual(status, 200)
self.assertEqual(payload['model']['variants'][0]['length'], 1607)
self.assertNotEqual(payload['model']['sha256'], self.model['sha256'])
self.assertEqual(r2c.DEFAULT_DXF.read_bytes(), self.raw)
def test_upload_rejects_invalid_oversized_or_truncated_input(self):
for raw, filename, length, expected in [
(b'', 'empty.dxf', None, 400),
(b'not a dxf', 'wrong.dxf', None, 422),
(b'AutoCAD Binary DXF', 'binary.dxf', None, 422),
(b'data', 'wrong.pdf', None, 415),
(b'x', 'large.dxf', r2c.MAX_UPLOAD_BYTES + 1, 413),
(b'x', 'missing.dxf', 10, 400),
]:
with self.subTest(expected=expected, filename=filename):
status, payload = self.api_request(raw, filename, length)
self.assertEqual(status, expected)
self.assertIn('error', payload)
status, payload = self.api_request(b'x', path='/wrong')
self.assertEqual(status, 404)
def test_inner_profiles_must_leave_positive_wall_thickness(self):
for changes in ({'D2': 5000}, {'D1': 5000}, {'b1': 5000},
{'h4': 5000}, {'D1': 10}):
with self.subTest(changes=changes):
row = dict(self.model['table'][0], **changes)
with self.assertRaisesRegex(ValueError, '관로 단면'):
r2c.specification_model(self.model, row)
def test_spline_validation_and_valid_linear_interpolation(self):
def spline(weights=('1', '1'), knots=('0', '0', '1', '1'), degree='1'):
return {'type': 'SPLINE', 'tags': [
(71, degree), (10, '0'), (20, '0'), (10, '10'), (20, '20'),
*[(41, w) for w in weights], *[(40, k) for k in knots]]}
points = r2c.spline_points(spline())
self.assertEqual(points[0], [0, 0])
self.assertEqual(points[32], [5, 10])
self.assertEqual(points[-1], [10, 20])
for entity in (spline(weights=('0', '0')), spline(weights=('1',)),
spline(knots=('0', '1', '0', '1')),
spline(knots=('0', '0', '0', '0')), spline(degree='-1')):
with self.subTest(entity=entity), self.assertRaises(ValueError):
r2c.spline_points(entity)
def test_upload_with_invalid_spline_returns_json_error(self):
lines = self.raw.decode('utf-8-sig').splitlines()
start = next(i + 2 for i in range(0, len(lines), 2)
if lines[i].strip() == '2' and lines[i + 1].strip() == 'ENTITIES')
end = next(i for i in range(start, len(lines), 2)
if lines[i].strip() == '0' and lines[i + 1].strip() == 'ENDSEC')
extra = ('0 SPLINE 71 1 10 0 20 0 41 0 10 1 20 1 41 0 '
'40 0 40 0 40 1 40 1').split()
raw = '\n'.join(lines[:end] + extra + lines[end:]).encode('utf-8')
status, payload = self.api_request(raw)
self.assertEqual(status, 422)
self.assertIn('SPLINE', payload['error'])
def test_nonfinite_coordinates_are_rejected_before_geometry(self):
with self.assertRaisesRegex(ValueError, '유효하지 않은 좌표'):
r2c.read_dxf_bytes(b'10\nNaN\n')
def test_unrelated_drawing_does_not_produce_a_guessed_model(self):
with self.assertRaisesRegex(ValueError, '변화치수표'):
r2c.extract_model([], b'')
+8 -50
View File
@@ -6,68 +6,26 @@
<style>
: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:#f7fafbLine truncated
@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%}}
</style>
</head>
<body><div class="wrap">
<header><div class="brand"><div class="symbol">R2</div><div><strong>R2C 도면 스튜디오</strong><small>DXF → 단면 재구성 → 웹 3D</small></div></div><div class="actions"><span class="badge">DXF 엔티티 추출</span><a class="link" id="sourceLink" href="__DXF_HREF__" download>원본 DXF ↓</a><button class="btn" id="download">3D 모델 ↓</button></div></header>
<div class="intro"><div><div class="eyebrow">DRAWING / RECONSTRUCTION</div><h1>사각 입구에서 원형 관로까지</h1><p class="subtitle">R2C관의 전환부와 소켓을 원본 단면으로 확인하세요.</p></div><div class="intro-note">모든 치수는 mm 기준<br>원본 A-A / B-B 단면 및 변화치수표 반영</div></div>
<section class="upload-zone" id="dropZone" aria-label="DXF 첨부 영역"><div><strong>DXF 파일을 첨부하거나 여기에 놓으세요</strong><p>R2C 일반도의 단면·변화치수표를 읽어 바로 3D로 변환합니다. · 최대 10 MB</p><p>첨부 파일은 변환 후 서버에 저장하지 않습니다.</p></div><div class="upload-controls"><input type="file" id="dxfFile" accept=".dxf" hidden><button class="btn" id="uploadButton">DXF 파일 첨부</button><button class="btn" id="sampleReset">기본 도면으로</button></div></section><p class="upload-message" id="uploadMessage" role="status" aria-live="polite" hidden></p>
<div class="specbar"><label for="specification">규격 선택</label><select id="specification" aria-label="R2C 관로 내경 규격"></select><span class="small-tag" id="specBasis">원본 단면</span><span class="specnote" id="specNotice">선택한 규격의 변화치수를 적용합니다.</span></div>
<div class="workspace"><section class="main"><div class="toolbar"><div class="seg" aria-label="표시할 부재"><button class="btn active" data-model="r2c">R2C관</button><button class="btn" data-model="pipe">원심력 콘크리트관</button></div><div class="seg" aria-label="분해 표시"><button class="btn active" data-mode="closed">전체</button><button class="btn" data-mode="exploded">덮개 분리</button><button class="btn" data-mode="cutaway">내부 보기</button></div></div>
<div class="stage"><div class="stage-label">SECTION-BASED 3D<strong id="modelName">R2C · Ø1,000</strong></div><span class="stage-status" id="status">기본 시점</span><svg id="scene" viewBox="0 0 1000 700" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="DXF에서 재구성한 R2C관 3D 모델" tabindex="0">__INITIAL_SCENE__</svg><div class="error" id="renderError" hidden role="alert"></div><noscript><p>기본 3D 형상입니다. 회전과 분해 기능은 JavaScript를 허용하면 사용할 수 있습니다.</p></noscript><span class="stage-bottom">X 길이 / Y 폭 / Z 높이 · 단위 mm</span><div class="zoom"><button id="zoomOut" aria-label="축소">−</button><button id="zoomIn" aria-label="확대">+</button><button id="reset" aria-label="기본 시점">↺</button></div></div>
<div class="viewbar"><div class="seg" aria-label="시점"><button class="btn active" data-view="iso">등각</button><button class="btn" data-view="inlet">입구 A-A</button><button class="btn" data-view="outlet">소켓 B-B</button><button class="btn" data-view="side">측면</button><button class="btn" data-view="top">평면</button><button class="btn" id="wire" aria-pressed="false">윤곽선</button></div><span class="help">드래그 회전 · 휠 확대 · 방향키 회전</span></div></section>
<aside class="cards"><section class="card"><h2>도면에서 읽은 치수 <span class="small-tag">DXF VERIFIED</span></h2><dl class="dims" id="dimensions"></dl><hr class="rule"><p class="micro" id="shapeNote">사각 입구 → Ø1,000 원형 유로 → 단차 소켓</p></section><section class="card"><h2>부재와 내부 형상</h2><div class="legend"><i class="swatch"></i>상부 덮개</div><div class="legend"><i class="swatch bottom"></i>하부 본체</div><div class="legend"><i class="swatch inner"></i>전환부·관로·소켓</div><p class="micro">덮개 분리 또는 내부 보기를 선택하면 사각–원형 전환 면이 드러납니다.</p></section><section class="card"><h2>재구성 기준 <span class="small-tag">A-A / B-B</span></h2><p class="micro">외곽선·원호·치수표에서 단면과 소켓 위치를 추출합니다. 원심력관은 실제 종단 폴리라인을 회전시켜 만듭니다.</p><div class="limits" id="limits"></div><button class="btn" id="evidence">추출 근거 JSON ↓</button></section></aside></div>
<aside class="cards"><section class="card"><h2>도면에서 읽은 치수 <span class="small-tag">DXF VERIFIED</span></h2><dl class="dims" id="dimensions"></dl><hr class="rule"><p class="micro" id="shapeNote">사각 입구 → Ø1,000 원형 유로 → 단차 소켓</p></section><section class="card"><h2>부재와 내부 형상</h2><div class="legend"><i class="swatch"></i>상부 덮개</div><div class="legend"><i class="swatch bottom"></i>하부 본체</div><div class="legend"><i class="swatch inner"></i>전환부·관로·소켓</div><p class="micro">덮개 분리 또는 내부 보기를 선택하면 사각–원형 전환 면이 드러납니다.</p></section><section class="card"><h2>재구성 기준 <span class="small-tag">A-A / B-B</span></h2><p class="micro">외곽선·원호·치수표에서 단면과 소켓 위치를 추출합니다. 원심력관은 실제 종단 폴리라인을 회전시켜 만듭니다.</p><div class="limits" id="limits"></div><p class="spec-warning" id="specWarning"></p><button class="btn" id="evidence">추출 근거 JSON ↓</button></section></aside></div>
<details class="drawing-card"><summary>원본 DXF 벡터 도면과 비교</summary><div class="drawing-controls"><select id="drawingView" aria-label="도면 영역"><option value="all">전체 일반도</option><option value="r2c">R2C 평면·측면·단면</option><option value="sections">A-A / B-B 단면</option><option value="pipe">원심력관 종단·단면</option></select><button class="btn" id="drawingReset">영역 맞춤</button><span class="micro">드래그 이동 · 휠 확대</span></div><div class="drawing-viewport">__DRAWING_SVG__</div><div class="drawing-note">DXF의 선·원·원호·타원·스플라인·문자·치수 블록을 표시합니다. 해치 채움과 치수 화살표 SOLID는 생략했습니다.</div></details>
<details class="drawing-card"><summary>변화치수표 · 원본에서 추출한 7개 규격</summary><div class="table-scroll"><table><thead><tr><th>D</th><th>L</th><th>B</th><th>H</th><th>D1</th><th>D2</th><th>L1</th><th>L2</th><th>L3</th></tr></thead><tbody id="sizeTable"></tbody></table></div><div class="drawing-note">3D 모델은 실제 단면이 그려진 Ø1,000 규격입니다. 다른 규격은 치수표 비교용입니다.</div></details>
<details class="drawing-card"><summary>변화치수표 · 규격을 누르면 3D에 반영</summary><div class="table-scroll"><table><thead><tr><th>D</th><th>D1</th><th>D2</th><th>L</th><th>L1</th><th>L2</th><th>L3</th><th>B</th><th>b1</th><th>b2</th><th>b3</th><th>b4</th><th>H</th><th>h1</th><th>h2</th><th>h3</th><th>h4</th><th>h5</th><th>h6</th><th>h7</th></tr></thead><tbody id="sizeTable"></tbody></table></div><div class="drawing-note">내경 D를 누르면 해당 규격의 R2C 모델과 치수가 함께 변경됩니다. 원형 콘크리트관은 실제 종단이 있는 원본 규격으로 표시합니다.</div></details>
<p class="foot" id="sourceNote">Python 표준 라이브러리로 생성한 독립 실행형 웹 뷰어 · 외부 CDN 및 추가 패키지 불필요</p>
</div>
<script id="modelData" type="application/json">__MODEL_JSON__</script>
<script>
window.addEventListener('error',function(event){const box=document.getElementById('renderError');box.hidden=false;box.textContent='화면 실행 오류: '+event.message;});
(()=>{
'use strict';
const data=JSON.parse(document.getElementById('modelData').textContent),scene=document.getElementById('scene');
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<data.profiles.length-1;i++){const a=data.profiles[i],b=data.profiles[i+1];if(b.x-a.x<.001)continue;for(const sign of [-1,1]){
const ra=a.kind==='circle'?a.radius:a.halfWidth,rb=b.kind==='circle'?b.radius:b.halfWidth;
const pts=[[a.x-data.length/2,sign*data.width/2,data.axisHeight],[b.x-data.length/2,sign*data.width/2,data.axisHeight],[b.x-data.length/2,sign*rb,data.axisHeight],[a.x-data.length/2,sign*ra,data.axisHeight]];
result.push({pts,part:'lower',surface:'end'});if(lift)result.push({pts:pts.map(p=>[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='<defs><pattern id="grid" width="40" height="40" patternUnits="userSpaceOnUse"><path d="M 40 0 L 0 0 0 40" fill="none" stroke="#98b1bb" stroke-opacity=".18" stroke-width="1"/></pattern></defs><rect width="1000" height="700" fill="url(#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+='<polygon points="'+pts+'" fill="'+color+'" stroke="'+(wire?'#3b5965':color)+'" stroke-width="'+(wire?'.7':'.45')+'" stroke-linejoin="round"/>';}
// 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)=>'<dt>'+v[0]+'</dt><dd class="'+(i===0?'main-value':'')+'">'+v[1]+' <small>mm</small></dd>').join('');document.getElementById('modelName').textContent=(model==='r2c'?'R2C관':'원심력 콘크리트관')+' · Ø'+fmt(data.diameter);document.getElementById('shapeNote').textContent=model==='r2c'?'사각 입구 → Ø1,000 원형 유로 → 단차 소켓':'종단 폴리라인 195를 관 중심축으로 회전한 실제 단면 형상';}
function reset(){[yaw,pitch]=presets.iso;zoom=1;active('[data-view]','view','iso');schedule();}
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);document.querySelectorAll('[data-mode]').forEach(x=>x.disabled=model==='pipe'&&x.dataset.mode==='exploded');info();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+'.obj',lines.join('\n')+'\n','text/plain');});document.getElementById('evidence').addEventListener('click',()=>{const report={...data};delete report.meshes;save('dxf-evidence.json',JSON.stringify(report,null,2),'application/json');});
document.getElementById('sizeTable').innerHTML=data.table.map(row=>'<tr class="'+(row.D===data.diameter?'current':'')+'">'+['D','L','B','H','D1','D2','L1','L2','L3'].map(key=>'<td>'+fmt(row[key])+'</td>').join('')+'</tr>').join('');document.getElementById('limits').textContent=data.limitations.join(' ');document.getElementById('sourceNote').textContent=data.source+' · SHA-256 '+data.sha256.slice(0,16)+'… · 외부 라이브러리 없이 생성';
const drawing=document.querySelector('.drawing-viewport svg'),regions={all:[0,-5940,16820,5940],r2c:[8750,-5500,7900,5000],sections:[11350,-2800,4200,1950],pipe:[550,-4900,4800,1900]};let vb=regions.all.slice(),drawingDrag=null;
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();});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});
info();render();
})();
__VIEWER_JS__
</script>
</body></html>
+132
View File
@@ -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<data.profiles.length-1;i++){const a=data.profiles[i],b=data.profiles[i+1];if(b.x-a.x<.001)continue;for(const sign of [-1,1]){
const ra=a.kind==='circle'?a.radius:a.halfWidth,rb=b.kind==='circle'?b.radius:b.halfWidth;
const pts=[[a.x-data.length/2,sign*data.width/2,data.axisHeight],[b.x-data.length/2,sign*data.width/2,data.axisHeight],[b.x-data.length/2,sign*rb,data.axisHeight],[a.x-data.length/2,sign*ra,data.axisHeight]];
result.push({pts,part:'lower',surface:'end'});if(lift)result.push({pts:pts.map(p=>[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='<defs><pattern id="grid" width="40" height="40" patternUnits="userSpaceOnUse"><path d="M 40 0 L 0 0 0 40" fill="none" stroke="#98b1bb" stroke-opacity=".18" stroke-width="1"/></pattern></defs><rect width="1000" height="700" fill="url(#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+='<polygon points="'+pts+'" fill="'+color+'" stroke="'+(wire?'#3b5965':color)+'" stroke-width="'+(wire?'.7':'.45')+'" stroke-linejoin="round"/>';}
// 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)=>'<dt>'+v[0]+'</dt><dd class="'+(i===0?'main-value':'')+'">'+v[1]+' <small>mm</small></dd>').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=>'<tr class="'+(row.D===selectedDiameter?'current':'')+'">'+tableKeys.map(key=>key==='D'?'<td><button class="spec-cell" data-spec="'+row.D+'" aria-label="내경 '+row.D+' 규격 보기">'+fmt(row.D)+'</button></td>':'<td>'+fmt(row[key])+'</td>').join('')+'</tr>').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=>'<option value="'+v.diameter+'">Ø '+fmt(v.diameter)+' mm</option>').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('<svg'))throw Error('변환 결과가 올바르지 않습니다.');
replaceDocument(payload.model,payload.drawingSvg,file);uploadMessage(file.name+' · '+payload.model.variants.length+'개 규격을 불러왔습니다.');
}catch(error){uploadMessage(error.name==='AbortError'?'변환 시간이 초과되었습니다. 파일 크기와 도면 형식을 확인해 주세요.':error.message,true);}
finally{clearTimeout(timer);uploadBusy=false;document.getElementById('uploadButton').disabled=false;document.getElementById('sampleReset').disabled=false;document.getElementById('dxfFile').value='';}
}
const fileInput=document.getElementById('dxfFile'),dropZone=document.getElementById('dropZone');
document.getElementById('uploadButton').addEventListener('click',()=>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();
});
})();