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

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

No files matched your search

+1
View File
@@ -0,0 +1 @@
*.dxf -text -diff
+3
View File
@@ -0,0 +1,3 @@
__pycache__/
*.py[cod]
dist/
+79 -3
View File
@@ -1,5 +1,81 @@
# R2C drawing viewer
# R2C DXF → 3D 웹 뷰어
Open `index.html` in a browser to view the interactive 3D schematic. Drag to rotate, use the mouse wheel to zoom, switch between standard views, and adjust the displayed model depth. The “원본 도면 열기” link opens the PDF in the browser.
`drawing/R2C_일반도_20260601.dxf`를 읽어 단면 기반 3D 모델, 원본 벡터 도면, 독립 HTML 페이지를 생성하고 웹 서버로 게시하는 Python 프로그램입니다. Python 3.9 이상에서 외부 패키지 없이 실행합니다.
The schematic reflects the drawing's 2,500 × 1,620 mm outer dimensions and 120 mm thickness callout. The drawing does not provide a clearly readable extrusion depth in the extracted dimensions, so the viewer exposes that value as an adjustable visualization setting.
## 실행 및 로컬 웹 게시
WSL 터미널에서 다음 명령을 실행합니다.
```bash
cd /home/b19203/3d-drawing-web
python3 r2c.py serve
```
브라우저에서 **http://localhost:8000** 을 엽니다. 서버 종료는 `Ctrl+C`입니다. 실행할 때마다 DXF에서 모델과 웹페이지를 다시 생성합니다. 생성된 루트 `index.html`은 더블클릭해서 직접 열 수도 있습니다.
다른 포트 또는 로컬 네트워크에 게시하려면 다음처럼 실행합니다.
```bash
python3 r2c.py serve --host 0.0.0.0 --port 8080
```
다른 기기에서는 이 PC의 IP 주소와 포트로 접속합니다. WSL의 네트워크 모드 및 Windows 방화벽 설정에 따라 접근 범위가 달라집니다. 서버는 생성된 `dist/`만 제공하므로 저장소의 Git 파일이나 프로그램 소스를 노출하지 않습니다.
## 정적 호스팅에 게시
```bash
python3 r2c.py build
```
생성된 **`dist/` 폴더 전체**를 정적 웹 호스팅의 게시 폴더로 지정하면 됩니다. 빌드 후 웹 서버 측 Python이나 CAD 소프트웨어는 필요하지 않습니다. HTML은 모델과 벡터 도면을 포함하고 외부 CDN을 사용하지 않습니다.
입력 및 출력 경로를 바꾸려면 다음 옵션을 사용합니다.
```bash
python3 r2c.py build --dxf './drawing/R2C_일반도_20260601.dxf' --output ./dist
```
## 화면 기능
- R2C 전환관과 원심력 콘크리트관의 3D 보기
- 마우스 회전, 휠 확대, 방향키 회전, 입구/소켓/측면/평면 시점
- 덮개 분리, 상부를 제거한 내부 보기, 윤곽선 표시
- DXF 벡터 도면 확대·이동 및 A-A/B-B 단면 비교
- 7개 규격의 변화치수표와 현재 Ø1,000 규격 표시
- OBJ 모델, 원본 DXF, 추출 근거 JSON 다운로드
- JavaScript 실행 전에도 미리 생성한 SVG 3D 형상 표시
## 도면에서 추출한 형상
현재 DXF의 제목은 `R2C관`, 연결 원심력관은 `Ø1000 일반 B형`입니다. 이전의 2,500 × 1,620 직사각형 관 모델은 교체했습니다.
| 항목 | 추출값 |
| --- | ---: |
| R2C 길이 L | 1,620 mm |
| 외형 폭 B | 1,540 mm |
| 외형 높이 H | 1,486 mm |
| 원형 유로 내경 D | 1,000 mm |
| 관 중심 높이 h6 | 666 mm |
| 사각 입구 | 1,310 × 1,205 mm |
| 사각→원형 전환 구간 | X = 100 ~ 1,400 mm |
| 소켓 구간 | X = 1,500 ~ 1,620 mm |
R2C의 A-A 외곽선과 치수표가 일치하는지 검증한 후 단면을 연결합니다. B-B 원호에서 소켓 반지름을 추출하며, 원심력관은 실제 폴리라인 `195`의 종단을 회전하여 만듭니다. 추출 근거에는 DXF 엔티티 핸들과 원본 파일 SHA-256이 남습니다.
2D 도면을 기반으로 단면 사이의 면을 직선 보간합니다. 상·하부 접합 홈, 철근, 개스킷은 3D에서 생략했습니다. 원본 SVG에는 외곽선·숨은선·원호·타원·스플라인·문자·치수 블록을 표시하고, 해치 채움과 SOLID 치수 화살표는 생략합니다.
이 프로그램은 **이 R2C 일반도의 구성에 맞춘 변환기**입니다. 임의의 모든 2D CAD 도면을 자동으로 3D화하는 범용 변환기가 아닙니다. 필수 단면이나 치수표를 찾지 못하면 오류로 종료합니다.
## 파일 구성 및 수정
- `r2c.py`: DXF 파서, 치수 추출, 모델 생성, SVG 변환, 웹 서버
- `web/viewer.html`: 웹페이지 원본 템플릿
- `tests/test_r2c.py`: 치수/단면/폐곡면/정적 패키지 검증
- `dist/`: 웹 게시 산출물 (`index.html`, `drawing.svg`, `model.json`, `model.obj`, 원본 DXF)
- `index.html`: 직접 열기용으로 함께 생성하는 단일 HTML
화면 수정은 `web/viewer.html`에 하고 `python3 r2c.py build`로 반영합니다.
```bash
python3 -m unittest discover -s tests -v
```
File diff suppressed because it is too large. Load diff
+69 -100
View File
@@ -1,104 +1,73 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="theme-color" content="#101b2a">
<title>R2C 일반도 · 3D 뷰어</title>
<style>
:root{color-scheme:dark;--bg:#101a28;--panel:#172435;--panel2:#1b2b3e;--line:#2d4055;--muted:#95a7b9;--text:#edf3f8;--cyan:#65d3d0;--amber:#f1bd70;--red:#ff8278}
*{box-sizing:border-box}html,body{margin:0;min-height:100%;background:var(--bg);color:var(--text);font:14px/1.5 Inter,"Noto Sans KR",system-ui,-apple-system,"Segoe UI",sans-serif}button,a,input{font:inherit}button{color:inherit}
.app{min-height:100vh;display:grid;grid-template-rows:68px minmax(0,1fr);padding:0 26px 24px;max-width:1680px;margin:auto}.topbar{display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid var(--line);gap:16px}.brand{display:flex;align-items:center;gap:12px}.mark{height:34px;width:34px;border:1px solid #47707a;border-radius:10px;display:grid;place-items:center;color:var(--cyan);font-weight:800;letter-spacing:-1px;background:linear-gradient(140deg,#173d4b,#172537)}.brand-title{font-weight:700;letter-spacing:.01em}.brand-sub{color:var(--muted);font-size:11px;margin-top:1px}.top-actions{display:flex;align-items:center;gap:9px}.pill{border:1px solid var(--line);border-radius:99px;padding:6px 11px;color:#c8d5df;font-size:11px;white-space:nowrap}.pill .dot{display:inline-block;width:6px;height:6px;background:#65d3a2;border-radius:50%;margin:0 7px 1px 1px}.pdf-link{display:flex;align-items:center;gap:7px;color:var(--text);text-decoration:none;padding:8px 12px;border-radius:8px;border:1px solid var(--line);background:var(--panel);font-size:12px}.pdf-link:hover{border-color:#5b758d;background:var(--panel2)}
main{display:grid;grid-template-columns:minmax(0,1fr) 314px;gap:18px;padding-top:18px;min-height:0}.stage-wrap{min-width:0;display:grid;grid-template-rows:auto minmax(400px,1fr) auto;gap:12px}.stage-heading{display:flex;align-items:flex-end;justify-content:space-between;gap:20px}.eyebrow{font-size:10px;letter-spacing:.16em;text-transform:uppercase;color:var(--cyan);font-weight:700}.stage-heading h1{font-size:20px;line-height:1.25;margin:3px 0 0;letter-spacing:-.025em}.stage-note{font-size:11px;color:var(--muted);text-align:right}.canvas-shell{position:relative;border:1px solid #2b3e51;border-radius:12px;overflow:hidden;min-height:400px;background:#e7edf0;box-shadow:0 20px 60px #0002}.canvas-shell:after{content:"";position:absolute;inset:0;pointer-events:none;background:linear-gradient(180deg,#fff2,transparent 18%,transparent 78%,#23394d12)}canvas{display:block;width:100%;height:100%;touch-action:none;cursor:grab}canvas:active{cursor:grabbing}.canvas-hud{position:absolute;z-index:1;left:16px;top:14px;display:flex;gap:7px;pointer-events:none}.hud-tag{background:#f7fafbe8;border:1px solid #cfdae0;color:#415365;padding:5px 8px;border-radius:6px;font-size:10px;font-weight:650}.canvas-hint{position:absolute;z-index:1;right:16px;bottom:14px;color:#506376;font-size:10px;background:#f7fafbe8;border:1px solid #cfdae0;border-radius:6px;padding:5px 8px;pointer-events:none}.viewbar{display:flex;justify-content:space-between;align-items:center;gap:12px}.views{display:flex;gap:6px;flex-wrap:wrap}.view-btn,.tool-btn{border:1px solid var(--line);background:var(--panel);border-radius:7px;padding:7px 11px;font-size:11px;color:#bfccd7;cursor:pointer}.view-btn:hover,.tool-btn:hover{background:var(--panel2)}.view-btn.active{background:#17464b;border-color:#337b7b;color:#baf0e9}.tools{display:flex;gap:6px;align-items:center}.tool-btn{display:flex;align-items:center;gap:6px}.tool-btn.active{border-color:#397d79;color:#9ce4dc;background:#163c43}.tool-btn svg,.pdf-link svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:1.7;stroke-linecap:round;stroke-linejoin:round}
aside{display:flex;flex-direction:column;gap:12px;min-width:0}.card{border:1px solid var(--line);background:var(--panel);border-radius:10px;padding:15px}.card-head{display:flex;align-items:center;justify-content:space-between;margin-bottom:12px}.card-title{font-weight:680;font-size:12px}.card-kicker{color:var(--muted);font-size:10px}.status{color:var(--cyan);font-size:10px;background:#15383c;border:1px solid #255456;padding:3px 7px;border-radius:99px}.dims{display:grid;gap:7px}.dim-row{display:flex;justify-content:space-between;align-items:center;color:#c3d0da;font-size:11px}.dim-row strong{font-size:12px;color:#f2f6f8;font-variant-numeric:tabular-nums}.dim-row span{color:var(--muted)}.dim-row .guess{color:var(--amber)}.divider{height:1px;background:var(--line);margin:5px 0}.range-wrap{margin-top:13px}.range-top{display:flex;justify-content:space-between;color:#c3d0da;font-size:10px;margin-bottom:6px}.range-top output{color:var(--cyan);font-variant-numeric:tabular-nums}input[type=range]{appearance:none;width:100%;height:3px;background:#3a4b5f;border-radius:5px;outline:none}input[type=range]::-webkit-slider-thumb{appearance:none;width:13px;height:13px;border-radius:50%;background:var(--cyan);border:2px solid #163541;cursor:pointer}input[type=range]::-moz-range-thumb{width:11px;height:11px;border-radius:50%;background:var(--cyan);border:2px solid #163541;cursor:pointer}.materials{display:grid;gap:9px}.mat-row{display:flex;align-items:center;gap:9px;font-size:11px;color:#c6d3dd}.swatch{width:9px;height:9px;border-radius:3px;background:#bbc4c8;border:1px solid #d5dcde}.swatch.rebar{background:#e47a69;border-color:#f59c89}.swatch.dimline{background:#d69b4d;border-color:#f5cb7b}.mat-row small{margin-left:auto;color:var(--muted);font-size:10px}.info{background:#202d3a;border:1px solid #3e4b53}.info .card-title{display:flex;align-items:center;gap:7px}.info .icon{width:17px;height:17px;display:grid;place-items:center;border-radius:50%;font-size:11px;color:#2f2412;background:#eab969;font-weight:800}.info p{margin:0;color:#bdc9d2;font-size:10px;line-height:1.65}.info strong{color:#f1d29c;font-weight:650}.file-name{font-size:10px;color:#aab9c6;overflow-wrap:anywhere;margin-top:9px;padding-top:9px;border-top:1px solid #354454}.footer-note{color:#71869a;font-size:10px;margin:0 2px}.toast{position:absolute;left:50%;top:20px;transform:translate(-50%,-8px);opacity:0;transition:.2s;background:#142735;color:#bceee8;border:1px solid #38716e;border-radius:7px;padding:7px 11px;font-size:11px;pointer-events:none;z-index:3}.toast.show{opacity:1;transform:translate(-50%,0)}
@media(max-width:950px){.app{padding:0 16px 18px}main{grid-template-columns:minmax(0,1fr) 270px;gap:12px}.stage-wrap{grid-template-rows:auto minmax(360px,1fr) auto}.canvas-shell{min-height:360px}}
@media(max-width:720px){.app{grid-template-rows:60px 1fr;padding:0 12px 14px}.brand-sub,.top-actions .pill{display:none}main{grid-template-columns:1fr;padding-top:13px}.stage-wrap{grid-template-rows:auto minmax(330px,55vh) auto}.canvas-shell{min-height:330px}.stage-heading h1{font-size:17px}.stage-note{font-size:10px;max-width:130px}.viewbar{align-items:flex-start;flex-direction:column}.tools{width:100%}aside{display:grid;grid-template-columns:1fr 1fr}.card.info{grid-column:1/-1}.card{padding:12px}.topbar{height:60px}}
@media(max-width:430px){aside{grid-template-columns:1fr}.card.info{grid-column:auto}.canvas-hint{right:9px;bottom:9px}.canvas-hud{left:9px;top:9px}.app{padding-inline:9px}}
</style>
<meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><meta name="theme-color" content="#111c29">
<title>R2C · DXF 도면 3D 뷰어</title>
<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}}
</style>
</head>
<body>
<div class="app">
<header class="topbar">
<div class="brand"><div class="mark">R2</div><div><div class="brand-title">도면 3D 뷰어</div><div class="brand-sub">R2C · 일반도 기반 형상 검토</div></div></div>
<div class="top-actions"><span class="pill"><i class="dot"></i>로컬 인터랙티브 모델</span><a class="pdf-link" href="./drawing/R2C_일반도_20260601.pdf" target="_blank" rel="noopener"><svg viewBox="0 0 24 24"><path d="M14 3h7v7M10 14 21 3M19 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2h6"/></svg>원본 도면 열기</a></div>
</header>
<main>
<section class="stage-wrap">
<div class="stage-heading"><div><div class="eyebrow">STRUCTURAL MODEL / R2C</div><h1>R2C 일반도 3D 형상</h1></div><div class="stage-note">마우스로 회전 · 휠로 확대/축소</div></div>
<div class="canvas-shell"><canvas id="scene" aria-label="R2C 도면 기반 3D 모델"></canvas><div class="canvas-hud"><span class="hud-tag">RC BOX · SCHEMATIC</span><span class="hud-tag">단위 mm</span></div><div class="canvas-hint">드래그하여 모델을 회전하세요</div><div class="toast" id="toast"></div></div>
<div class="viewbar"><div class="views" role="group" aria-label="보기 방향"><button class="view-btn active" data-view="iso">등각</button><button class="view-btn" data-view="front">정면</button><button class="view-btn" data-view="side">측면</button><button class="view-btn" data-view="top">평면</button></div><div class="tools"><button class="tool-btn active" id="rebarBtn" aria-pressed="true"><svg viewBox="0 0 24 24"><path d="M5 19 19 5M4 12l8-8M12 20l8-8M4 17l3 3M17 4l3 3"/></svg>철근 표시</button><button class="tool-btn" id="resetBtn"><svg viewBox="0 0 24 24"><path d="M3 12a9 9 0 1 0 2.6-6.4L3 8M3 3v5h5"/></svg>초기화</button></div></div>
</section>
<aside>
<section class="card"><div class="card-head"><span class="card-title">주요 치수</span><span class="status">도면 확인</span></div><div class="dims"><div class="dim-row"><span>외형 폭 <span>W</span></span><strong>2,500 mm</strong></div><div class="dim-row"><span>외형 높이 <span>H</span></span><strong>1,620 mm</strong></div><div class="dim-row"><span>벽체 두께 <span>T</span></span><strong>120 mm</strong></div><div class="divider"></div><div class="dim-row"><span>모델 깊이 <span class="guess">L · 시각화값</span></span><strong id="depthReadout">1,600 mm</strong></div></div><div class="range-wrap"><div class="range-top"><label for="depth">깊이 조절</label><output id="depthOutput">1,600 mm</output></div><input id="depth" type="range" min="800" max="3200" step="100" value="1600"></div></section>
<section class="card"><div class="card-head"><span class="card-title">표현 요소</span><span class="card-kicker">LEGEND</span></div><div class="materials"><div class="mat-row"><i class="swatch"></i>철근 콘크리트<small>회색</small></div><div class="mat-row"><i class="swatch rebar"></i>철근 배근 개략<small>표시 전환 가능</small></div><div class="mat-row"><i class="swatch dimline"></i>치수 기준 형상<small>mm</small></div></div></section>
<section class="card info"><div class="card-head"><span class="card-title"><i class="icon">i</i>모델 참고</span></div><p>도면의 <strong>2,500 × 1,620 mm</strong> 외형 치수와 <strong>120 mm</strong> 치수 표기를 반영한 개략 모델입니다. 도면에서 깊이를 특정하기 어려워 조절값으로 제공하며, 철근 표시는 배근 형상을 이해하기 위한 시각화입니다.</p><div class="file-name">R2C_일반도_20260601.pdf</div></section>
<p class="footer-note">형상 검토용 시각화 · 제작·시공 전 원본 도면과 구조 검토 자료를 확인하세요.</p>
</aside>
</main>
</div>
<script>
(()=>{
const canvas=document.getElementById('scene'),ctx=canvas.getContext('2d');
const W=2500,H=1620,T=120;
let L=1600, yaw=-.72,pitch=.48,zoom=1,showRebar=true,drag=null;
const fmt=n=>new Intl.NumberFormat('ko-KR').format(n);
function resize(){const r=canvas.getBoundingClientRect(),d=Math.min(devicePixelRatio||1,2);canvas.width=Math.round(r.width*d);canvas.height=Math.round(r.height*d);ctx.setTransform(d,0,0,d,0,0);draw()}
const V=(x,y,z)=>[x,y,z];
function quad(a,b,c,d,color,edge='#67737b',normal){return {pts:[a,b,c,d],color,edge,normal}}
function faces(){
const x=W/2,y=L/2,z=H,ix=x-T,iy=y,iz0=T,iz1=H-T, f=[];
const cTop='#c4cdd0',cFront='#d2d9da',cSide='#aeb9be',cInner='#99a7ad',cBottom='#aab5b9';
// outside shell surfaces
f.push(quad(V(-x,-y,z),V(x,-y,z),V(x,y,z),V(-x,y,z),cTop,'#76838a',[0,0,1]));
f.push(quad(V(-x,-y,0),V(-x,y,0),V(x,y,0),V(x,-y,0),cBottom,'#68767d',[0,0,-1]));
f.push(quad(V(-x,-y,0),V(-x,-y,z),V(-x,y,z),V(-x,y,0),cSide,'#66747c',[-1,0,0]));
f.push(quad(V(x,-y,0),V(x,y,0),V(x,y,z),V(x,-y,z),'#b8c2c6','#6d7980',[1,0,0]));
// tunnel interior
f.push(quad(V(-ix,-y,iz1),V(ix,-y,iz1),V(ix,y,iz1),V(-ix,y,iz1),'#b8c2c5','#6c7a81',[0,0,-1]));
f.push(quad(V(-ix,-y,iz0),V(-ix,y,iz0),V(ix,y,iz0),V(ix,-y,iz0),'#a9b5b9','#68777d',[0,0,1]));
f.push(quad(V(-ix,-y,iz0),V(-ix,-y,iz1),V(-ix,y,iz1),V(-ix,y,iz0),cInner,'#64727a',[1,0,0]));
f.push(quad(V(ix,-y,iz0),V(ix,y,iz0),V(ix,y,iz1),V(ix,-y,iz1),'#9eacb1','#64727a',[-1,0,0]));
// visible entrance ring at front
f.push(quad(V(-x,-y,z),V(x,-y,z),V(ix,-y,iz1),V(-ix,-y,iz1),cFront,'#68767d',[0,-1,0]));
f.push(quad(V(-x,-y,0),V(-ix,-y,iz0),V(ix,-y,iz0),V(x,-y,0),'#bdc7ca','#68767d',[0,-1,0]));
f.push(quad(V(-x,-y,0),V(-x,-y,z),V(-ix,-y,iz1),V(-ix,-y,iz0),'#c8d1d3','#68767d',[0,-1,0]));
f.push(quad(V(x,-y,0),V(ix,-y,iz0),V(ix,-y,iz1),V(x,-y,z),'#c2cccf','#68767d',[0,-1,0]));
// rear ring, visible as a shaded face through tunnel
f.push(quad(V(-x,y,z),V(-ix,y,iz1),V(ix,y,iz1),V(x,y,z),'#b6c0c3','#77838a',[0,1,0]));
f.push(quad(V(-x,y,0),V(x,y,0),V(ix,y,iz0),V(-ix,y,iz0),'#9eaaaf','#77838a',[0,1,0]));
f.push(quad(V(-x,y,0),V(-ix,y,iz0),V(-ix,y,iz1),V(-x,y,z),'#a9b5b9','#77838a',[0,1,0]));
f.push(quad(V(x,y,0),V(x,y,z),V(ix,y,iz1),V(ix,y,iz0),'#a5b1b6','#77838a',[0,1,0]));
return f;
}
function rot(p){let [x,y,z]=p;const cy=Math.cos(yaw),sy=Math.sin(yaw),cp=Math.cos(pitch),sp=Math.sin(pitch);const x1=x*cy-y*sy,y1=x*sy+y*cy;return [x1,y1*cp-z*sp,y1*sp+z*cp]}
function projection(){const r=canvas.getBoundingClientRect(),scale=Math.min(r.width/(W+L*.68),r.height/(H+L*.5))*1.14*zoom;return {cx:r.width*.5,cy:r.height*.53,scale}}
function project(p,P){const [x,y,z]=rot(p);return [P.cx+x*P.scale,P.cy-(z-y*.45)*P.scale]}
function shade(hex,n){const l=Math.max(.72,Math.min(1.12,.88+(-n[0]*.13-n[1]*.17+n[2]*.22)));const h=hex.replace('#',''),v=[0,2,4].map(i=>Math.min(255,Math.round(parseInt(h.slice(i,i+2),16)*l)));return `rgb(${v.join(',')})`}
function drawGrid(P){const r=canvas.getBoundingClientRect();ctx.fillStyle='#e7edf0';ctx.fillRect(0,0,r.width,r.height);ctx.save();ctx.strokeStyle='#d5dfe3';ctx.lineWidth=1;const extent=4200,step=400;for(let x=-extent;x<=extent;x+=step){const a=project([x,-extent,-4],P),b=project([x,extent,-4],P);ctx.beginPath();ctx.moveTo(...a);ctx.lineTo(...b);ctx.stroke()}for(let y=-extent;y<=extent;y+=step){const a=project([-extent,y,-4],P),b=project([extent,y,-4],P);ctx.beginPath();ctx.moveTo(...a);ctx.lineTo(...b);ctx.stroke()}ctx.restore()}
function line3(a,b,P,color,width=1){const A=project(a,P),B=project(b,P);ctx.beginPath();ctx.moveTo(...A);ctx.lineTo(...B);ctx.strokeStyle=color;ctx.lineWidth=width;ctx.stroke()}
function drawRebar(P){const rebar='#d97567',thin=2;
// perimeter bars following the two opening rings
for(const y of [-L/2+T*.46,L/2-T*.46]){
const x=W/2-T*.34,z0=T*.48,z1=H-T*.48;
const corners=[V(-x,y,z0),V(x,y,z0),V(x,y,z1),V(-x,y,z1)];
for(let i=0;i<4;i++)line3(corners[i],corners[(i+1)%4],P,rebar,thin);
}
// longitudinal bars at slab edges and sidewalls
for(const xx of [-W/2+T*.46,W/2-T*.46])for(const zz of [T*.47,H-T*.47])line3(V(xx,-L/2+T*.46,zz),V(xx,L/2-T*.46,zz),P,rebar,thin);
for(let i=1;i<=5;i++){const x=-W/2+(W*i/6);line3(V(x,-L/2+T*.46,T*.48),V(x,-L/2+T*.46,H-T*.48),P,'#cb7165',1.25)}
// simple ties along the visible near side of the two walls
for(let i=1;i<8;i++){const yy=-L/2+T+(L-2*T)*i/8;line3(V(-W/2+T*.42,yy,T*.42),V(-W/2+T*.42,yy,H-T*.42),'#d8897d',1);line3(V(W/2-T*.42,yy,T*.42),V(W/2-T*.42,yy,H-T*.42),'#d8897d',1)}
}
function dimension(a,b,label,offset,P){const A=project(a,P),B=project(b,P),ox=offset[0],oy=offset[1],C=[A[0]+ox,A[1]+oy],D=[B[0]+ox,B[1]+oy];ctx.save();ctx.strokeStyle='#b17935';ctx.fillStyle='#795421';ctx.lineWidth=1;ctx.setLineDash([4,3]);ctx.beginPath();ctx.moveTo(...A);ctx.lineTo(...C);ctx.moveTo(...B);ctx.lineTo(...D);ctx.setLineDash([]);ctx.moveTo(...C);ctx.lineTo(...D);ctx.stroke();const ang=Math.atan2(D[1]-C[1],D[0]-C[0]);for(const [p,sgn] of [[C,1],[D,-1]]){ctx.beginPath();ctx.moveTo(...p);ctx.lineTo(p[0]+Math.cos(ang+sgn*.45)*8,p[1]+Math.sin(ang+sgn*.45)*8);ctx.moveTo(...p);ctx.lineTo(p[0]+Math.cos(ang-sgn*.45)*8,p[1]+Math.sin(ang-sgn*.45)*8);ctx.stroke()}ctx.font='600 11px system-ui';ctx.textAlign='center';ctx.textBaseline='middle';const mx=(C[0]+D[0])/2,my=(C[1]+D[1])/2;ctx.fillStyle='#e7edf0';ctx.fillRect(mx-35,my-9,70,18);ctx.fillStyle='#795421';ctx.fillText(label,mx,my);ctx.restore()}
function draw(){const r=canvas.getBoundingClientRect();if(!r.width||!r.height)return;const P=projection();drawGrid(P);const fs=faces().map(face=>({face,depth:face.pts.reduce((s,p)=>s+rot(p)[1],0)/4})).sort((a,b)=>a.depth-b.depth);ctx.lineJoin='round';for(const {face}=fs){const pts=face.pts.map(p=>project(p,P));ctx.beginPath();ctx.moveTo(...pts[0]);for(let i=1;i<pts.length;i++)ctx.lineTo(...pts[i]);ctx.closePath();ctx.fillStyle=shade(face.color,face.normal);ctx.fill();ctx.strokeStyle=face.edge;ctx.lineWidth=1.1;ctx.stroke()}if(showRebar)drawRebar(P);dimension(V(-W/2,-L/2,0),V(W/2,-L/2,0),'2,500 mm',[0,36],P);dimension(V(-W/2,-L/2,0),V(-W/2,-L/2,H),'1,620 mm',[-48,-2],P)}
const views={iso:[-.72,.48],front:[0,.02],side:[-Math.PI/2,.02],top:[0,Math.PI/2-.02]};
document.querySelectorAll('.view-btn').forEach(b=>b.addEventListener('click',()=>{document.querySelectorAll('.view-btn').forEach(x=>x.classList.toggle('active',x===b));[yaw,pitch]=views[b.dataset.view];draw()}));
document.getElementById('depth').addEventListener('input',e=>{L=Number(e.target.value);const val=fmt(L)+' mm';document.getElementById('depthOutput').textContent=val;document.getElementById('depthReadout').textContent=val;draw()});
document.getElementById('rebarBtn').addEventListener('click',e=>{showRebar=!showRebar;e.currentTarget.classList.toggle('active',showRebar);e.currentTarget.setAttribute('aria-pressed',String(showRebar));draw()});
document.getElementById('resetBtn').addEventListener('click',()=>{[yaw,pitch]=views.iso;zoom=1;L=1600;document.getElementById('depth').value=L;document.getElementById('depthOutput').textContent='1,600 mm';document.getElementById('depthReadout').textContent='1,600 mm';draw();const t=document.getElementById('toast');t.textContent='기본 시점으로 돌아왔습니다';t.classList.add('show');setTimeout(()=>t.classList.remove('show'),1300)});
canvas.addEventListener('pointerdown',e=>{drag={x:e.clientX,y:e.clientY,yaw,pitch};canvas.setPointerCapture(e.pointerId)});canvas.addEventListener('pointermove',e=>{if(!drag)return;yaw=drag.yaw+(e.clientX-drag.x)*.008;pitch=Math.max(-.08,Math.min(1.48,drag.pitch+(e.clientY-drag.y)*.006));document.querySelectorAll('.view-btn').forEach(x=>x.classList.remove('active'));draw()});canvas.addEventListener('pointerup',()=>drag=null);canvas.addEventListener('pointercancel',()=>drag=null);canvas.addEventListener('wheel',e=>{e.preventDefault();zoom=Math.max(.55,Math.min(1.8,zoom*Math.exp(-e.deltaY*.001)));draw()},{passive:false});
new ResizeObserver(resize).observe(canvas);resize();
})();
</script>
</body>
</html>
<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>
<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>
<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>
<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>
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();
})();
</script>
</body></html>
+418
View File
@@ -0,0 +1,418 @@
#!/usr/bin/env python3
"""Extract this R2C general-arrangement DXF, reconstruct its sections, and serve a static viewer."""
import argparse
from collections import Counter, defaultdict
import functools
import hashlib
import html
import http.server
import json
import math
from pathlib import Path
import re
import shutil
ROOT = Path(__file__).resolve().parent
DEFAULT_DXF = ROOT / 'drawing' / 'R2C_일반도_20260601.dxf'
def read_dxf(path):
raw = Path(path).read_bytes()
if raw.startswith(b'AutoCAD Binary DXF'):
raise ValueError('ASCII DXF로 저장해 주세요. 바이너리 DXF는 지원하지 않습니다.')
try:
text = raw.decode('utf-8-sig')
except UnicodeDecodeError:
text = raw.decode('cp949')
lines = text.splitlines()
if len(lines) % 2:
raise ValueError('DXF 그룹 코드/값 쌍이 올바르지 않습니다.')
records, section, current = [], '', None
for i in range(0, len(lines), 2):
code, value = int(lines[i].strip()), lines[i + 1].strip()
if code == 0:
if current:
records.append(current)
current = {'type': value, 'section': section, 'tags': []}
elif current:
current['tags'].append((code, value))
if current['type'] == 'SECTION' and code == 2:
section = value
current['section'] = section
if current:
records.append(current)
entities = [r for r in records if r['section'] == 'ENTITIES']
blocks, block = {}, None
for record in records:
if record['section'] != 'BLOCKS':
continue
if record['type'] == 'BLOCK':
block = tag(record, 2)
blocks[block] = []
elif record['type'] == 'ENDBLK':
block = None
elif block:
blocks[block].append(record)
return entities, blocks, raw
def tag(e, code, default=None):
return next((v for c, v in e['tags'] if c == code), default)
def number(e, code, default=0):
return float(tag(e, code, default))
def point(e, base=10):
return [number(e, base), number(e, base + 10)]
def clean_text(value):
value = re.sub(r'\\U\+([0-9a-fA-F]{4})', lambda m: chr(int(m[1], 16)), value)
value = re.sub(r'%%[cC]', 'Ø', value).replace('\\P', ' ')
value = re.sub(r'\\[A-Za-z][^;]*;', '', value)
return value.replace('{', '').replace('}', '')
def vertices(e):
out = []
for code, value in e['tags']:
if code == 10:
out.append([float(value), 0.0])
elif code == 20 and out:
out[-1][1] = float(value)
return out
def extract_model(entities, raw):
texts = [e for e in entities if e['type'] in ('TEXT', 'MTEXT')]
headers = [e for e in texts if tag(e, 1) in ('D', 'D1', 'D2', 'L', 'L1', 'L2', 'L3', 'B', 'b1', 'b2', 'b3', 'b4', 'H', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'h7') and tag(e, 8) == '0']
if len(headers) != 20:
raise ValueError('R2C 변화치수표의 20개 열을 찾지 못했습니다. 이 도면용 추출 규칙을 확인해 주세요.')
header_y = number(headers[0], 20)
table = defaultdict(dict)
for e in texts:
text = tag(e, 1, '')
if tag(e, 8) != '0' or not re.fullmatch(r'[\d,]+', text) or not header_y - 600 < number(e, 20) < header_y - 20:
continue
nearest = min(headers, key=lambda h: abs(number(e, 10) - number(h, 10)))
if abs(number(e, 10) - number(nearest, 10)) < 130:
table[round(number(e, 20), 1)][tag(nearest, 1)] = float(text.replace(',', ''))
rows = sorted((row for row in table.values() if len(row) == 20), key=lambda r: r['D'])
title = next((clean_text(tag(e, 1)) for e in texts if '%%C' in tag(e, 1, '') and '일반' in tag(e, 1, '')), '')
diameter_match = re.search(r'Ø\s*(\d+)', title)
if not diameter_match:
raise ValueError('원심력관의 지름 표기를 찾지 못했습니다.')
diameter = float(diameter_match[1])
dims = next((r for r in rows if r['D'] == diameter), None)
if dims is None:
raise ValueError('현재 도면 지름과 일치하는 치수표 행이 없습니다.')
label = next((e for e in texts if '단면 A-A' in tag(e, 1, '')), None)
if not label:
raise ValueError('단면 A-A를 찾지 못했습니다.')
arcs = [e for e in entities if e['type'] == 'ARC' and tag(e, 8) == '외형선1']
aa = [e for e in arcs if number(label, 10) - 200 < number(e, 10) < number(label, 10) + 1800 and number(label, 20) - 2000 < number(e, 20) < number(label, 20)]
bore = next((e for e in aa if abs(number(e, 40) - diameter / 2) < .01), None)
if bore is None:
raise ValueError('단면 A-A의 원형 관로를 찾지 못했습니다.')
cx, cy = point(bore)
section_lines = [e for e in entities if e['type'] == 'LINE' and tag(e, 8) == '외형선1' and cx - 900 < number(e, 10) < cx + 900 and cy - 800 < number(e, 20) < cy + 1000]
coords = [p for e in section_lines for p in (point(e), point(e, 11))]
bounds = [min(p[0] for p in coords), min(p[1] for p in coords), max(p[0] for p in coords), max(p[1] for p in coords)]
width, height, axis = bounds[2] - bounds[0], bounds[3] - bounds[1], cy - bounds[1]
if abs(width - dims['B']) > .1 or abs(height - dims['H']) > .1:
raise ValueError('단면 외곽선과 치수표 값이 일치하지 않습니다.')
# Inlet verticals expose the rectangular opening; the next inner edges are the lead-in.
verticals = [e for e in section_lines if abs(number(e, 10) - number(e, 11)) < .01 and abs(number(e, 20) - number(e, 21)) > 100]
inner = [e for e in verticals if cx - width / 2 + 50 < number(e, 10) < cx - diameter / 2]
left = min(number(e, 10) for e in inner)
inset = min(number(e, 10) for e in inner if number(e, 10) > left + 1)
upper = max(max(number(e, 20), number(e, 21)) for e in inner if abs(number(e, 10) - left) < .01) - cy
inner_top = max(max(number(e, 20), number(e, 21)) for e in inner if abs(number(e, 10) - inset) < .01) - cy
bb_label = next(e for e in texts if '단면 B-B' in tag(e, 1, ''))
bb = [e for e in arcs if number(bb_label, 10) - 200 < number(e, 10) < number(bb_label, 10) + 1800]
radii = sorted({round(number(e, 40), 6) for e in bb})
if len(radii) != 5:
raise ValueError('소켓부의 5개 원호 반지름을 찾지 못했습니다.')
length = dims['L']
# Station positions come from the dimension block below the side elevation.
labels = [e for e in texts if '측면도' in tag(e, 1, '')]
side_label = labels[0]
side = [e for e in entities if e['type'] == 'LINE' and tag(e, 8) == '외형선1' and number(side_label, 10) - 100 < number(e, 10) < number(side_label, 10) + length + 200 and number(side_label, 20) - height - 550 < number(e, 20) < number(side_label, 20)]
start = min(number(e, 10) for e in side)
hidden = [e for e in entities if e['type'] == 'LINE' and tag(e, 8) == '숨은선' and start - .1 <= number(e, 10) <= start + length + .1 and bounds[1] - 1 <= number(e, 20) <= bounds[3] + 1]
stations = sorted({round(number(e, 10) - start, 6) for e in hidden if abs(number(e, 10) - number(e, 11)) < .01})
if len(stations) != 5:
raise ValueError('측면도 소켓/변환 구간의 5개 위치를 찾지 못했습니다.')
a, b, c, d, end_chamfer = stations
end = length
profiles = [
{'x': 0, 'kind': 'rectangle', 'halfWidth': cx - left, 'top': upper, 'bottom': diameter / 2},
{'x': a, 'kind': 'rectangle', 'halfWidth': cx - inset, 'top': inner_top, 'bottom': diameter / 2},
{'x': b, 'kind': 'circle', 'radius': diameter / 2},
{'x': c, 'kind': 'circle', 'radius': diameter / 2},
{'x': c, 'kind': 'circle', 'radius': radii[1]},
{'x': d, 'kind': 'circle', 'radius': radii[2]},
{'x': end_chamfer, 'kind': 'circle', 'radius': radii[3]},
{'x': end, 'kind': 'circle', 'radius': radii[4]},
]
pipe_circle = next(e for e in entities if e['type'] == 'CIRCLE' and abs(number(e, 40) - diameter / 2) < .01)
pipe_axis = number(pipe_circle, 20)
pipe = next(e for e in entities if e['type'] == 'LWPOLYLINE' and tag(e, 8) == 'CS-CONC-MAJR' and int(number(e, 70)) & 1 and all(p[1] > pipe_axis for p in vertices(e)) and min(p[0] for p in vertices(e)) < number(pipe_circle, 10))
pts = vertices(pipe)
minimum = min(p[0] for p in pts)
radial = [[round(x - minimum, 6), round(y - pipe_axis, 6)] for x, y in pts]
result = {
'source': DEFAULT_DXF.name, 'sha256': hashlib.sha256(raw).hexdigest(),
'title': 'R2C관 · 사각–원형 전환관', 'diameter': diameter,
'dimensions': dims, 'width': width, 'height': height, 'length': length,
'axisHeight': axis, 'profiles': profiles, 'pipeProfile': radial,
'pipeDimensions': pipe_dimensions(radial),
'table': rows, 'counts': dict(Counter(e['type'] for e in entities if e['type'] not in ('SECTION', 'ENDSEC', 'EOF'))),
'evidence': {'sectionA': tag(bore, 5), 'sectionB': [tag(e, 5) for e in bb], 'pipeProfile': tag(pipe, 5), 'stations': stations},
'limitations': ['2D 일반도 A-A/B-B와 치수표를 이용한 단면 기반 재구성입니다.', '사각–원형 전환 면은 단면 사이를 직선 보간했습니다. 철근과 개스킷은 모델에 포함하지 않았습니다.', '상·하부 접합부의 작은 홈은 원본 2D 도면에서 확인할 수 있으며 3D에서는 생략했습니다.'],
}
return result
def pipe_dimensions(profile):
inner = min(p[1] for p in profile)
horizontal = [(abs(a[0] - b[0]), a[1]) for a, b in zip(profile, profile[1:] + profile[:1]) if abs(a[1] - b[1]) < .001]
nominal = max(length for length, radius in horizontal if abs(radius - inner) < .001)
outer = max((item for item in horizontal if item[1] > inner + 1), key=lambda item: item[0])[1]
return {'nominalLength': nominal, 'overallLength': max(p[0] for p in profile), 'outerDiameter': outer * 2, 'wallThickness': outer - inner}
def ray_rectangle(angle, half_width, top, bottom):
c, s = math.cos(angle), math.sin(angle)
t = min(half_width / max(abs(c), 1e-12), (top if s >= 0 else bottom) / max(abs(s), 1e-12))
return [t * c, t * s]
def make_mesh(model):
# Include rectangle corners and horizontal seam intersections in the angular sampling.
angles = {round(i * math.tau / 96, 12) for i in range(96)}
for p in model['profiles']:
if p['kind'] == 'rectangle':
for yy in [-p['halfWidth'], p['halfWidth']]:
for zz in [-p['bottom'], p['top']]:
angles.add(round(math.atan2(zz, yy) % math.tau, 12))
for yy in [-model['width'] / 2, model['width'] / 2]:
for zz in [-model['axisHeight'], model['height'] - model['axisHeight']]:
angles.add(round(math.atan2(zz, yy) % math.tau, 12))
angles = sorted(angles)
verts, faces, groups = [], [], []
def ring(x, shape):
indices = []
for angle in angles:
if shape['kind'] == 'circle':
yy, zz = shape['radius'] * math.cos(angle), shape['radius'] * math.sin(angle)
else:
yy, zz = ray_rectangle(angle, shape['halfWidth'], shape['top'], shape['bottom'])
indices.append(len(verts))
verts.append([round(x - model['length'] / 2, 6), round(yy, 6), round(zz + model['axisHeight'], 6)])
return indices
def join(a, b, kind):
for i, angle in enumerate(angles):
j = (i + 1) % len(angles)
faces.append([a[i], a[j], b[j], b[i]])
groups.append({'part': 'upper' if math.sin(angle + .00001) >= 0 else 'lower', 'surface': kind})
outer = {'kind': 'rectangle', 'halfWidth': model['width'] / 2, 'top': model['height'] - model['axisHeight'], 'bottom': model['axisHeight']}
oa, ob = ring(0, outer), ring(model['length'], outer)
join(oa, ob, 'outer')
rings = [ring(p['x'], p) for p in model['profiles']]
for a, b in zip(rings, rings[1:]):
join(b, a, 'inner')
join(rings[0], oa, 'end')
join(ob, rings[-1], 'end')
r2c = {'vertices': verts, 'faces': faces, 'groups': groups}
# Revolve the actual closed longitudinal pipe polyline; no guessed wall thickness.
verts, faces, groups = [], [], []
radial = model['pipeProfile']
pipe_length = max(p[0] for p in radial)
for x, r in radial:
for a in angles:
verts.append([x - pipe_length / 2, r * math.cos(a), r * math.sin(a) + model['axisHeight']])
n = len(angles)
for k in range(len(radial)):
for i, a in enumerate(angles):
j, kk = (i + 1) % n, (k + 1) % len(radial)
faces.append([k * n + i, k * n + j, kk * n + j, kk * n + i])
groups.append({'part': 'upper' if math.sin(a + .00001) >= 0 else 'lower', 'surface': 'pipe'})
return {'r2c': r2c, 'pipe': {'vertices': verts, 'faces': faces, 'groups': groups}}
def spline_points(e):
points = vertices(e)
knots = [float(v) for c, v in e['tags'] if c == 40]
weights = [float(v) for c, v in e['tags'] if c == 41] or [1.0] * len(points)
degree = int(number(e, 71, 3))
if len(knots) != len(points) + degree + 1 or len(points) < degree + 1:
return []
homogeneous = [[p[0] * w, p[1] * w, w] for p, w in zip(points, weights)]
result = []
for step in range(65):
u = knots[degree] + (knots[len(points)] - knots[degree]) * step / 64
span = len(points) - 1 if step == 64 else next(i for i in range(degree, len(points)) if knots[i] <= u < knots[i + 1])
d = [homogeneous[j + span - degree][:] for j in range(degree + 1)]
for r in range(1, degree + 1):
for j in range(degree, r - 1, -1):
k = j + span - degree
denom = knots[j + 1 + span - r] - knots[k]
alpha = (u - knots[k]) / denom if denom else 0
d[j] = [(1 - alpha) * a + alpha * b for a, b in zip(d[j - 1], d[j])]
x, y, w = d[degree]
result.append([x / w, y / w])
return result
def drawing_svg(entities, blocks):
counts = Counter()
def render(e, depth=0):
if depth > 8:
return ''
typ, layer = e['type'], tag(e, 8, '0')
if layer == 'Defpoints' or int(number(e, 67)) == 1:
return ''
color = '#cc6c49' if layer in ('CS-DIML', '치수', 'Dims') else '#7b98a7' if layer == '숨은선' else '#263c4b'
style = f'fill="none" stroke="{color}" stroke-width="4" stroke-linejoin="round"'
if layer == '숨은선':
style += ' stroke-dasharray="18 12"'
points = None
if typ == 'LINE':
points = [point(e), point(e, 11)]
elif typ == 'LWPOLYLINE':
points = vertices(e)
if int(number(e, 70)) & 1:
points += points[:1]
elif typ in ('ARC', 'CIRCLE'):
cx, cy = point(e)
r = number(e, 40)
a, b = math.radians(number(e, 50)), math.radians(number(e, 51, 360))
if b <= a:
b += math.tau
points = [[cx + r * math.cos(a + (b - a) * i / 96), cy + r * math.sin(a + (b - a) * i / 96)] for i in range(97)]
elif typ == 'ELLIPSE':
cx, cy = point(e)
ax, ay = point(e, 11)
ratio, a, b = number(e, 40), number(e, 41), number(e, 42, math.tau)
points = [[cx + ax * math.cos(t) - ay * ratio * math.sin(t), cy + ay * math.cos(t) + ax * ratio * math.sin(t)] for t in [a + (b - a) * i / 96 for i in range(97)]]
elif typ == 'SPLINE':
points = spline_points(e)
elif typ in ('TEXT', 'MTEXT'):
text = ''.join(v for c, v in e['tags'] if c in (1, 3))
text = clean_text(text)
x, y = point(e)
angle = -number(e, 50)
return f'<text x="{x}" y="{-y}" font-size="{number(e, 40, 35)}" fill="{color}" transform="rotate({angle} {x} {-y})">{html.escape(text)}</text>'
elif typ in ('INSERT', 'DIMENSION'):
content = ''.join(render(item, depth + 1) for item in blocks.get(tag(e, 2), []))
if typ == 'INSERT':
x, y = point(e)
return f'<g transform="translate({x},{-y}) rotate({-number(e, 50)}) scale({number(e, 41, 1)},{number(e, 42, 1)})">{content}</g>'
return content
elif typ in ('HATCH', 'POINT', 'SOLID', 'SECTION', 'ENDSEC', 'EOF'):
counts[typ] += 1
return ''
else:
counts[typ] += 1
return ''
if points:
p = ' '.join(f'{x:.3f},{-y:.3f}' for x, y in points)
return f'<polyline points="{p}" {style}/>'
return ''
content = ''.join(render(e) for e in entities)
return f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -5940 16820 5940" aria-label="DXF 원본 벡터 도면"><rect x="0" y="-5940" width="16820" height="5940" fill="#f7fafb"/><g font-family="system-ui,sans-serif">{content}</g></svg>', dict(counts)
def obj_text(mesh):
lines = ['# R2C DXF section reconstruction; millimetres']
lines.extend('v ' + ' '.join(str(v) for v in p) for p in mesh['vertices'])
for face in mesh['faces']:
lines.append('f ' + ' '.join(str(i + 1) for i in face))
return '\n'.join(lines) + '\n'
def initial_scene(model):
"""Pre-render real mesh polygons so opening HTML never depends on JS to show a model."""
yaw, pitch = .87, .40
def rotate(p):
x, y, z = p[0], p[1], p[2] - model['height'] / 2
h, d = x * math.cos(yaw) - y * math.sin(yaw), x * math.sin(yaw) + y * math.cos(yaw)
return [h, d * math.cos(pitch) - z * math.sin(pitch), z * math.cos(pitch) + d * math.sin(pitch)]
mesh = model['meshes']['r2c']
rotated = [rotate(p) for p in mesh['vertices']]
xs, ys = [p[0] for p in rotated], [p[2] for p in rotated]
min_x, max_x, min_y, max_y = min(xs), max(xs), min(ys), max(ys)
scale = min(780 / (max_x - min_x), 490 / (max_y - min_y))
result = []
order = sorted(range(len(mesh['faces'])), key=lambda i: -sum(rotated[v][1] for v in mesh['faces'][i]) / 4)
for i in order:
f, group = mesh['faces'][i], mesh['groups'][i]
a, b, c = [mesh['vertices'][v] for v in f[:3]]
u, v = [b[k] - a[k] for k in range(3)], [c[k] - a[k] for k in range(3)]
n = [u[1] * v[2] - u[2] * v[1], u[2] * v[0] - u[0] * v[2], u[0] * v[1] - u[1] * v[0]]
length = math.sqrt(sum(x * x for x in n)) or 1
n = [x / length for x in n]
light = max(.48, .78 - n[0] * .22 - n[1] * .25 + n[2] * .28)
base = [194, 204, 203] if group['part'] == 'upper' else [150, 171, 176]
if group['surface'] == 'inner':
base = [152, 171, 175] if group['part'] == 'upper' else [123, 150, 159]
color = 'rgb(' + ','.join(str(min(255, round(x * light))) for x in base) + ')'
points = ' '.join(f'{500 + (rotated[v][0] - (min_x + max_x) / 2) * scale:.2f},{345 - (rotated[v][2] - (min_y + max_y) / 2) * scale:.2f}' for v in f)
result.append(f'<polygon points="{points}" fill="{color}" stroke="{color}" stroke-width=".45"/>')
return ''.join(result)
def build(dxf=DEFAULT_DXF, output=ROOT / 'dist', update_entry=True):
dxf, output = Path(dxf).resolve(), Path(output).resolve()
if output == ROOT or output == ROOT / 'drawing':
raise ValueError('출력 폴더는 dist 같은 별도 폴더로 지정해 주세요.')
entities, blocks, raw = read_dxf(dxf)
model = extract_model(entities, raw)
model['source'] = dxf.name
model['meshes'] = make_mesh(model)
svg, omissions = drawing_svg(entities, blocks)
model['drawingOmissions'] = omissions
output.mkdir(parents=True, exist_ok=True)
template = (ROOT / 'web' / 'viewer.html').read_text(encoding='utf-8')
template = template.replace('__INITIAL_SCENE__', initial_scene(model)).replace('__DXF_HREF__', './drawing/' + html.escape(dxf.name, quote=True))
page = template.replace('__MODEL_JSON__', json.dumps(model, ensure_ascii=False, separators=(',', ':')).replace('<', '\\u003c')).replace('__DRAWING_SVG__', svg)
(output / 'index.html').write_text(page, encoding='utf-8')
(output / 'model.json').write_text(json.dumps(model, ensure_ascii=False, indent=2), encoding='utf-8')
(output / 'model.obj').write_text(obj_text(model['meshes']['r2c']), encoding='utf-8')
(output / 'drawing.svg').write_text(svg, encoding='utf-8')
(output / 'drawing').mkdir(exist_ok=True)
shutil.copyfile(dxf, output / 'drawing' / dxf.name)
if update_entry:
(ROOT / 'index.html').write_text(page, encoding='utf-8')
print(f'생성 완료: {output / "index.html"}')
print(f'R2C: {model["length"]:g} × {model["width"]:g} × {model["height"]:g} mm / 내경 Ø{model["diameter"]:g}')
return model
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('command', choices=['build', 'serve'], nargs='?', default='serve')
parser.add_argument('--dxf', type=Path, default=DEFAULT_DXF)
parser.add_argument('--output', type=Path, default=ROOT / 'dist')
parser.add_argument('--host', default='127.0.0.1', help='다른 기기에서 접속하려면 0.0.0.0')
parser.add_argument('--port', type=int, default=8000)
args = parser.parse_args()
try:
build(args.dxf, args.output)
if args.command == 'serve':
handler = functools.partial(http.server.SimpleHTTPRequestHandler, directory=str(args.output.resolve()))
with http.server.ThreadingHTTPServer((args.host, args.port), handler) as server:
print(f'웹 뷰어: http://{args.host}:{args.port} (종료: Ctrl+C)', flush=True)
try:
server.serve_forever()
except KeyboardInterrupt:
pass
except (OSError, ValueError, StopIteration) as error:
parser.exit(1, f'오류: {error}\n')
if __name__ == '__main__':
main()
+119
View File
@@ -0,0 +1,119 @@
import json
import io
import http.server
from urllib.parse import quote
import math
from collections import Counter
from pathlib import Path
import tempfile
import unittest
import r2c
class DrawingReconstructionTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.entities, cls.blocks, cls.raw = r2c.read_dxf(r2c.DEFAULT_DXF)
cls.model = r2c.extract_model(cls.entities, cls.raw)
cls.meshes = r2c.make_mesh(cls.model)
def test_diameter_and_dimensions_match_both_section_and_table(self):
m = self.model
self.assertEqual(m['diameter'], 1000)
self.assertAlmostEqual(m['length'], 1620)
self.assertAlmostEqual(m['width'], 1540)
self.assertAlmostEqual(m['height'], 1486)
self.assertAlmostEqual(m['axisHeight'], 666)
self.assertEqual(len(m['table']), 7)
self.assertEqual([r['D'] for r in m['table']], [700, 800, 900, 1000, 1100, 1200, 1350])
def test_inlet_is_rectangular_and_outlet_is_circular(self):
profiles = self.model['profiles']
self.assertEqual(profiles[0]['kind'], 'rectangle')
self.assertAlmostEqual(profiles[0]['halfWidth'] * 2, 1310)
self.assertAlmostEqual(profiles[0]['top'] + profiles[0]['bottom'], 1205)
self.assertEqual(profiles[2]['kind'], 'circle')
self.assertEqual(profiles[2]['radius'], 500)
self.assertEqual(profiles[2]['x'], 1400)
self.assertAlmostEqual(profiles[-1]['x'], 1620)
self.assertAlmostEqual(profiles[-1]['radius'], 615.8)
def test_pipe_is_actual_revolved_polyline(self):
self.assertEqual(self.model['evidence']['pipeProfile'], '195')
profile = self.model['pipeProfile']
self.assertAlmostEqual(max(p[0] for p in profile), 2620)
self.assertAlmostEqual(min(p[1] for p in profile), 500)
self.assertAlmostEqual(max(p[1] for p in profile), 666)
def test_closed_meshes_have_no_boundary_edges(self):
for name, mesh in self.meshes.items():
edges = Counter()
for face in mesh['faces']:
self.assertEqual(len(set(face)), len(face))
for a, b in zip(face, face[1:] + face[:1]):
edges[tuple(sorted((a, b)))] += 1
self.assertTrue(all(count == 2 for count in edges.values()), name)
self.assertTrue(all(math.isfinite(n) for p in mesh['vertices'] for n in p))
def test_r2c_bounds_match_native_drawing(self):
pts = self.meshes['r2c']['vertices']
for axis, expected in enumerate([1620, 1540, 1486]):
self.assertAlmostEqual(max(p[axis] for p in pts) - min(p[axis] for p in pts), expected, places=5)
def test_svg_preserves_curves_sections_and_korean_text(self):
svg, omitted = r2c.drawing_svg(self.entities, self.blocks)
self.assertIn('단면 A-A', svg)
self.assertIn('단면 B-B', svg)
self.assertIn('1,620', svg)
self.assertNotIn('%%C', svg)
self.assertGreater(svg.count('<polyline'), 800)
self.assertIn('HATCH', omitted)
def test_static_package_has_visible_geometry_without_javascript(self):
with tempfile.TemporaryDirectory() as directory:
m = r2c.build(output=directory, update_entry=False)
out = Path(directory)
source = (out / 'index.html').read_text()
self.assertNotIn('__INITIAL_SCENE__', source)
self.assertNotIn('__MODEL_JSON__', source)
self.assertNotIn('__DRAWING_SVG__', source)
before_script = source.split('<script')[0]
self.assertGreater(before_script.count('<polygon'), 900)
self.assertTrue((out / 'drawing' / r2c.DEFAULT_DXF.name).is_file())
self.assertEqual(json.loads((out / 'model.json').read_text())['sha256'], m['sha256'])
self.assertTrue((out / 'model.obj').read_text().startswith('# R2C'))
def test_web_handler_serves_generated_files_only(self):
class Handler(http.server.SimpleHTTPRequestHandler):
def log_message(self, *args):
pass
with tempfile.TemporaryDirectory() as directory:
r2c.build(output=directory, update_entry=False)
for path, status, content in [
('/index.html', b'200 OK', b'<polygon'),
('/model.obj', b'200 OK', b'# R2C'),
('/drawing/' + quote(r2c.DEFAULT_DXF.name), b'200 OK', b'SECTION'),
('/.git/config', b'404', b'File not found'),
]:
handler = object.__new__(Handler)
handler.directory = directory
handler.path = path
handler.command = 'GET'
handler.request_version = 'HTTP/1.1'
handler.requestline = 'GET ' + path + ' HTTP/1.1'
handler.headers = {}
handler.wfile = io.BytesIO()
handler.do_GET()
response = handler.wfile.getvalue()
self.assertIn(status, response.split(b'\r\n')[0])
self.assertIn(content, response)
def test_unrelated_drawing_does_not_produce_a_guessed_model(self):
with self.assertRaisesRegex(ValueError, '변화치수표'):
r2c.extract_model([], b'')
if __name__ == '__main__':
unittest.main()
+73
View File
@@ -0,0 +1,73 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><meta name="theme-color" content="#111c29">
<title>R2C · DXF 도면 3D 뷰어</title>
<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}}
</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>
<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>
<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>
<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();
})();
</script>
</body></html>