공개용 정리본: 1차 제출본(도면 좌표·과업명 제거)으로 새 이력 시작 (SPEC 34장)

- 정리는 코치 세션이 저장소 밖 도구로 수행, 하이쿠 세션이 커밋.
- 옛 81커밋은 로컬에만 보존. 기록은 evidence/gitlog_v1.txt.
- F13 기준 변경: .gitignore 에 private/*.dwg·private/full.dxf 제외 (SPEC 34장 끝 문장).
- check.sh 결과: 결과: PASS 151 / FAIL 0 / SKIP 3 (검사 범위 F01~F46)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
This commit is contained in:
오문성andClaude Haiku 5.5 committed 2026-10-27 14:27:50 +09:00
commit 9068b6c7f2
31 files changed
+374207

No files matched your search

+5
View File
@@ -0,0 +1,5 @@
private/*.dwg
private/full.dxf
private/model_extracted.js
private/*.txt
__pycache__/
+37
View File
@@ -0,0 +1,37 @@
# drawing3d-web — 작업 규칙 (Haiku 세션용)
원형 수직구 일반도를 라이브러리 없이 Canvas 2D 로 보여 주는 3D 뷰어. 설계는 `SPEC.md`, 할 일은 `FEATURES.json`.
## 시작할 때마다
1. `PROGRESS.md` 마지막 5줄과 `git log --oneline -5` 를 읽는다.
2. `FEATURES.json` 에서 `"passes": false` 인 **맨 앞 기능 하나**만 고른다. 두 개를 같이 하지 않는다.
3. 그 기능의 `steps` 와 SPEC 의 해당 장을 읽고 시작한다.
## 실행과 검증
- 화면 보기: `explorer.exe index.html` (Windows 브라우저로 열림). 수석이 화면을 확인한다.
- 판정: `bash check.sh F0n` (n = 지금 기능 번호). 그 기능과 앞 기능 항목이 전부 `[PASS]` 여야 끝이다.
- 끝났다고 말할 때는 **check.sh 출력 전체를 그대로 붙인다.** 요약하거나 고쳐 쓰지 않는다.
## 끝낼 때 (기능 1개 = 커밋 1개)
1. `FEATURES.json` 에서 그 기능의 `passes` 만 `true` 로 바꾼다. 다른 글자는 건드리지 않는다.
2. `PROGRESS.md` 에 한 줄 추가(양식은 파일 첫 줄).
3. `git add -A` 후 커밋. 메시지 첫 줄 `F0n: 한 일`, 본문에 check.sh 마지막 줄.
4. 다음 기능으로 넘어가지 말고 수석에게 보고하고 멈춘다.
## 금지
- `check.sh`, `SPEC.md`, `CLAUDE.md` 수정 금지. 틀렸다고 생각되면 고치지 말고 수석에게 말한다.
- three.js 등 라이브러리, CDN, `import`, `require`, 외부 URL 금지. 파일은 index.html, viewer.js, model.js, README.md, .gitignore, guide/ (구동설명서 HTML), tools/prep_drawing.py, tools/extract_from_dxf.py, model_extracted.js, compare.html. F33~F34 에서는 model.js 를 고치지 않는다.
- 도면은 `private/` 의 공개용 정리본만 커밋한다: `drawing.dxf`·`drawing.svg`·`drawing_gen.svg` (SPEC 34장). 원본 DWG·`full.dxf`·지명·과업명·실제 좌표가 든 파일은 절대 커밋하지 않는다. `git add -A` 전에 `git status` 로 확인한다.
- `model.js` 는 `const MODEL = ` 뒤에 순수 JSON(따옴표 키, 주석·끝 쉼표 없음) 그리고 `;`.
- 커밋 메시지의 `Co-Authored-By` 줄을 지우지 않는다. `--no-verify`, `--amend`, `rebase`, 날짜 지정 금지.
- 실패한 시도도 커밋을 지우지 않는다. 되돌릴 때는 새 커밋으로.
- 원본 도면 파일(DWG·정리 전 DXF), 회사 파일명, 비밀값을 리포에 넣지 않는다.
## 코드
- 순수 JavaScript 한 파일(viewer.js). 함수 이름은 SPEC 7장 목록을 쓴다.
- 길이 단위는 mm, z 위로 +. 각도는 X축에서 반시계, 도.
- 면 = 점 배열 + 색 + alpha. 먼 면부터 그린다.
## 막히면
- 같은 문제를 두 번 고쳐도 안 되면 멈추고 수석에게 무엇이 안 되는지 보고한다.
- 모르는 치수는 지어내지 않는다. SPEC 에 없으면 수석에게 묻는다.
+499
View File
@@ -0,0 +1,499 @@
[
{
"id": "F01",
"description": "골격: index.html + viewer.js + model.js 빈 파일, 캔버스가 창을 채운다",
"steps": [
"index.html 에 canvas 하나, model.js 다음 viewer.js 순서로 script 태그",
"model.js 는 SPEC 5장 꼴로 units 는 mm, parts 는 빈 배열",
"viewer.js 는 캔버스를 창 크기에 맞추고 배경만 칠한다",
"bash check.sh F01"
],
"passes": true
},
{
"id": "F02",
"description": "스파이크 1: 캔버스에 삼각형 하나를 칠한다",
"steps": [
"viewer.js 안에 고정 좌표 삼각형 하나를 path 로 채운다",
"브라우저로 열어 삼각형이 보이는지 수석이 확인",
"bash check.sh F02"
],
"passes": true
},
{
"id": "F03",
"description": "스파이크 2: 시험용 큐브 하나를 정사 투영, 면 음영, 먼 면부터 그리기로 그린다",
"steps": [
"facesBox, rotatePoint, project, shadeColor, sortFaces, draw 를 만든다",
"시험 큐브 1개(viewer.js 안 고정값)를 그린다",
"면마다 밝기가 다르고 뒤쪽 면이 앞쪽 면을 덮지 않는지 수석이 확인",
"bash check.sh F03"
],
"passes": true
},
{
"id": "F04",
"description": "스파이크 3: 등각 초기 화면 + 마우스 드래그 궤도 회전",
"steps": [
"카메라 초기값을 등각(좌우 45도, 기울기 약 35도)으로",
"onMouseDown, onMouseMove, onMouseUp 으로 좌우 = Z축 둘레, 위아래 = 기울기",
"드래그할 때마다 draw 다시 호출",
"bash check.sh F04"
],
"passes": true
},
{
"id": "F05",
"description": "휠 확대/축소",
"steps": [
"onWheel 로 배율 변경, 너무 작거나 크지 않게 한계",
"bash check.sh F05"
],
"passes": true
},
{
"id": "F06",
"description": "model.js 데이터: 벽, 중간 슬래브 19장, 19B 패드, 하부 슬래브, 바닥콘크리트 (SPEC 4.1~4.3, 5장)",
"steps": [
"SPEC 4.1~4.3 표의 숫자를 그대로 parts 에 적는다(mm)",
"const MODEL = 뒤는 순수 JSON (따옴표 키, 끝 쉼표 없음)",
"bash check.sh F06"
],
"passes": true
},
{
"id": "F07",
"description": "disc 그리기: 슬래브·패드·하부 슬래브·바닥콘크리트를 32각형 원판으로, 화면에 모델 전체가 들어오게",
"steps": [
"facesDisc, buildFaces 를 만들고 시험 큐브는 지운다",
"처음 배율을 모델 크기에 맞춘다",
"bash check.sh F07"
],
"passes": true
},
{
"id": "F08",
"description": "ring 그리기: 벽체를 32각형 속 빈 원통으로, alpha 0.25 반투명",
"steps": [
"facesRing (바깥면, 안쪽면, 위아래 고리면)",
"alpha 를 globalAlpha 로 적용, 벽 너머 슬래브가 보이는지 수석이 확인",
"bash check.sh F08"
],
"passes": true
},
{
"id": "F09",
"description": "TBM 터널: pipe 데이터(SPEC 4.5) + 누운 속 빈 원통 그리기",
"steps": [
"model.js 에 tunnel 1개: 시작점, 330도, 길이 20650, r_out 1500, r_in 1300",
"facesPipe",
"평면 4시 방향, 19B 높이에 붙었는지 수석이 확인",
"bash check.sh F09"
],
"passes": true
},
{
"id": "F10",
"description": "계단 18런: stair 데이터(SPEC 4.4) + 디딤판 블록 그리기",
"steps": [
"model.js 에 stair 18개 (첫 단 100, 단 200, 디딤판 300, 폭 1000)",
"facesStair: 블록마다 facesBox 를 부른다",
"층마다 방향이 바뀌는지 수석이 확인",
"bash check.sh F10"
],
"passes": true
},
{
"id": "F11",
"description": "터널 안 채움: fill 3개(잡석, 패드 2단, 패드 1단) 데이터 + 그리기 (일정이 밀리면 가장 먼저 뺀다)",
"steps": [
"model.js 에 fill 3개 (SPEC 4.5 표)",
"facesFill: 원과 수평선으로 자른 단면을 터널 축 방향으로 늘인다",
"bash check.sh F11"
],
"passes": true
},
{
"id": "F12",
"description": "README.md: 구동 방법 3줄, 조작법, 모델 설명(구조물·단위), 사용 도구(WSL2·Claude Code Haiku), wsl -l -v 한 줄, 🔒 표기",
"steps": [
"README.md 작성 (코치가 읽고 수석에게 의견)",
"bash check.sh"
],
"passes": true
},
{
"id": "F13",
"description": "좌우 분할 화면: 왼쪽 도면 칸, 끌어서 움직이는 가운데 막대, 오른쪽 3D (SPEC 10.1)",
"steps": [
"index.html 에 #drawPane, #splitter, #viewPane",
"onSplitDown/onSplitMove/onSplitUp 으로 왼쪽 폭 15%~85%",
"폭이 바뀌면 캔버스를 오른쪽 칸에 맞추고 draw",
"도면이 없으면 안내 글자",
"bash check.sh F13"
],
"passes": true
},
{
"id": "F14",
"description": "도면 표시·확대·이동: #drawStage 안 img + 겹친 svg, 처음 fit 상자에 맞춤 (SPEC 10.2)",
"steps": [
"#drawStage, img#drawing, svg#hotspots(viewBox 0 0 443 443)",
"fitDrawing, onDrawWheel(마우스 위치 기준 0.5~40배), onDrawDown/Move/Up(이동)",
"bash check.sh F14"
],
"passes": true
},
{
"id": "F15",
"description": "도면 클릭 → 3D 강조: MODEL.drawing 데이터 + 부재별 클릭 영역 + 선택 표시 (SPEC 10.3)",
"steps": [
"model.js 에 drawing 추가(SPEC 10.3 값 그대로)",
"sectionPoly, planPoly, buildHotspots 로 영역 만들기",
"selectPart: 도면 영역 칠하기, 3D 부재 #ffd400, 「선택: 이름」",
"bash check.sh F15"
],
"passes": true
},
{
"id": "F16",
"description": "구동설명서.md: 목적·기획·범위·구동·조작·작업 방식·한계·이력 (SPEC 11.1)",
"steps": [
"SPEC 11.1 순서대로 구동설명서.md 작성, 첫머리 🔒",
"근거는 SPEC.md·PROGRESS.md·git log 에서만",
"bash check.sh F16"
],
"passes": true
},
{
"id": "F17",
"description": "README.md 를 코드 설명으로 고쳐 쓰기: 파일 구조·실행·데이터·코드·그리기 방식·도면 연결·검사 (SPEC 11.2)",
"steps": [
"SPEC 11.2 순서대로 README.md 다시 쓰기, 첫머리 🔒",
"SPEC 7장·10.3 함수 이름 전부와 한 줄 설명",
"bash check.sh F17"
],
"passes": true
},
{
"id": "F18",
"description": "구동설명서를 guide/ HTML 위키(목차 + 8쪽)로 옮기고 3D 칸 오른쪽 위에 「구동 설명서」 버튼 (SPEC 12장)",
"steps": [
"구동설명서.md 내용을 guide/ 8쪽으로 나눠 옮기고 md 는 지운다",
"모든 쪽 맨 위 「← 3D 화면으로 돌아가기」(../index.html), 🔒, h1, 다른 쪽 링크 2개 이상, 목차·이전/다음",
"index.html 에 a#guideLink(guide/index.html) 를 3D 칸 오른쪽 위에",
"bash check.sh F18"
],
"passes": true
},
{
"id": "F19",
"description": "선명한 도면: tools/prep_drawing.py 로 1px 선 SVG 만들기 + 실제 크기로 확대 (SPEC 13.1)",
"steps": [
"tools/prep_drawing.py 작성",
"수석이 준 원본 경로로 실행해 private/drawing.svg 다시 만들기(커밋 안 됨)",
"applyDrawView: width/height 로 확대, translate 로 이동",
"README.md 코드 절에 새 함수 이름과 한 줄 설명 추가",
"bash check.sh F19"
],
"passes": true
},
{
"id": "F20",
"description": "모든 그림 연결: MODEL.drawing.views 11개 + 그림별 클릭 영역 + 여러 부재 한 번에 선택 (SPEC 13.2)",
"steps": [
"model.js 에 drawing.views (SPEC 13.2 표 그대로)",
"viewHotspots, buildHotspots 가 views 를 돈다",
"selectPart 가 id 배열을 받는다",
"README.md 코드 절에 새 함수 이름과 한 줄 설명 추가",
"bash check.sh F20"
],
"passes": true
},
{
"id": "F21",
"description": "슬래브 구멍(holes) + 계단 방향 정정 (SPEC 15.1~15.2)",
"steps": [
"model.js: 슬래브·19B 패드에 holes (15.1 표 그대로)",
"model.js: 계단 18런 x_top·dir·y0·y1 을 15.2 대로",
"facesDiscHoles: 윗면·아랫면 evenodd + 구멍 옆면",
"README.md 코드 절에 새 함수",
"bash check.sh F21"
],
"passes": true
},
{
"id": "F22",
"description": "카메라 이동: 오른쪽 드래그 팬, 마우스 기준 확대 0.5~40배, 더블클릭·처음 화면 버튼 (SPEC 15.3)",
"steps": [
"CAM.target 기준으로 project",
"panView, zoomAt, resetView",
"index.html 에 button#resetView, 오른쪽 클릭 메뉴 막기",
"README.md 코드 절에 새 함수",
"bash check.sh F22"
],
"passes": true
},
{
"id": "F23",
"description": "사람 애니메이션: 1B~19B 계단을 쉬지 않고 오르내림, 사람 ▶/❚❚ 버튼 (SPEC 15.4)",
"steps": [
"buildWalkPath, walkerAt, facesPerson, animate",
"index.html 에 button#walkToggle",
"README.md 코드 절에 새 함수",
"bash check.sh F23"
],
"passes": true
},
{
"id": "F24",
"description": "바닥부 부재: 월류벽(prism 3)·집수조·침사조(zone)·사다리(ladder), 19B 슬래브·패드 침사구 구멍 (SPEC 16.1)",
"steps": [
"model.js 에 16.1 표 그대로",
"facesPrism, facesZone, facesLadder",
"README.md 데이터·코드 절에 새 type 과 함수",
"bash check.sh F24"
],
"passes": true
},
{
"id": "F25",
"description": "바닥부 도면 연결: 단면 A-A·20B 평면·19B 침사구 (SPEC 16.2)",
"steps": [
"bottomHotspots 를 buildHotspots 에 연결",
"README.md 코드 절에 새 함수",
"bash check.sh F25"
],
"passes": true
},
{
"id": "F26",
"description": "상부 슬래브·맨홀 목 2개·벽 윗면 −2000·전기 배선구 구멍 정정 (SPEC 17.1)",
"steps": [
"model.js: 벽 z1 −2000, topslab, neck 2, elec 다각형",
"holePolygon(원 구멍), prism holes",
"README.md 갱신",
"bash check.sh F26"
],
"passes": true
},
{
"id": "F27",
"description": "분기구(obox 5)·환기구 덕트와 환기탑(obox 13) (SPEC 17.2)",
"steps": [
"model.js: 17.2 표 그대로",
"facesObox",
"README.md 갱신",
"bash check.sh F27"
],
"passes": true
},
{
"id": "F28",
"description": "방호 난간 18장·펌프 배출관·위쪽 사다리 2개 (SPEC 17.3)",
"steps": [
"model.js: 17.3 그대로",
"facesRailing",
"README.md 갱신",
"bash check.sh F28"
],
"passes": true
},
{
"id": "F29",
"description": "상부 부재 도면 연결: 단면 A-A·평면도 1·2 (SPEC 17.4)",
"steps": [
"upperHotspots 를 buildHotspots 에 연결",
"README.md 갱신",
"bash check.sh F29"
],
"passes": true
},
{
"id": "F30",
"description": "사람 두 명이 2초 간격으로 줄지어 이동 (SPEC 18.1)",
"steps": [
"teamAt: 첫 사람 t, 두 번째 t−2초",
"facesPerson 를 두 번(색 #ff8c1a, #e07010)",
"README.md 코드 절에 새 함수",
"bash check.sh F30"
],
"passes": true
},
{
"id": "F31",
"description": "콘크리트 계단: 경사판 밑면, 계단 아래 빈 공간 (SPEC 18.2)",
"steps": [
"stairProfile: 18.2 의 점 순서 그대로",
"facesStair: 단면을 Y 띠 폭만큼 늘인 면(블록 방식 버림)",
"README.md 코드 절에 새 함수",
"bash check.sh F31"
],
"passes": true
},
{
"id": "F32",
"description": "구동설명서를 목차 + 목적·구동·조작 3쪽으로 정리, 🔒 줄 빼기 (SPEC 18.3, 코치 작성·하이쿠 커밋)",
"steps": [
"코치가 쓴 guide/ 변경을 그대로 커밋, 지울 쪽은 git rm",
"bash check.sh F32"
],
"passes": true
},
{
"id": "F33",
"description": "도면 추출 도구 tools/extract_from_dxf.py → model_extracted.js (SPEC 20.1, DXF 입력)",
"steps": [
"20.1 규칙대로 작성, 표준 라이브러리만, 도면 좌표·정답 값 금지",
"python3 tools/extract_from_dxf.py private/drawing.dxf model_extracted.js",
"README.md 에 도구 설명",
"bash check.sh F33"
],
"passes": true
},
{
"id": "F34",
"description": "대조 검사·변조 시험 통과 + compare.html (SPEC 20.2)",
"steps": [
"check.sh F34 의 FAIL 을 도구 쪽에서 고친다(model.js·check.sh 는 고치지 않는다)",
"compare.html",
"bash check.sh F34"
],
"passes": true
},
{
"id": "F35",
"description": "계단 18런 추출 → 추출 모델에서도 사람 두 명이 걷는다 (SPEC 21장)",
"steps": [
"section_stairs, stair_parts (21장 규칙대로, 도면 좌표·정답 값 금지)",
"python3 tools/extract_from_dxf.py private/drawing.dxf model_extracted.js 로 다시 만들기",
"compare.html 에서 계단·사람이 보이는지(수석·코치 화면 확인)",
"README.md 도구 설명 갱신",
"bash check.sh F35"
],
"passes": true
},
{
"id": "F36",
"description": "정답지 좌표틀 정정 — 평면도 출처 부재를 슬래브 평면 틀로 (SPEC 22장)",
"steps": [
"model.js 를 22장 표대로 고친다(표에 없는 값은 건드리지 않는다)",
"plan1·plan2 에 rot_deg -30.3",
"viewer.js planXY·planPoly 에 rot_deg 회전",
"bash check.sh F36"
],
"passes": true
},
{
"id": "F37",
"description": "터널·채움 추출 → 추출 모델에도 TBM 터널 (SPEC 23장)",
"steps": [
"section_tunnel, plan_tunnel, tunnel_parts (23장 규칙대로, 도면 좌표·정답 값 금지)",
"python3 tools/extract_from_dxf.py private/drawing.dxf model_extracted.js 로 다시 만들기",
"README.md 도구 설명 갱신",
"bash check.sh F37"
],
"passes": true
},
{
"id": "F38",
"description": "분기구·환기구 추출 → 추출 모델에도 상부 구조 (SPEC 24장)",
"steps": [
"plan_center, frame_angle, section_heights, branch_parts, vent_parts (24장 규칙대로, 도면 좌표·정답 값 금지)",
"python3 tools/extract_from_dxf.py private/drawing.dxf model_extracted.js 로 다시 만들기",
"README.md 도구 설명 갱신",
"bash check.sh F38"
],
"passes": true
},
{
"id": "F39",
"description": "나머지 부재 추출 — 상부 슬래브·목·패드·하부 슬래브·바닥 콘크리트·월류벽·집수조·침사조 (SPEC 25장)",
"steps": [
"top_parts, pad_part, bottom_parts, weir_parts (25장 규칙대로, 도면 좌표·정답 값 금지)",
"python3 tools/extract_from_dxf.py private/drawing.dxf model_extracted.js 로 다시 만들기",
"README.md 도구 설명 갱신",
"bash check.sh F39"
],
"passes": true
},
{
"id": "F40",
"description": "정답지 보강 — 환기구 벽 사다리·환기탑 사다리, ladder angle_deg (SPEC 26장)",
"steps": [
"viewer.js facesLadder 에 angle_deg 회전",
"model.js 에 26장 표의 부재 2개 추가(다른 값은 건드리지 않는다)",
"index.html 에서 두 사다리가 덕트 입구 벽·환기탑 안에 보이는지(수석·코치 화면 확인)",
"bash check.sh F40"
],
"passes": true
},
{
"id": "F41",
"description": "설계 규칙 파일 tools/design_rules.json + 추출 모델이 정답지 부재 전체를 갖는다 (SPEC 27장)",
"steps": [
"tools/design_rules.json 작성(🔶 부재 6개·style)",
"extract_from_dxf.py 에 apply_design_rules, id 를 model.js 와 같게",
"python3 tools/extract_from_dxf.py private/drawing.dxf model_extracted.js",
"README.md 도구 설명 갱신",
"bash check.sh F41"
],
"passes": true
},
{
"id": "F42",
"description": "변환 패키지 — tools/dwg_to_dxf.sh, tools/select_views.py, tools/make_model.sh (SPEC 28장)",
"steps": [
"dwg_to_dxf.sh, select_views.py(28장 규칙, 좌표 숫자 금지), make_model.sh",
"bash tools/make_model.sh 로 private/ 다시 만들기(결과가 지금과 같아야 한다)",
"README.md 「변환 패키지」 절",
"bash check.sh F42"
],
"passes": true
},
{
"id": "F43",
"description": "DXF → SVG(tools/dxf_to_svg.py) + 도면 연결값을 도면에서 (SPEC 29장)",
"steps": [
"tools/dxf_to_svg.py (29.1)",
"make_model.sh 에 SVG 단계",
"추출 도구 drawing_views (29.2)",
"bash tools/make_model.sh 로 private/drawing_gen.svg·model_extracted.js 다시 만들기",
"README.md 갱신",
"bash check.sh F43"
],
"passes": true
},
{
"id": "F44",
"description": "화면 전환 — index.html = 도면에서 만든 모델, answer.html = 정답지 (SPEC 30장)",
"steps": [
"index.html 이 model_extracted.js 를 읽게",
"answer.html 새로",
"viewer.js 가 MODEL.drawing.src 로 도면을 읽게",
"README.md 갱신",
"bash check.sh F44"
],
"passes": true
},
{
"id": "F45",
"description": "README 「사용 도구」 절 (SPEC 31장)",
"steps": [
"wsl.exe -l -v 출력 확인",
"README.md 에 ## 사용 도구 절",
"bash check.sh F45"
],
"passes": true
},
{
"id": "F46",
"description": "WSL2 설치 증빙 evidence/wsl2.txt (SPEC 33장)",
"steps": [
"evidence/wsl2.txt 뒤에 「WSL 안쪽」 명령 출력 덧붙이기(33장 목록, 직접 실행)",
"README 「사용 도구」 갱신",
"bash check.sh F46"
],
"passes": true
}
]
+59
View File
@@ -0,0 +1,59 @@
# PROGRESS — 기능 1개 = 커밋 1개
한 줄 양식: `YYYY-MM-DD HH:MM | 기능 ID | 한 일 | check.sh 결과(PASS n / FAIL n) | 막힌 것`
- 2026-10-20 | KIT | 설계 키트 5파일 추가(SPEC·FEATURES·CLAUDE·check.sh·PROGRESS) | - | -
2026-10-20 13:45 | F01 | 골격: index.html(canvas 1, model.js→viewer.js) · model.js(mm, parts 빈 배열) · viewer.js(창 크기 맞춤, 배경 칠하기) | PASS 3 / FAIL 0 | -
2026-10-20 13:51 | F02 | 스파이크 1: viewer.js draw()에 고정 좌표 삼각형 하나를 path 로 채움 | PASS 4 / FAIL 0 | 수석 육안 확인 대기
2026-10-20 13:53 | F03 | 스파이크 2: viewer.js 에 facesBox·rotatePoint·project·shadeColor·sortFaces·draw, 등각 카메라, 시험 큐브 1개(한 변 2000) 를 면 음영·먼 면부터 그림 | PASS 5 / FAIL 0 | 수석 육안 확인 대기
2026-10-20 13:55 | F04 | 스파이크 3: viewer.js 에 onMouseDown·onMouseMove·onMouseUp 추가, 드래그로 yaw(좌우)·pitch(위아래, ±89도 제한) 를 바꾸고 draw 다시 호출. 등각 초기값은 기존 그대로 | PASS 6 / FAIL 0 | 수석 육안 확인 대기 (드래그 회전)
2026-10-20 13:56 | F05 | 휠 확대/축소: viewer.js 에 ZOOM(0.5~5)·onWheel 추가, 휠 한 칸 10%, draw 의 SCALE 에 ZOOM 곱함 | PASS 7 / FAIL 0 | 수석 육안 확인 대기 (휠 확대)
2026-10-20 14:24 | F06 | model.js 데이터: 벽체(ring alpha 0.25), 중간 슬래브 19장(1B~19B, 상면 z·두께 300·r 4850), 19B 패드, 하부 슬래브(20B), 바닥콘크리트 — SPEC 4.1~4.3 숫자 그대로, const MODEL = 순수 JSON | PASS 17 / FAIL 0 | 수석 확인 대기 (화면은 아직 모델 안 그림)
2026-10-20 14:25 | F07 | disc 그리기: viewer.js 에 facesDisc(32각형 뚜껑 2+옆면 32), buildFaces(disc 만), MODEL_CENTER·MODEL_R 로 처음 배율을 모델 전체에 맞춤, 시험 큐브 제거 | PASS 18 / FAIL 0 | 수석 화면 확인 대기 (모델 전체가 들어오는지)
2026-10-20 14:26 | F08 | ring 그리기: viewer.js 에 facesRing(바깥면·안쪽면·윗고리·아랫고리 각 32장), buildFaces 가 ring 도 모음, 벽 alpha 0.25 는 globalAlpha 로 적용 | PASS 19 / FAIL 0 | 수석 화면 확인 대기 (벽 너머 슬래브가 보이는지)
2026-10-20 14:27 | F09 | TBM 터널: model.js 에 tunnel 1개(pipe, 축 시작 x 4200.2·y -2425.0·z -65050, 330도, 길이 20650, r_out 1500, r_in 1300), viewer.js 에 facesPipe 추가 후 buildFaces 에 pipe 연결 | PASS 23 / FAIL 0 | 수석 확인 대기 (평면 4시 방향, 19B 높이에 붙었는지)
2026-10-20 14:29 | F10 | 계단 18런: model.js 에 stair 18개(SPEC 4.4, 단 수 17·마지막 14, dir 번갈아), viewer.js 에 facesStair 추가 후 buildFaces 에 연결, 디딤판마다 facesBox 블록 | PASS 28 / FAIL 0 | 수석 확인 대기 (층마다 계단 방향이 바뀌는지)
2026-10-20 14:31 | F11 | 터널 안 채움: model.js 에 fill 3개(잡석 -1300~-1175, 패드 2단 -1175~-1100, 패드 1단 -1100~-1050 폭 800), viewer.js 에 facesFill(원을 수평선으로 자른 단면을 축 방향으로 늘임) 추가 후 buildFaces 에 연결 | PASS 32 / FAIL 0 | 수석 화면 확인 대기 (터널 안이 19B 패드 높이까지 차는지)
2026-10-20 14:31 | F12 | README.md 작성: 구동 방법 3줄(index.html 더블클릭 · explorer.exe · Chrome/Edge), 조작법, 모델 설명(단위·구조물·데이터 파일), 검사 명령, 사용 도구(WSL2·Claude Code Haiku), wsl -l -v, 🔒 표기 | PASS 33 / FAIL 0 | 수석 확인 대기 (README 문장 검토)
2026-10-20 15:21 | F13 | 좌우 분할: index.html 에 #drawPane·#splitter·#viewPane, viewer.js 에 onSplitDown/Move/Up (폭 15%~85%), 캔버스는 #viewPane 크기에 맞춤, private/drawing.svg 없으면 안내 글자 | PASS 37 / FAIL 0 | 수석 화면 확인 대기 (막대 끌기, 3D 칸 크기 맞춰지는지)
2026-10-20 15:22 | F14 | 도면 확대·이동: index.html 에 #drawStage(img#drawing + svg#hotspots 443), viewer.js 에 fitDrawing·onDrawWheel(마우스 기준 0.5~40배)·onDrawDown/Move/Up, 처음 화면은 MODEL.drawing.fit 없으면 도면 전체 | PASS 39 / FAIL 0 | 수석 화면 확인 대기 (도면 휠·드래그, 처음 화면이 왼쪽 칸에 꽉 차는지)
2026-10-20 15:24 | F15 | 도면 클릭 연결: model.js 에 drawing 추가, sectionPoly·planPoly·buildHotspots·selectPart 로 부재별 클릭 영역, 선택 시 도면 노란 칠·3D 노란색·「선택: 이름」, 드래그 뒤 클릭 무시 | PASS 41 / FAIL 0 (F01~F15) | 수석 화면 확인 대기 (도면 영역 위치가 맞는지, 클릭·선택 해제)
2026-10-20 15:25 | F16 | 구동설명서.md 작성: 목적·기획(결정 6개)·범위·구동·조작·작업 방식·한계·이력(git log 표), 첫머리 🔒 | PASS 43 / FAIL 0 (F01~F16) | 수석 문서 검토 대기 (구동설명서 기획·한계 문장)
2026-10-20 15:26 | F17 | README.md 를 코드 설명으로 고쳐 씀: 파일 구조·실행·데이터·코드(함수 28개 한 줄씩)·그리기 방식·도면 연결·검사 (SPEC 11.2), 첫머리 🔒 | PASS 45 / FAIL 0 (F01~F17) | 수석 문서 검토 대기 (README 코드 설명 문장)
2026-10-20 15:29 | F18 | 구동설명서.md 를 _소거대기로 옮김(내용은 guide/ 8쪽에 있음), 3D 칸 「구동 설명서」 버튼 | 결과: PASS 50 / FAIL 0 / SKIP 0 (검사 범위 F01~F18) | 수석 화면 확인 대기 (guide 쪽 내용·버튼 위치)
2026-10-20 15:32 | F18 | 구동설명서 보완: guide/ 7쪽 pager 에 목차 링크 추가(목적·기획·범위·구동·조작·작업 방식·한계, 이력은 다음 자리 비움), 작업 방식·기획의 화면 확인 문장을 사실대로 정정(F02 이후 코치 localhost 미리보기, 더블클릭 구동은 수석) | PASS 50 / FAIL 0 (F01~F18) | 없음
2026-10-20 19:07 | F19 | tools/prep_drawing.py 작성, 원본으로 private/drawing.svg 재생성(1px 선, 커밋 안 함), viewer.js 의 applyDraw 를 applyDrawView 로 바꿔 width/height 확대 + translate 이동, README 함수 한 줄 추가 | PASS 54 / FAIL 0 (F01~F19, F20 SKIP) | 수석 화면 확인 대기 (도면 선이 확대해도 얇은지)
2026-10-20 19:11 | F20 | MODEL.drawing.views 11개(SPEC 13.2 표), viewHotspots·circlePoly·region·pathD 추가, 영역을 path 로 바꾸고 evenodd 로 벽 고리 구멍, selectPart 가 id 배열을 받음, 3D SELECTED 배열화, README 함수 추가 | PASS 56 / FAIL 0 (F01~F20) | 수석 화면 확인 대기 (그림 11장 클릭, 여러 부재 한 번에 선택, 「선택:」 글자)
2026-10-20 19:13 | F20 | 보완: 「선택:」 글자를 한 줄(nowrap)·오른쪽 120px 여백으로, 여럿일 때 「처음 둘, … 마지막」(SPEC 13.2) | PASS 56 / FAIL 0 (F01~F20) | 수석 화면 확인 대기 (홀수층 슬래브 선택 시 글자가 설명서 버튼과 안 겹치는지)
2026-10-20 19:15 | DOC | guide/ 9쪽을 사용 설명서로 다시 씀(index·purpose·run·control·plan·scope·limits·method·history, 수석 지시로 코치 세션 작성) | 결과: 문서만 변경, check.sh 안 돌림 | 수석 화면 확인 대기 (guide 9쪽)
2026-10-20 20:57 | F21 | model.js: 슬래브 19장·패드에 holes(SPEC 15.1 표), 계단 18런 x_top·dir·Y 띠 정정(15.2); viewer.js: facesDiscHoles 추가, buildFaces 가 disc 에 사용, draw 가 holes 를 evenodd 로 채움; README 함수 한 줄 | PASS 59 / FAIL 0 (F01~F21, F22·F23 SKIP) | 수석 화면 확인 대기 (슬래브 구멍 위치, 계단 입구 방향)
2026-10-20 20:59 | F22 | viewer.js: CAM.target 기준 project, panView(오른쪽 드래그·Shift+왼쪽), zoomAt(마우스 아래 점 고정, 0.5~40), resetView(더블클릭·처음 화면 버튼), contextmenu 막기, unrotateVector·scaleFor 추가; index.html 에 button#resetView; README 함수 추가 | PASS 62 / FAIL 0 (F01~F22, F23 SKIP) | 수석 화면 확인 대기 (오른쪽 드래그 이동, 휠 확대 40배, 더블클릭 복귀). guide/control.html 의 휠 0.5~5 줄은 아직 옛 값
2026-10-20 21:01 | F23 | 사람 애니메이션: buildWalkPath(계단 18런 내려갔다 되짚어 올라오기, 1000mm/s), walkerAt, facesPerson(상자 4개, 진행 방향에 맞춰 돌림, #ff8c1a), animate(rAF, 초당 30번 이하 다시 그림), setWalking; index.html 에 button#walkToggle; README 코드 절에 5개 함수 | PASS 65 / FAIL 0 (F01~F23) | 수석 화면 확인 대기 (사람이 계단을 오르내리는지, ▶/❚❚ 버튼, 다리 간격 100 해석 확인)
2026-10-20 21:05 | DOC | guide/ 7쪽(purpose·control·plan·scope·limits·method·history)을 F21~F23 에 맞춰 고침: 슬래브 구멍, 카메라 이동(오른쪽 드래그·Shift+드래그·휠 0.5~40·더블클릭), 사람 애니메이션(▶/❚❚) 반영. 수석 지시로 코치 세션 작성 | 결과: 문서만 변경, check.sh 안 돌림 | 수석 화면 확인 대기 (guide 7쪽 갱신분)
2026-10-20 21:12 | F24 | model.js: 월류벽 3·집수조·침사조 zone 2·사다리 1, 19B 슬래브·패드 침사구 구멍 (SPEC 16.1); viewer.js: facesPrism, facesZone, facesLadder 추가, buildFaces 가 prism·zone·ladder 를 받음; README 데이터 표·코드 절 추가 | PASS 70 / FAIL 0 (F01~F24, F25 SKIP) | 수석 화면 확인 대기 (월류벽 홈, 집수조·침사조 반투명 색, 사다리 위치·가로대, 19B 침사구 구멍). 사다리 폭 400·가로대 300 은 도면에 없는 코치 값
2026-10-20 21:15 | F25 | viewer.js: xSpan·planXY·bottomPlanPoly·bottomHotspots 추가, sectionPoly 가 xSpan 사용, buildHotspots 가 bottomHotspots 를 viewHotspots 뒤에 이음 (순서 집수조→침사조→월류벽→사다리); README 코드 절에 새 함수 4개 | PASS 71 / FAIL 0 (F01~F25) | 수석 화면 확인 대기 (단면 A-A·20B 평면에서 집수조·침사조 활꼴, 월류벽 홈, 사다리 클릭, 19B 평면 침사구 클릭 → 사다리 선택)
2026-10-20 21:19 | F26 | model.js: 벽 z1 −2000, topslab(disc r 5500, 원 구멍 2), neck 2(prism, 맨홀 목·원 구멍), elec 19개 슬래브 다각형으로 정정 / viewer.js: holePolygon(원→16각형), addHoles(facesDiscHoles 에서 분리), facesPrism 에 holes; README 데이터·코드 절 갱신 | PASS 74 / FAIL 0 / SKIP 8 (F01~F26) | 수석 화면 확인 대기 (상부 슬래브 뚜껑 원 구멍 2개, 맨홀 목 2개, 배선구 평행사변형). facesPrism 바깥 면 법선 부호가 반대로 보임(코드 읽기로만 확인, 화면 미확인) — F24 월류벽도 같은 함수
2026-10-20 21:20 | F27 | model.js: 분기구 obox 5, 환기 덕트·환기탑 obox 13 (SPEC 17.2 표 그대로, angle 167/0, cx=cy=0) / viewer.js: facesObox(facesBox 를 (s,t,z) 로 만들고 angle_deg 만큼 돌려 cx,cy 에 놓음, 법선도 돈다), buildFaces 에 obox 분기; README 데이터·코드 절 갱신(부재 72개) | PASS 77 / FAIL 0 / SKIP 5 (F01~F27, F28·F29 SKIP) | 수석 화면 확인 대기 (분기구가 167도 방향 옆 굴, 덕트가 남쪽으로 내려가 동쪽으로 꺾여 환기탑 연결, 환기탑 높이 지표 0)
2026-10-20 21:22 | F28 | model.js: 방호 난간 18장(railing, z·h 1100·paths), 펌프 배출관(pipe ring, 0,4220), 위쪽 사다리 2개(ladder_up) 추가 (SPEC 17.3) / viewer.js: facesRailing(꺾은선 구간마다 두께 40 판 6장) 추가, buildFaces 에 railing 분기 / README 데이터 표·부재 수 93개·코드 절 갱신 | PASS 81 / FAIL 0 / SKIP 1 (F01~F28, F29 범위 밖) | 수석 화면 확인 대기 (1B~17B 윗면 난간 높이 1100, 19B 패드 위 난간, 배출관 집수조→상부 슬래브 밑, 서쪽·가운데 사다리). 판 두께 40 과 난간 색 #d8c890 은 도면에 없는 코치 값
2026-10-20 21:24 | F29 | viewer.js: obox4 추가, xSpan 이 neck·obox 를 다룸, upperHotspots 추가(단면 A-A 사각형·평면도 원판 r 4850 → 맨홀 목 → 상자 바닥 윤곽), buildHotspots 순서 상부→벽·터널→바닥부 / README 함수 절 갱신 | 결과: PASS 82 / FAIL 0 / SKIP 0 (검사 범위 F01~F29) | 수석 화면 확인 대기 (단면 A-A 상부 슬래브·목·분기구·환기구·위쪽 사다리 클릭, 평면도 1·2 에서 원판·목·상자 클릭 후 벽 고리 클릭). 원판 평면 r 4850 은 SPEC 17.4 값(상부 슬래브 r 5500 과 다름), 코치 값 아님
2026-10-20 21:26 | F29 | viewer.js 보완: 분기구·환기구는 같은 kind 덩어리 전체 선택(upperHotspots 의 group, KIND_LABEL), 선택 문구를 kind 이름으로 (「선택: 평면도 — 분기구」「… — 환기구(강제)」), 목·사다리는 하나씩 | PASS 82 / FAIL 0 / SKIP 0 (F01~F29) | 수석 화면 확인 대기 (단면 A-A·평면도 1·2 에서 분기구·환기구 누르면 5개·13개 전체가 노랗게 되는지)
2026-10-20 21:28 | DOC | guide/ 사용 설명서 6쪽(purpose·plan·scope·limits·method·history) F24~F29 에 맞춰 갱신 (코치 세션 작성, 수석 사전 승인, SPEC 14장) / viewer.js·model.js 변경 없음 | PASS 82 / FAIL 0 / SKIP 0 (F01~F29) | 수석 확인 대기 (guide/ 6쪽 본문 검토)
2026-10-21 10:00 | F30 | model.js 변경 없음 / viewer.js: teamAt(t) 추가(첫 사람 t, 두 번째 t−TEAM_LAG 2000ms), facesPerson 에 색 인자(WALK_COLORS #ff8c1a, #e07010), draw 에서 두 사람 그림, walkerAt 음수 시각 접음 / README 사람 애니메이션 절 갱신 | PASS 82 / FAIL 0 / SKIP 4 (F01~F30 검사, F31·F32 범위 밖) | 수석 화면 확인 대기 (사람 두 명이 2초 간격으로 같은 길을 가는지, 시작 직후 두 번째 사람이 경로 끝에서 튀지 않는지)
2026-10-21 10:02 | F31 | viewer.js: stairProfile 추가(18.2 점 순서, 디딤판 300·챌판 200, s_b=(z_high−z_low−400)×1.5), facesStair 를 단면 Y 띠 압출(옆면 2 + 둘레 변마다 사각형)로 교체, 블록 방식 삭제 / README 함수 절 갱신(stairProfile 추가, facesBox·facesStair 설명 고침) | PASS 83 / FAIL 0 / SKIP 0 (F01~F31) | 수석 화면 확인 대기 (계단 18런이 경사판 밑면을 가진 콘크리트 계단으로 보이는지, 계단 아래 빈 공간·사람 경로와 어긋나지 않는지). 첫 점(0,z_high)→(0,z_high−200) 과 닫는 변이 s=0 에서 겹치는 것은 SPEC 18.2 점 순서 그대로 둔 것
2026-10-21 10:02 | F32 | guide/ 를 목차·목적·구동·조작 3쪽으로 정리: 코치 작성 index·purpose·run·control 그대로 커밋, plan·scope·limits·method·history git rm / README·index.html 변경 없음 (SPEC 18.3) | 결과: PASS 86 / FAIL 0 / SKIP 0 (검사 범위 F01~F32) | 수석 화면 확인 대기 (guide/index.html 목차 → 목적·구동·조작 3쪽 링크, 🔒 줄 없음)
2026-10-21 14:49 | F33 | tools/extract_from_dxf.py 추가: DXF 에서 벽·지표·슬래브 윗면 19·평면 구멍·난간 추출 (SPEC 20.1, 도면 좌표 코드에 없음) / model_extracted.js 생성 / README 파일 구조 두 줄 | PASS 89 / FAIL 0 / SKIP 9 (검사 범위 F01~F33) | 수석 확인 대기 (대조는 F34). 19B 침사구 구멍 1개는 20.1 규칙(오프닝·구조물 계단 레이어)에 안 잡힘 — F34 전에 판단 필요. 벽 z 는 19.1 고정값 -70200·-2000 사용
2026-10-21 14:50 | F34 | 대조 검사: 도구 쪽 검사 통과(model.js 변경 없음) / compare.html 추가(model_extracted.js 읽음, 「추출 데이터로 그린 화면」 글자) / viewer.js: MODEL.drawing 없을 때 hotspots·도면 로드 건너뜀 | PASS 98 / FAIL 0 / SKIP 0 (F01~F34) | 수석 화면 확인 대기 (compare.html 3D 칸이 index.html 과 같은 모양인지, 왼쪽 안내 글자)
2026-10-21 14:53 | F34 | 보완: viewer.js 계단 없으면 사람·버튼 숨김(HAS_WALK), MODEL.drawing 없으면 클릭 영역 안 만듦 / model.js·check.sh·SPEC.md 무변경 | PASS 98 / FAIL 0 / SKIP 0 (F01~F34) | index.html 의 <path> NaN 4개는 HEAD 에도 있던 것(수석 판단 대기, 이번엔 손대지 않음)
2026-10-21 14:55 | F29 | 보완: viewer.js xSpan 이 사다리를 kind 로 찾아 위쪽 사다리(kind ladder_up)가 NaN 이 되던 것을 type 으로 고침 (단면 A-A·A-A 생략 그림의 ladder_up_W·ladder_up_C 영역 4개, x0~x1) / FEATURES.json·check.sh·SPEC.md 무변경 | PASS 81 / FAIL 0 / SKIP 17 (검사 범위 F01~F29) | 수석 화면 확인 대기 (F12 콘솔 path NaN 오류 사라졌는지)
2026-10-21 19:32 | F35 | tools/extract_from_dxf.py: 계단 18런 추출 section_stairs·stair_profile·stair_dir·stair_parts (SPEC 21장, 도면 좌표·정답 값 코드에 없음) / model_extracted.js 다시 생성 / README 도구 설명 갱신 | 결과: PASS 100 / FAIL 0 / SKIP 0 (검사 범위 F01~F35) | 수석 확인 대기 (compare.html 에 계단·사람 두 명이 보이는지)
2026-10-21 20:05 | F36 | 정답지 좌표틀 정정 (SPEC 22장): model.js 표대로 터널·잡석·패드 0°·(4850,0), 상부슬래브 구멍·맨홀 목 2개 축 정렬, 분기구 197.3°, 환기구 30.3°, 위쪽 사다리 W 값 / 평면도 1·2 rot_deg -30.3 / viewer.js planXY·planPoly 회전 | 결과: PASS 103 / FAIL 0 / SKIP 0 (검사 범위 F01~F36) | 수석 화면 확인 대기 (compare·index 평면도 클릭 영역이 도면 그림 위에 놓이는지, 터널이 계단과 나란히 +X 로 가는지)
2026-10-21 20:11 | F37 | 터널 추출 section_tunnel·plan_tunnel·tunnel_parts (SPEC 23장, 도면 좌표·정답 값 코드에 없음) / inner_r 추가 / model_extracted.js 다시 생성 / README 도구 설명 갱신 | 결과: PASS 108 / FAIL 0 / SKIP 0 (검사 범위 F01~F37) | 수석 확인 대기 (compare.html 에 터널·잡석·패드가 계단과 나란히 보이는지)
2026-10-21 20:24 | F38 | 분기구·환기구 추출 (SPEC 24장): plan_center·frame_angle(φ 30.3 류, 도면에서 구함)·section_heights(A-A 좌측 X, B-B 우측 X)·branch_parts·vent_parts / model_extracted.js 다시 생성 / README 도구 설명 갱신 | 결과: PASS 113 / FAIL 0 / SKIP 0 (검사 범위 F01~F38) | 수석 화면 확인 대기 (compare.html 에 분기구·환기구·환기탑이 계단·터널과 맞물려 보이는지)
2026-10-21 20:37 | F39 | 나머지 부재 추출: top_slab_part·neck_parts(상부 슬래브·맨홀 목 2)·pad_and_sump(19B 패드·침사구)·bottom/lean(하부 슬래브·바닥 콘크리트)·weir_parts·pit_parts(월류벽 3·집수조/침사조) / 벽 높이를 도면에서 읽도록 고정값 제거 / 20B 평면 제목 추가 / model_extracted.js 다시 생성 / README 도구 설명 갱신 | 결과: PASS 124 / FAIL 0 / SKIP 0 (검사 범위 F01~F39) | 수석 화면 확인 대기 (compare.html 에 상부 슬래브·목·패드·월류벽·집수조가 맞는지)
2026-10-21 20:39 | F40 | 정답지 보강 (SPEC 26장): viewer.js facesLadder 에 angle_deg 회전(원점 기준, 법선 포함) / model.js 에 환기구 사다리 2개(ladder_vent_wall·ladder_vent_tower, kind ladder_vent) / .gitignore 에 __pycache__/ | 결과: PASS 126 / FAIL 0 / SKIP 0 (검사 범위 F01~F40) | 수석 화면 확인 대기 (index.html 에서 환기구 벽 사다리가 덕트 입구 벽에, 환기탑 사다리가 탑 안에 보이는지)
2026-10-21 20:42 | F41 | 설계 규칙 파일 tools/design_rules.json (🔶 부재 6개·style·ids) / extract_from_dxf.py 에 load_design_rules·apply_design_rules (부재 뒤에 🔶 붙이고 color·alpha 덮어씀) / model_extracted.js 다시 생성(95개) / README 도구 설명 갱신 | 결과: PASS 132 / FAIL 0 / SKIP 0 (검사 범위 F01~F41) | 수석 화면 확인 대기 (compare.html 에 사다리·배출관이 색까지 model.js 와 같게 보이는지)
2026-10-21 20:49 | F42 | 변환 패키지: tools/dwg_to_dxf.sh(dwg2dxf 래퍼)·tools/select_views.py(views.json 상자 안 객체만, 좌표 숫자 없음)·tools/make_model.sh(DWG→full.dxf→drawing.dxf→model_extracted.js, private/ 출력 시 루트 model_extracted.js 갱신) / README 「변환 패키지」 절 / 객체 3356개 확인 | 결과: PASS 138 / FAIL 0 / SKIP 0 (검사 범위 F01~F42) | 수석 확인 대기 (README 변환 패키지 절, make_model.sh 실행 결과)
2026-10-21 21:01 | F43 | DXF→SVG 변환 도구 tools/dxf_to_svg.py (SPEC 29.1) / 추출 도구 drawing_views 로 도면 연결값을 도면에서 계산 (SPEC 29.2, views.json 세 번째 인수) / make_model.sh 에 SVG 단계 / drawing_gen.svg·model_extracted.js 다시 생성 / README 갱신 | 결과: PASS 144 / FAIL 0 / SKIP 0 (검사 범위 F01~F43) | 수석 화면 확인 대기 (index.html 왼쪽 도면이 drawing_gen.svg 로 원본과 같은지, README 변환 패키지 절)
2026-10-21 21:04 | F44 | 화면 전환: index.html 이 model_extracted.js 를 읽음 / answer.html 신설(정답지, model.js) / viewer.js 가 MODEL.drawing.src 로 도면을 읽고 없으면 그 경로 안내 / README 첫 화면·정답지·다시 만들기 갱신 | 결과: PASS 150 / FAIL 0 / SKIP 0 (검사 범위 F01~F44) | 수석 화면 확인 대기 (index.html 이 도면에서 만든 모델인지, answer.html 이 예전 정답지인지)
2026-10-21 21:08 | F45 | README 「사용 도구」 절 복원 (SPEC 31장): wsl -l -v 출력은 8f606f5 에 기록된 값을 그대로 옮김 (WSL 안에서 wsl.exe 호출 불가) / Haiku 5.5·LibreDWG·git·브라우저 / 커밋 메시지 Co-Authored-By 줄 | 결과: PASS 151 / FAIL 0 / SKIP 0 (검사 범위 F01~F45) | 수석 확인 대기 (README 사용 도구 절의 wsl 출력이 지금 Windows 값과 같은지)
2026-10-22 09:23 | F46 | WSL2 설치 증빙: evidence/wsl2.txt 에 WSL 안쪽 출력(uname·/proc/version·Ubuntu 26.04·claude 2.1.304·git·python3·dwg2dxf) 덧붙임 / README 사용 도구에 설치일 2026-07-09 와 증빙 파일 연결 | 결과: PASS 154 / FAIL 0 / SKIP 0 (검사 범위 F01~F46) | 수석 확인 대기 (evidence/wsl2.txt 의 Windows 쪽 설치일·WSL 안쪽 출력이 맞는지)
+184
View File
@@ -0,0 +1,184 @@
# drawing3d-web — 코드 설명
이 저장소는 1차 과제 제출본을 사내 공개용으로 정리한 것입니다 (도면 실제 좌표·과업명 제거, 좌표 평행이동, 화면·검사 결과는 같음, SPEC 34장).
1차의 커밋 81개 기록은 `evidence/gitlog_v1.txt` 에 있습니다.
> 🔒 사내교육용 — 열람 외 재배포 등의 활동을 금함 (회사 도면의 치수를 옮겨 적음)
원형 수직구 일반도를 라이브러리 없이 Canvas 2D 로 그리는 3D 뷰어. 설계 근거는 `SPEC.md`, 구동 설명은 `guide/index.html` (HTML 위키).
## 파일 구조
| 파일 | 내용 |
|---|---|
| `index.html` | 첫 화면(도면에서 만든 모델). 화면 배치(도면 칸 · 가운데 막대 · 3D 칸)와 스크립트 읽기 순서: `model_extracted.js` 다음에 `viewer.js` |
| `answer.html` | 정답지 화면. `index.html` 과 같은 화면이되 `model.js` 를 읽는다. 손으로 만든 정답 모델이고, 도면에서 만든 모델(`index.html`)을 검사하는 기준이다 |
| `model.js` | 데이터. `const MODEL = {...};` 한 줄짜리 순수 JSON |
| `viewer.js` | 그리기·입력·도면 연결 코드 전부 |
| `private/drawing.svg` | 도면 원본. `.gitignore` 로 커밋 제외 |
| `check.sh` | 합격 판정 (수정 금지) |
| `tools/extract_from_dxf.py` | 도면 DXF(`private/drawing.dxf`)에서 데이터를 뽑는 도구. 벽·슬래브·구멍·난간에 더해 계단 18런(`section_stairs`, `stair_parts`)과 TBM 터널·채움 3개(`section_tunnel`, `plan_tunnel`, `tunnel_parts`)도 뽑는다. 분기구·환기구(덕트·환기탑)는 평면도 중심과 φ(`plan_center`, `frame_angle`), 단면 높이(`section_heights`), 상자 묶음(`branch_parts`, `vent_parts`)으로 뽑는다. 나머지 부재(상부 슬래브·맨홀 목·19B 패드·침사구·하부 슬래브·바닥 콘크리트·월류벽·집수조·침사조)는 `top_slab_part`, `neck_parts`, `pad_and_sump`, `weir_parts`, `pit_parts` 로 뽑는다. 나머지 부재 뒤에 설계 규칙 파일(`tools/design_rules.json`, `load_design_rules`·`apply_design_rules`)의 🔶 부재 6개(사다리 3·환기구 사다리 2·배출관)를 붙이고, 모든 부재의 색·투명도를 kind → id 순으로 덮어쓴다. 표준 라이브러리만 쓴다. `python3 tools/extract_from_dxf.py private/drawing.dxf model_extracted.js` |
| `model_extracted.js` | 위 도구가 만든 데이터(`model.js` 와 같은 꼴). 값은 도면에서 읽은 것이라 `model.js` 와 대조해 본다 |
| `SPEC.md`, `FEATURES.json`, `PROGRESS.md` | 설계, 기능 목록, 진행 기록 |
## 실행
- `index.html` 이 첫 화면이다. 도면에서 만든 모델(`model_extracted.js`)을 보여 준다. `answer.html` 은 손으로 만든 정답지 화면이다.
- `index.html` 을 더블클릭해서 Chrome 또는 Edge 로 연다. 서버·인터넷은 필요 없다.
- WSL 에서 열려면 이 폴더에서 `explorer.exe index.html` 을 실행한다.
- 다시 만들기: `bash tools/make_model.sh` (DWG 를 읽어 `model_extracted.js` 와 `private/drawing_gen.svg` 를 만든다).
- 도면은 모델의 `drawing.src` 경로에서 읽는다(첫 화면은 `private/drawing_gen.svg`, 정답지는 `private/drawing.svg`). 없으면 왼쪽 칸에 그 경로를 보이는 안내 글자가 나온다.
## 데이터 (`model.js`)
- 단위는 mm, 각도는 도. 원점은 수직구 축 중심 × 지표면, Z 는 위로 +.
- 최상위: `units`("mm"), `parts`(배열), `drawing`(도면 연결, 아래 「도면 연결」 참고).
- 모든 부재 공통 필드: `id`, `name`, `kind`, `type`, `color`("#rrggbb"), `alpha`(없으면 1).
- `kind`: `wall`, `topslab`, `neck`, `slab`, `pad`, `bottom`, `lean`, `stair`, `tunnel`, `fill`, `weir`, `pit`, `ladder`, `branch`, `vent`, `railing`, `ladder_up`
| type | 쓰는 kind | 필드 |
|---|---|---|
| `ring` (세운 속 빈 원통) | wall, pipe(펌프 배출관) | `cx, cy, z0, z1, r_out, r_in` |
| `disc` (세운 원판) | topslab, slab, pad, bottom, lean | `cx, cy, z0, z1, r`, `holes`(선택) |
| `pipe` (누운 속 빈 원통) | tunnel | `x, y, z`(축 시작점), `angle_deg, length, r_out, r_in` |
| `stair` (계단 런) | stair | `z_low, z_high, risers, first_riser, riser, tread, x_top, dir(+1/−1), y0, y1` |
| `fill` (누운 원통 속 채움) | fill | `x, y, z, angle_deg, length`(pipe 와 같은 축), `r, v0, v1, half_width`(0 이면 폭 제한 없음) |
| `prism` (다각형 기둥) | weir, neck | `polygon`([x, y] 점 목록), `z0, z1`, `holes`(선택) |
| `zone` (원 안 활꼴 기둥) | pit | `r, x_min, x_max, z0, z1`(alpha 0.25) |
| `ladder` (사다리) | ladder, ladder_up | `x0, x1, yc, width, z0, z1, rung`(가로대 간격) |
| `obox` (돌린 직육면체) | branch, vent | `cx, cy, angle_deg, s0, s1, t0, t1, z0, z1`(s·t 는 돌린 축 방향) |
| `railing` (방호 난간) | railing | `z`(설치 슬래브 윗면), `h`(높이), `paths`(꺾은선 목록, 각 [x, y] 점) |
- `holes` 목록의 한 원소는 [x, y] 점 목록(다각형)이나 `{"circle": [x, y, r]}`(16각형으로 그림)이다.
- 부재 93개: wall 1 · topslab 1 · neck 2 · slab 19 · pad 1 · bottom 1 · lean 1 · stair 18 · tunnel 1 · fill 3 · weir 3 · pit 2 · ladder 1 · branch 5 · vent 13 · railing 18 · pipe 1 · ladder_up 2.
- `drawing`: `src`(도면 파일), `viewBox`([0, 0, 443, 443]), `fit`([u0, v0, u1, v1], 처음 화면에 꽉 찰 도면 상자), `section`(`x0, y0, mm_per_unit`), `plan`(`cx, cy, mm_per_unit`), `views`(그림 11장: `id, type, label` 과 그림마다 기준 값, 아래 「도면 연결」).
## 코드 (`viewer.js`)
함수 이름은 SPEC 7장과 10장의 목록을 그대로 쓴다.
**부재 → 면** (각 면은 점 배열 · 색 · alpha)
- `facesBox`: 직육면체 6면을 만든다. 분기구·환기구 등 상자 부재가 쓴다.
- `facesDisc`: 세운 원판(32각형)을 뚜껑 2장과 옆면 32장으로 만든다.
- `holePolygon`: 구멍 하나를 점 목록으로 바꾼다. `{"circle": [x, y, r]}` 는 16각형이 된다.
- `addHoles`: 뚜껑 2장(윗면·아랫면)에 구멍 고리를 넣고(evenodd), 구멍 둘레마다 세로 옆면을 더한다. `facesDiscHoles`·`facesPrism` 이 함께 쓴다.
- `facesDiscHoles`: `facesDisc` 에 구멍(`holes`)을 얹는다. 구멍 점을 `cx, cy` 만큼 옮긴 뒤 `addHoles` 를 부른다.
- `facesRing`: 세운 속 빈 원통(32각형)을 바깥면·안쪽면·윗고리·아랫고리로 만든다.
- `facesPipe`: 누운 속 빈 원통(32각형)을 축 시작점에서 `angle_deg` 방향으로 만든다.
- `facesFill`: 원을 수평선 `v0`·`v1` 로 자른 단면을 터널 축 방향으로 늘인다. `half_width` 가 있으면 그 폭 안으로만.
- `facesPrism`: 다각형을 `z0`~`z1` 로 세운 기둥(뚜껑 2장과 변마다 옆면)을 만든다. `holes` 가 있으면 `addHoles` 로 구멍을 뚫는다. 월류벽·맨홀 목이 쓴다.
- `facesZone`: 원 안에서 `x_min`~`x_max` 만 남긴 활꼴 기둥을 `facesPrism` 으로 만든다. 집수조·침사조가 쓴다.
- `facesLadder`: 사다리 기둥 2개와 가로대(`rung` 간격, 두께 25)를 상자로 만든다.
- `facesObox`: 돌린 직육면체. `facesBox` 로 (s, t, z) 상자를 만든 뒤 `angle_deg` 만큼 돌리고 (`cx, cy`) 에 놓는다. 분기구·환기구 덕트·환기탑이 쓴다.
- `facesRailing`: 방호 난간. 꺾은선 구간마다 두께 40 의 얇은 판을 `z` 부터 `z + h` 까지 세운다(옆 2·끝 2·윗면·아랫면, 판당 6장).
- `stairProfile`: 계단 한 런의 단면(경사판·단·밑면)을 (s, z) 점 목록으로 만든다. s 는 `x_top` 에서 내려가는 방향 거리다.
- `facesStair`: `stairProfile` 의 단면을 Y 띠(`y0`~`y1`) 폭만큼 늘인다. 옆면 2개와 둘레 변마다 사각형 하나.
- `buildFaces`: 모든 부재의 면을 한 줄로 모은다.
**카메라와 투영**
- `rotatePoint`: 세계 좌표를 yaw(Z축 둘레)와 pitch(기울기)로 돌린다.
- `project`: 돌린 점을 화면 기준 좌표 [오른쪽, 위, 깊이]로 바꾸고, 캔버스 픽셀로 옮긴다.
**그리기**
- `shadeColor`: `#rrggbb` 색을 밝기 계수만큼 밝게 또는 어둡게 한다.
- `sortFaces`: 면 가운데 점의 깊이가 큰 것부터 정렬한다(먼 면부터).
- `draw`: 캔버스를 지우고 면을 정렬해 차례로 칠한다. 선택된 부재는 노란색으로 그린다.
**입력 (3D 칸)**
- `onMouseDown`: 드래그 시작 위치를 기억하고, 오른쪽 버튼 또는 Shift 가 눌렸으면 화면 이동으로 정한다.
- `onMouseMove`: 드래그 중 회전이면 yaw·pitch 를, 화면 이동이면 `panView` 를 부르고 `draw` 를 다시 부른다.
- `onMouseUp`: 드래그를 끝낸다.
- `panView`: 드래그한 픽셀만큼 보는 중심(`CAM.target`)을 화면 평면에서 옮긴다.
- `unrotateVector`: 화면 기준 벡터를 세계 좌표로 되돌린다(`rotatePoint` 의 역).
- `scaleFor`: 배율 값에 맞는 mm 당 픽셀 수를 돌려준다.
- `zoomAt`: 마우스 아래 점이 그대로 머물게 배율을 바꾸고 보는 중심을 옮긴다(0.5~40 사이).
- `resetView`: 등각 화면·배율 1·보는 중심 = 모델 중심으로 되돌린다(더블클릭, 「처음 화면」 버튼).
- `onWheel`: 휠로 `zoomAt` 을 부른다.
**화면 나누기**
- `onSplitDown`: 가운데 막대 끌기를 시작한다.
- `onSplitMove`: 왼쪽 칸 폭을 창 폭의 15%~85% 로 바꾸고 3D 캔버스를 다시 맞춘다.
- `onSplitUp`: 막대 끌기를 끝낸다.
**사람 애니메이션**
- `buildWalkPath`: 계단 18런의 데이터로 내려가는 길을 만들고, 같은 길을 되짚어 올라오는 한 바퀴 경로(구간마다 길이·진행 방향)를 만든다.
- `walkerAt`: 시각(ms)에 맞는 사람의 발 위치와 진행 방향을 돌려준다. 경로 끝에 오면 처음으로 돌아간다.
- `teamAt`: 두 사람의 위치를 돌려준다. 두 번째 사람은 첫 사람보다 2초 뒤 시각(`TEAM_LAG`)으로 `walkerAt` 을 부른다.
- `facesPerson`: 사람 한 명(다리 2·몸통·머리 상자 4개)을 진행 방향에 맞춰 돌린 면들로 만든다. 색은 인자로 받는다(`WALK_COLORS`: #ff8c1a, #e07010).
- `animate`: 움직이는 동안 `requestAnimationFrame` 으로 시간을 더하고 초당 30번 이하로 `draw` 를 부른다.
- `setWalking`: 움직임을 켜거나 끄고, 「사람 ❚❚」·「사람 ▶」 글자를 바꾼다.
**도면 칸**
- `applyDrawView`: 배율을 `#drawing` 과 `#hotspots` 의 실제 width/height 로 넣고(벡터를 다시 그린다), 위치만 translate 로 옮긴다.
- `fitDrawing`: 처음 화면에서 `drawing.fit` 상자가 왼쪽 칸에 꽉 차게 배율과 위치를 정한다.
- `onDrawWheel`: 마우스 아래의 도면 점이 그대로 머물도록 확대·축소한다(0.5~40배).
- `onDrawDown`: 도면 드래그(이동)를 시작한다.
- `onDrawMove`: 드래그 중 도면을 옮긴다.
- `onDrawUp`: 드래그를 끝낸다. 움직였으면 다음 클릭은 선택으로 치지 않는다.
**도면 ↔ 부재**
- `region`: 클릭 영역 하나(부재 id 들과 다각형들)를 만든다. 다각형이 없으면 영역도 없다.
- `pathD`: 다각형 점 배열을 SVG `path` 의 `d` 글자로 바꾼다.
- `sectionPoly`: 단면 그림 한 장에서 부재 하나의 클릭 영역을 만든다. `section_break` 그림은 생략 구간을 뺀다(벽은 위·아래 두 조각).
- `circlePoly`: 평면 그림의 원(32각형)을 만든다.
- `planPoly`: 평면 그림에서 벽(고리, 가운데가 빈다)과 터널(사각형)의 클릭 영역을 만든다.
- `viewHotspots`: 그림 한 장(`MODEL.drawing.views` 의 한 항목)의 클릭 영역 목록을 만든다. 단면·평면·중간슬래브 그림 세 가지를 나눠 처리한다.
- `obox4`: 상자(obox) 바닥 윤곽 네 꼭짓점(mm)을 돌려준다. `facesObox` 와 같은 회전을 쓴다.
- `xSpan`: 부재가 덮는 X 범위(mm)를 돌려준다. 계단·집수조·침사조·사다리·월류벽·맨홀 목·상자마다 다르다.
- `planXY`: 평면 그림의 점 하나(X, Y mm)를 도면 좌표로 바꾼다.
- `bottomPlanPoly`: 하부 슬래브 평면에서 집수조·침사조(원을 X 범위로 자른 활꼴), 월류벽, 사다리의 클릭 영역을 만든다.
- `bottomHotspots`: 바닥부 클릭 영역을 만든다. 단면 A-A 두 그림, 하부 슬래브 평면, 19B 평면의 침사구(사다리 선택)를 처리한다.
- `upperHotspots`: 상부 부재의 클릭 영역을 만든다. 단면 A-A 두 그림은 상부 슬래브·맨홀 목·분기구·환기구·위쪽 사다리를 X·z 사각형으로, 평면도 1·2 는 원판(r 4850)·맨홀 목·분기구와 환기구 바닥 윤곽을 처리한다.
- `buildHotspots`: 11개 그림의 클릭 영역을 `svg#hotspots` 에 `path` 로 그린다. 같은 그림 안에서는 뒤에 나온 영역이 위에 온다. `upperHotspots` → `viewHotspots` → `bottomHotspots` 순으로 이어 붙인다.
- `selectPart`: 부재 id 배열을 고른다. 겹치는 도면 영역을 칠하고, 3D 에서 그 부재들을 노란색으로 그리고, 「선택: 그림 이름 — 부재 이름」을 쓴다. 빈 배열이면 해제한다.
## 그리기 방식
- 투영은 정사 투영이다. 처음 화면은 등각(좌우 45도, 기울기 35도).
- 드래그 = 궤도 회전: 좌우는 Z축 둘레, 위아래는 기울기(89도에서 멈춤).
- 면 음영: 법선이 빛 방향에 가까울수록 밝다(계수 0.4~1.0).
- 화가 알고리즘: 면마다 가운데 점의 깊이를 재서 먼 면부터 칠한다. 겹침은 이 순서로 해결한다.
- 벽은 `alpha` 0.25 로 반투명, 나머지는 불투명. 원은 32각형으로 근사한다.
## 도면 연결
도면은 SVG 를 `<img>` 로 띄우고, 같은 크기의 `<svg id="hotspots">` 를 겹친다. 좌표는 mm 를 도면 단위로 바꿔 쓴다. `u` 는 도면 가로, `v` 는 도면 세로(아래로 +).
- 단면 A-A: `u = x0 + X / 1000`, `v = y0 − z / 1000`
- 평면도: `u = cx + x / 1000`, `v = cy − y / 1000`
`section` 과 `plan` 의 값은 도면 선을 재서 얻었다. 값은 `model.js` 의 `drawing` 에 있다.
## 변환 패키지 (DWG → DXF → 모델)
설계도면은 DWG 로 주고받는다. DWG 는 바이너리라 GNU LibreDWG 로 DXF(글자 파일)로 바꾸고, 과제가 쓰는 그림 상자만 남겨 추출 도구에 넣는다.
- 설치 (WSL Ubuntu, LibreDWG 0.14 소스 빌드): `PKG_CONFIG=/bin/true ./configure --prefix=$HOME/.local --disable-bindings --disable-docs && make && make install`. `dwg2dxf` 가 `~/.local/bin` 에 생긴다.
- 실행: `bash tools/make_model.sh` (입력 `private/drawing.dwg`, 출력 `private/` 와 리포 루트의 `model_extracted.js`). 웹 왼쪽 도면은 `private/drawing_gen.svg` 를 읽는다.
- 파일 용도:
- `private/drawing.dwg`: 주고받는 원본 DWG. 커밋하지 않는다.
- `private/drawing.dxf`: 추출 입력. 그림 상자 안 객체만 남긴 것.
- `private/full.dxf`: DWG 를 바꾼 전체 DXF (중간 산출물).
- `tools/views.json`: 그림 상자 4개와 뺄 레이어·블록 이름. 사람이 정한다.
- `private/drawing_gen.svg`: 웹 표시용 그림. DXF 에서 `tools/dxf_to_svg.py` 가 만든다(상자 배치는 `views.json`).
- `private/drawing.svg`: 예전 수작업 SVG 원본. 비교용으로만 쓴다.
- 각 도구: `tools/dwg_to_dxf.sh`(DWG → DXF), `tools/select_views.py`(상자 안 객체만 남김), `tools/dxf_to_svg.py`(DXF → SVG, 표준 라이브러리만), `tools/make_model.sh`(위 도구와 추출 도구를 차례로 돌림). 추출 도구는 `views.json` 을 세 번째 인수로 받아 도면 연결값(`drawing`: 단면·평면도·슬래브 평면의 좌표와 층 높이)을 도면에서 계산한다.
## 검사
- `bash check.sh` : F01~F17 전체 검사
- `bash check.sh F06` : F01~F06 만 검사, 뒤 기능은 SKIP
- 결과 끝 줄이 `PASS n / FAIL 0` 이면 통과. 육안으로 볼 항목은 SPEC 8.2 에 있다.
## 사용 도구
- WSL2 (Ubuntu) 에서 작업. 설치일은 Ubuntu 가상 디스크를 만든 날 2026-07-09. 설치 증빙(Windows 쪽 `wsl --version`·`wsl --status`, WSL 안쪽 커널·Ubuntu·claude 버전)은 `evidence/wsl2.txt` 참고. WSL 버전 확인 (Windows 명령 프롬프트): `wsl -l -v`
```
NAME STATE VERSION
* Ubuntu Stopped 2
```
- Claude Code, 모델은 Haiku 5.5 (코드 작성·수정·커밋. 커밋 메시지마다 `Co-Authored-By: Claude Haiku 5.5` 줄)
- 설계 문서·검사(`SPEC.md`, `FEATURES.json`, `check.sh`)와 구동 설명서(`guide/`)는 설계를 맡은 별도 Claude(Opus) 세션이 쓰고, 하이쿠가 커밋한다(커밋 첫 줄 KIT·DOC).
- GNU LibreDWG 0.14 (DWG → DXF), Python 3 표준 라이브러리, git, 브라우저(Chrome·Edge)
+615
View File
@@ -0,0 +1,615 @@
# SPEC — 전력구 수직구 3D 뷰어 (MVP)
> 🔒 사내교육용 — 열람 외 재배포 등의 활동을 금함 (회사 도면의 치수를 옮겨 적음)
> 설계 면담(수석 + 코치 세션, 2026-10-19~20)으로 확정. 이 파일은 하이쿠가 고치지 않는다.
## 1. 목적
원형 수직구 일반도 한 장을 **라이브러리 없이** 브라우저 3D 화면으로 보여 준다.
청중은 교육 담당자와 동료이고, 3분 시연에서 아래 세 문장을 화면을 돌려 가며 말할 수 있으면 끝이다.
1. 외경 11m, 깊이 70.3m 원통이다.
2. 안에 슬래브 19장 + 하부 슬래브가 있고, 층마다 계단이 지그재그로 내려간다.
3. 바닥 근처 19B 층에서 4시 방향으로 TBM 터널(내경 2.6m)이 나가고, 터널 바닥 패드와 19B 패드 상면 높이가 같다.
## 2. 범위
| 넣는 것 | 빼는 것 (범위 밖) |
|---|---|
| 벽체, 중간 슬래브 19장, 19B 패드, 하부 슬래브, 바닥콘크리트 | 상부 분기구·환기구·관로, 지표→1B 구간의 계단·설비 |
| 계단 18런(1B→19B) | 슬래브 개구부(계단·케이블 slot·펌프 배출구 구멍), 방호 난간 |
| TBM 터널 세그먼트(속 빈 원통) + 터널 안 잡석·패드 2단 | 집수정 사다리, 집수조·침사조·월류벽, 배관 |
| 등각 초기 화면, 드래그 회전, 휠 확대, 벽 반투명 | 프리셋 뷰, 클릭 선택·속성 패널, 표시/숨김, 축·그리드, 단면, 치수 라벨, PNG 저장 |
## 3. 좌표계와 단위
- 데이터의 길이는 모두 **mm**, 각도는 **도**.
- 원점 = 수직구 축 중심 × 지표면(구조물 상단). **Z 위로 +**, 땅속은 음수.
- X = 평면도 오른쪽, Y = 평면도 위쪽. 수평 각도는 **X축에서 반시계**(시계의 3시 = 0°, 12시 = 90°, 4시 = 330°).
## 4. 부재와 치수
### 4.1 벽체·바닥
| 부재 | 형태 | 치수 |
|---|---|---|
| 벽체 | 속 빈 원통 | 외경 11,000(r 5,500), 내경 9,700(r 4,850), z −70,200 ~ 0 |
| 하부 슬래브(BOTTOM, 20B) | 원판 | r 4,850, z −70,200 ~ −69,000 (두께 1,200, 벽 안쪽) |
| 바닥콘크리트 | 원판 | r 5,500(= 벽 외경, 슬러리월 안에서 타설), z −70,300 ~ −70,200 (두께 100) |
### 4.2 중간 슬래브 (원판, r 4,850, 두께 300)
슬래브 상면 z. 하면 = 상면 − 300.
| 층 | 상면 z | 층 | 상면 z | 층 | 상면 z |
|---|---|---|---|---|---|
| 1B | −7,400 | 8B | −30,500 | 15B | −53,600 |
| 2B | −10,700 | 9B | −33,800 | 16B | −56,900 |
| 3B | −14,000 | 10B | −37,100 | 17B | −60,200 |
| 4B | −17,300 | 11B | −40,400 | 18B | −63,500 |
| 5B | −20,600 | 12B | −43,700 | 19B | −66,200 |
| 6B | −23,900 | 13B | −47,000 | | |
| 7B | −27,200 | 14B | −50,300 | | |
규칙: 1B = −7,400(지표에서 5,400+500+1,300+200). 1B~18B 는 3,300 간격. 18B→19B 2,700. 19B→하부 슬래브 상면 2,800. 총높이 70,300.
### 4.3 19B 패드
원판, 벽 안쪽면에서 100 떨어짐 → r 4,750(지름 9,500), z −66,200 ~ −66,100 (두께 100).
### 4.4 계단 (18런)
- 런 i 는 i 층 슬래브(위)에서 i+1 층 슬래브(아래)로 내려간다. i = 1~18 (마지막 런은 18B→19B).
- 단 높이 200, **맨 아래 첫 단만 100**. 디딤판 깊이 300. 폭 1,000.
- 층고 3,300 → 단 수 17(= 100 + 200×16), 디딤판 16장, 런 길이 4,800
- 층고 2,700(런 18) → 단 수 14(= 100 + 200×13), 디딤판 13장, 런 길이 3,900
- 계단은 디딤판마다 **블록**(아래 슬래브 상면부터 그 디딤판 높이까지 꽉 찬 상자)으로 쌓는다.
- 위치 (🔶 확인 필요 — 코치 추론):
- 런의 윗끝 = 위 슬래브의 계단 개구부 가장자리. 개구부(4,200)는 X 가운데 −2,100 ~ +2,100 으로 본다.
- 홀수 런: 윗끝 x = −2,100, 내려가며 +X 방향, Y 띠 −1,000 ~ 0.
- 짝수 런: 윗끝 x = +2,100, 내려가며 −X 방향, Y 띠 0 ~ +1,000.
- 블록 k(아래부터 1, 2, …, 단 수−1)의 윗면 z = 아래 슬래브 상면 + 100 + 200×(k−1). 맨 위 단(200)은 위 슬래브로 올라선다.
- 블록 k 의 X 구간: 윗끝 x 에서 (단 수−1−k)×300 ~ (단 수−k)×300 만큼 내려가는 방향으로 떨어진 곳.
- 개구부를 그리지 않으므로 맨 위 블록이 위 슬래브 두께에 100 겹친다. MVP 에서는 받아들인다.
### 4.5 TBM 터널
- 방향 330°(평면도 4시). 축 높이 z −65,050.
- 세그먼트: 속 빈 원통, 두께 200, 내경 2,600(r 1,300), 외경 3,000(r 1,500).
- 원통은 **벽 안쪽면(r 4,850)에서 시작**해 벽을 관통하고 벽 바깥면에서 20,000 더 나간다 → 길이 20,650. 시작점 = (4,850·cos330°, 4,850·sin330°, −65,050).
- 근거(수석): 세그먼트가 벽체 개구부의 거푸집 역할을 해 18B 슬래브와 19B 벽체부를 동시 타설한다. 그래서 세그먼트 천장 외면(−63,550)이 18B 슬래브 두께(−63,800 ~ −63,500) 안에 들어가는 것은 실제 형태다.
- 높이 검산: 세그먼트 바닥 외면 −66,550(= 19B 슬래브 하면 −66,500 − 50) → 내면 −66,350 → 잡석 상면 −66,225 → 패드 상면 −66,100 = **19B 패드 상면**.
- 터널 안 채움(누운 원통 속을 높이까지 채움). 높이 v 는 터널 축에서 잰 값(위 +).
| 채움 | v 범위 | 폭 제한 |
|---|---|---|
| 잡석 | −1,300 ~ −1,175 (125) | 세그먼트 안쪽 벽까지 |
| 패드 2단(아래) | −1,175 ~ −1,100 (75) 🔶 | 세그먼트 안쪽 벽까지 |
| 패드 1단(위) | −1,100 ~ −1,050 (50) 🔶 | 축 중심 폭 800 |
🔶 확인 필요: 패드 합계 125 를 「양측 트렌치 깊이 50」에 맞춰 75 + 50 으로 나눈 것은 코치 추론.
## 5. 데이터 파일 `model.js`
- 첫 줄은 `const MODEL = ` 로 시작하고 그 뒤는 **순수 JSON**(따옴표 있는 키, 주석·끝 쉼표 없음), 마지막은 `;`. check.sh 가 이 꼴을 python 으로 읽는다.
- 최상위: `units`("mm"), `parts`(배열).
- 모든 부재 공통 필드: `id`(겹치지 않음), `name`(한글 이름), `kind`, `type`, `color`("#rrggbb"), `alpha`(0~1, 없으면 1).
- `kind` 값: `wall`, `slab`, `pad`, `bottom`, `lean`, `stair`, `tunnel`, `fill`.
| type | 쓰는 kind | 필드 |
|---|---|---|
| `ring` (세운 속 빈 원통) | wall | `cx, cy, z0, z1, r_out, r_in` |
| `disc` (세운 원판) | slab, pad, bottom, lean | `cx, cy, z0, z1, r` (z0 아래, z1 위) |
| `pipe` (누운 속 빈 원통) | tunnel | `x, y, z`(축 시작점), `angle_deg, length, r_out, r_in` |
| `stair` (계단 런) | stair | `z_low, z_high, risers, first_riser, riser, tread, x_top, dir(+1/−1), y0, y1` |
| `fill` (누운 원통 속 채움) | fill | `x, y, z, angle_deg, length`(pipe 와 같은 축), `r`(세그먼트 안쪽 반지름), `v0, v1, half_width`(0 이면 제한 없음) |
부재 수: wall 1 · slab 19 · pad 1 · bottom 1 · lean 1 · stair 18 · tunnel 1 · fill 3 = 45.
색은 자유. 단 벽은 `alpha` 0.25.
## 6. 화면
- `index.html` 을 브라우저로 **더블클릭해서 열면** 바로 보인다(서버·인터넷 불필요). 캔버스 하나가 창을 채운다.
- 처음 화면: **등각 정사 투영**, 모델 전체(지하 70m 원통과 20m 터널)가 화면에 들어온다.
- 마우스 드래그: 궤도 회전(좌우 = Z축 둘레, 위아래 = 기울기). 휠: 확대/축소.
- 면은 방향에 따라 밝기가 다르다(면 음영). 먼 면부터 그린다(화가 알고리즘).
- 원은 32각형. 벽만 반투명(0.25), 나머지는 불투명.
## 7. 그리기 방식 (함수 이름 목록 — 구현은 하이쿠)
- 부재 → 면: `facesRing`, `facesDisc`, `facesPipe`, `facesStair`, `facesFill`, `facesBox`
- 모든 면 모으기: `buildFaces`
- 카메라: `rotatePoint`, `project`
- 그리기: `shadeColor`, `sortFaces`, `draw`
- 입력: `onMouseDown`, `onMouseMove`, `onMouseUp`, `onWheel`
- 라이브러리·외부 파일 불러오기 금지. 파일은 `index.html`, `viewer.js`, `model.js` 셋(+ README.md).
## 8. 합격 기준
### 8.1 check.sh (기계 판정)
`bash check.sh` → 항목마다 `[PASS]`/`[FAIL]`, 전부 PASS 면 종료 코드 0. 항목 이름 앞에 기능 ID(F01…)가 붙는다.
- 파일 셋이 있고 `index.html` 이 `model.js` 다음에 `viewer.js` 를 읽는다.
- 외부 라이브러리 흔적이 없다(`http://`, `https://`, `import `, `require(`).
- `model.js` 가 위 꼴의 JSON 이고, 부재 수·치수가 4장 표와 같다(허용 오차 1mm).
### 8.2 수석 육안 체크리스트 (check.sh 로 못 재는 것)
- [ ] 더블클릭으로 열린다(Chrome, Edge)
- [ ] 첫 화면이 등각이고 모델 전체가 보인다
- [ ] 드래그 회전·휠 확대가 된다
- [ ] 벽 너머로 슬래브·계단·터널이 보인다
- [ ] 계단이 층마다 방향을 바꾼다
- [ ] 터널이 평면 4시 방향, 19B 높이에 붙어 있다
- [ ] 1장의 세 문장을 화면으로 말할 수 있다
## 9. 나중 (MVP 뒤, 시간이 남으면)
프리셋 뷰·리셋 → 클릭 선택·속성 패널 → 부재별 표시/숨김 → 축·그리드·단위 → 슬래브 개구부 → 단면·거리·치수 라벨·PNG.
## 10. 추가 — 도면 분할 화면과 도면 클릭 연결 (2026-10-20 수석 지시, F13~F15)
### 10.1 화면 배치 (F13)
- `index.html` 본문: 왼쪽 `#drawPane` · 가운데 세로 막대 `#splitter`(폭 6px) · 오른쪽 `#viewPane`(기존 캔버스).
- 처음 비율 50:50. 막대를 끌면 왼쪽 폭이 바뀐다(창 폭의 15%~85%). 바뀔 때마다 캔버스 크기를 오른쪽 칸에 맞추고 다시 그린다.
- 도면 파일은 `private/drawing.svg` 고정. `private/` 는 `.gitignore` 로 커밋하지 않는다(제출 압축에만 넣는다). 파일이 없으면 왼쪽 칸에 「도면 파일 없음 — private/drawing.svg 를 넣으세요」.
### 10.2 도면 표시·확대·이동 (F14)
- `#drawPane` 안에 `#drawStage` 하나, 그 안에 `<img id="drawing">`(도면)과 같은 크기로 겹친 인라인 `<svg id="hotspots" viewBox="0 0 443 443">`(클릭 영역). 확대·이동은 `#drawStage` 의 CSS transform 으로 한다.
- 처음 화면: 도면 SVG 좌표 상자 `fit`(아래 MODEL.drawing) 이 왼쪽 칸에 꽉 차게(`fitDrawing`).
- 휠 = 마우스 위치 기준 확대/축소(0.5~40배), 드래그 = 이동.
### 10.3 도면 ↔ 부재 연결 (F15)
- `model.js` 최상위에 `drawing` 추가:
`src` "private/drawing.svg" · `viewBox` [0, 0, 443, 443] · `fit` [118, 160, 245, 250](u0, v0, u1, v1) · `section` {x0 139.0, y0 174.08, mm_per_unit 1000} · `plan` {cx 227.51, cy 177.1, mm_per_unit 1000}
- 좌표 (u = 도면 가로, v = 도면 세로, 아래로 +):
- 단면 A-A: u = x0 + X/1000, v = y0 − z/1000 (도면 선으로 확인: 벽 바깥 133.5/144.5, 안쪽 134.15/143.85, 1B 상면 181.48)
- 평면도: u = cx + x/1000, v = cy − y/1000
- 부재별 클릭 영역(다각형, 도면 단위). 아래 순서대로 만들고, 겹치면 나중 것이 위:
1. stair: 단면, u = x0 ± 4.85, v = y0 − (z_high − 300)/1000 ~ y0 − z_low/1000
2. wall: 단면 두 띠, u = x0 − 5.5 ~ x0 − 4.85 와 x0 + 4.85 ~ x0 + 5.5, v = y0 − z1/1000 ~ y0 − z0/1000
3. tunnel: 평면, 축 시작점에서 angle_deg 방향으로 length, 폭 ±r_out 인 사각형
4. slab, pad, bottom, lean (disc): 단면, u = x0 ± r/1000, v = y0 − z1/1000 ~ y0 − z0/1000
5. fill: 영역 없음
- 동작: 영역 위에 마우스 → 노란 테두리. 클릭 → 그 부재 선택: 도면 영역을 #ffd400(불투명도 0.35)으로 칠하고, 3D 에서 그 부재를 #ffd400·alpha 1 로 그리고, 3D 칸 왼쪽 위에 「선택: 부재 이름」. 영역 밖 클릭 → 선택 해제.
- 함수 이름: `onSplitDown`, `onSplitMove`, `onSplitUp`, `fitDrawing`, `onDrawWheel`, `onDrawDown`, `onDrawMove`, `onDrawUp`, `sectionPoly`, `planPoly`, `buildHotspots`, `selectPart`
- 3D → 도면 방향(3D 클릭으로 도면 표시)은 하지 않는다(나중).
## 11. 추가 — 제출 문서 두 개 (2026-10-20 수석 지시, F16~F17)
제출물은 git log, 코드, 문서. 문서는 둘로 나눈다. 둘 다 첫머리에 🔒 한 줄. 사실만 쓰고, git log·PROGRESS.md·SPEC.md 에 근거가 있는 내용만 쓴다.
### 11.1 `구동설명서.md` — 무엇을 어떤 기획으로 만들었고 어떻게 돌리나 (F16)
제목 순서(## 단계 제목에 아래 낱말을 그대로 넣는다):
1. 목적 — 무엇을 누구에게 보여 주는가(SPEC 1장 세 문장)
2. 기획 — 도면 선정 → 범위 결정 → 설계 면담으로 치수·규칙 확정(SPEC 3~5장 요지) → SPEC·FEATURES·check.sh → 기능 하나씩 커밋. 결정된 것 중 핵심 5개 이상(예: 지표 = z 0, 터널 패드 = 19B 패드 높이, 계단 첫 단 100)
3. 범위 — 넣은 것 / 뺀 것 (SPEC 2장, 9장)
4. 구동 — 더블클릭으로 연다, 도면은 `private/drawing.svg` 에 둔다, 브라우저
5. 조작 — 3D 드래그·휠, 가운데 막대, 도면 확대·이동, 도면 클릭 → 3D 강조
6. 작업 방식 — WSL2, Claude Code(Haiku 5.5)가 코드 작성·수정·커밋, 합격 판정은 check.sh, 설계 면담·검토와 F02 이후 하이쿠 호출은 수석 위임으로 상위 모델 코치 세션이 맡음, 기능 1개 = 커밋 1개
7. 한계 — 계단은 위에서 보면 슬래브에 가려 옆에서 본다, 슬래브 개구부 없음, 3D → 도면 방향 없음 등
8. 이력 — `git log --oneline` 결과 표(해시·첫 줄)
### 11.2 `README.md` — 코드 설명 (F17, 기존 README 를 고쳐 쓴다)
제목 순서: 파일 구조 · 실행 · 데이터(model.js) · 코드(viewer.js) · 그리기 방식 · 도면 연결 · 검사
- 데이터: type 별 필드 표(SPEC 5장), drawing 항목(SPEC 10.3)
- 코드: SPEC 7장·10.3 의 함수 이름을 **모두** 적고 각각 한 줄 설명
- 그리기 방식: 회전·정사 투영·면 음영·먼 면부터(화가 알고리즘)·벽 반투명
- 도면 연결: 단면·평면 좌표 변환식
- 검사: `bash check.sh`, `bash check.sh F0n`
## 12. 변경 — 구동설명서를 HTML 위키로 (2026-10-20 수석 지시, F18)
- 11.1 의 `구동설명서.md` 를 HTML 여러 쪽으로 옮기고, md 파일은 지운다(내용은 그대로 옮기고 git 기록에는 남는다).
- 폴더 `guide/`: 목차 `index.html` + 8쪽. 파일 이름(쪽 제목): `purpose.html`(목적) · `plan.html`(기획) · `scope.html`(범위) · `run.html`(구동) · `control.html`(조작) · `method.html`(작업 방식) · `limits.html`(한계) · `history.html`(이력). 모양은 `guide/guide.css` 하나.
- 모든 쪽(목차 포함):
- 맨 위에 「← 3D 화면으로 돌아가기」 버튼 — `../index.html` 로 가는 링크
- 🔒 한 줄, `<h1>` 쪽 제목
- 위키처럼 본문 낱말에 다른 쪽 링크를 건다(예: 기획 쪽의 「범위」 → scope.html). 쪽마다 다른 쪽 링크 2개 이상
- 아래에 「목차」 링크와 이전/다음 쪽 링크
- 3D 화면(`index.html`): 오른쪽 3D 칸 **오른쪽 위**에 「구동 설명서」 버튼 `<a id="guideLink" href="guide/index.html">`. 3D 드래그를 방해하지 않게 작게.
- 라이브러리·외부 URL 금지는 그대로. 파일 목록에 `guide/` 추가.
## 13. 추가 — 선명한 도면과 모든 그림 연결 (2026-10-20 수석 지시, F19~F20)
### 13.1 선명한 도면 (F19)
- 원인: 도면 SVG 의 선 굵기가 0.75 도면단위로 박혀 있어 확대할수록 굵어지고, `#drawStage` 를 CSS scale 로 키워 그림이 뭉개진다.
- `tools/prep_drawing.py`(python3, 표준 라이브러리만): `python3 tools/prep_drawing.py <원본.svg> private/drawing.svg`. 원본은 고치지 않고, 출력 SVG 는 0 이 아닌 stroke-width 를 모두 1 로 바꾸고 루트에 `<style>` 로 모든 요소에 `vector-effect: non-scaling-stroke` 를 건다(화면에서 1px 선). 원본 파일 이름·경로를 코드나 문서에 적지 않는다.
- 확대·이동은 CSS scale 대신 `#drawing` 과 `#hotspots` 의 **실제 width/height** 를 배율에 맞춰 바꾸고(브라우저가 벡터를 다시 그림), 위치만 translate 로 옮긴다. 함수 `applyDrawView`.
### 13.2 모든 그림 연결 (F20)
- `MODEL.drawing.views` 배열을 추가한다(10.3 의 section·plan 은 그대로 둔다). 값은 아래 표 그대로.
| id | type | 그림 | 기준 |
|---|---|---|---|
| secA | section | 단면 A-A | x0 139.0, y0 174.08 |
| secB | section | 단면 B-B | x0 179.01, y0 174.08 |
| secA2 | section_break | 단면 A-A (4B~15B 생략) | x0 263.41, y0_upper 173.04, y0_lower 140.04, z_upper_min −17600, z_lower_max −53600 |
| secB2 | section_break | 단면 B-B (4B~15B 생략) | x0 284.23, 나머지 secA2 와 같음 |
| plan1 | plan | 평면도 | cx 227.51, cy 177.1, tunnel true |
| plan2 | plan | 평면도 2 | cx 227.51, cy 197.1, tunnel false |
| slabOdd | slabplan | 중간슬래브 (홀수층) | cx 227.51, cy 226.31, kind slab, z1 = 1B·3B·…·17B 상면 |
| slabEven | slabplan | 중간슬래브 (짝수층) | cx 252.27, cy 226.31, kind slab, z1 = 2B·4B·…·16B 상면 |
| slab18 | slabplan | 중간슬래브 (18B) | cx 277.02, cy 226.31, kind slab, z1 [−63500] |
| slab19 | slabplan | 중간슬래브 (19B) | cx 227.51, cy 247.32, kind slab, z1 [−66200], tunnel true |
| slab20 | slabplan | 하부슬래브 (20B) | cx 252.27, cy 247.32, kind bottom, z1 [−69000] |
- 그림별 클릭 영역(모두 도면 단위, 각 view 의 기준으로 계산):
- section: 10.3 규칙 그대로(stair → wall → slab·pad·bottom·lean). x0·y0 만 view 값.
- section_break: section 과 같되, 부재의 z 범위가 전부 z_upper_min 이상이면 y0_upper, 전부 z_lower_max 이하이면 y0_lower 로 계산하고, 그 밖(생략 구간에 걸친 것)은 영역 없음. 벽은 위 조각(0 ~ z_upper_min)과 아래 조각(z_lower_max ~ 바닥) 두 개로 나눈다.
- plan: 고리(반지름 4.85 ~ 5.5, 32각형 두 개를 evenodd 로) → 벽. tunnel true 면 10.3 의 터널 사각형도(그 view 의 cx·cy 기준).
- slabplan: 안쪽 원판(반지름 4.85) → z1 상면이 일치하는 kind 부재 **전부를 한 번에** 선택. 고리 → 벽. slab19 는 19B 패드도 함께, tunnel true 면 터널 사각형.
- 선택은 여러 부재를 한 번에 할 수 있다: `selectPart` 가 부재 id 배열을 받는다. 「선택:」 글자는 view 이름과 부재 이름(여럿이면 「1B, 3B, … 17B」처럼).
- 함수: `viewHotspots`(view 하나의 영역 목록), `buildHotspots` 는 views 를 돈다.
- 참고(구현하지 않음): 슬래브 평면에 있는 개구부(계단·케이블 slot·펌프 배출구·전기 배선구)는 3D 에 없다(2장 범위 밖). 그래서 3D 슬래브는 꽉 찬 원판이고, 계단 맨 윗단이 슬래브를 뚫고 보인다. 개구부를 넣으려면 슬래브 평면에서 개구 위치·크기를 읽어 원판 윗면·아랫면을 구멍 난 다각형(evenodd)으로 그려야 한다 — 다음 단계 후보.
## 14. 변경 — 구동설명서는 사용 설명서 (2026-10-20 수석 지시)
- `guide/` 는 누구나 이 웹을 작동할 수 있게 하는 **사용 설명서**다. 수석 지시로 코치 세션이 직접 쓴다. 파일 이름·목차·돌아가기 버튼 규칙(12장)과 check.sh 검사 항목은 그대로 지킨다.
## 15. 추가 — 슬래브 구멍, 계단 방향 정정, 카메라 이동, 사람 애니메이션 (2026-10-20 수석 지시, F21~F23)
### 15.1 슬래브 구멍 (F21)
- 슬래브 평면 도면에서 읽은 구멍(슬래브 중심 기준 mm, X 오른쪽·Y 위쪽). 노란 선(방호 난간)이 비어 있는 쪽이 계단 입구.
- disc 부재에 `holes` 필드: 다각형 목록, 다각형은 [x, y] 점 목록(mm). 구멍이 없으면 필드를 생략하거나 빈 목록.
| 구멍 | 다각형 (mm) |
|---|---|
| T_odd (계단 + 위 slot) | (−2100,0) (2100,0) (2100,1000) (1250,1000) (1250,2200) (−1250,2200) (−1250,1000) (−2100,1000) |
| T_even (계단 + 아래 slot) | (−2100,0) (2100,0) (2100,−1000) (1250,−1000) (1250,−2200) (−1250,−2200) (−1250,−1000) (−2100,−1000) |
| slot_up | 사각형 x −1250~1250, y 1000~2200 |
| slot_dn | 사각형 x −1250~1250, y −2200~−1000 |
| stair_even | 사각형 x −2100~2100, y −1000~0 |
| pump | 사각형 x −750~750, y 3650~4790 |
| pump_pad | 사각형 x −750~750, y 3650~4750 |
| elec | 사각형 x 2080~3070, y 3530~3750 (벽에 붙은 1000×300, 벽 안쪽까지만) |
| 부재 | holes |
|---|---|
| 홀수층 슬래브 1B·3B·…·17B | T_odd, slot_dn, pump, elec |
| 짝수층 슬래브 2B·4B·…·16B | T_even, slot_up, pump, elec |
| 18B 슬래브 | stair_even, pump, elec (slot 없음 — 수석 확인 Q1) |
| 19B 슬래브 | pump (집수정 사다리도 이 구멍으로 — 수석 확인 Q2) |
| 19B 패드 | pump_pad |
| 하부 슬래브·바닥콘크리트 | 없음 |
- 그리기: 원판 윗면·아랫면 = 바깥 32각형 + 구멍 다각형을 한 면으로 묶어 evenodd 로 칠한다. 구멍 둘레마다 세로 옆면을 더한다. 함수 `facesDiscHoles`.
### 15.2 계단 방향 정정 (F21, 4.4 의 🔶 추론을 도면으로 바로잡음)
- 홀수 런(1·3·…·17, 홀수층 슬래브에서 내려감): x_top **+2100**, dir **−1**, Y 띠 **0 ~ +1000**
- 짝수 런(2·4·…·18, 짝수층·18B 에서 내려감): x_top **−2100**, dir **+1**, Y 띠 **−1000 ~ 0**
- 블록 계산식(4.4)은 그대로.
### 15.3 카메라 이동 (F22)
- 3D 는 보는 중심 `CAM.target`[x, y, z](처음 = 모델 중심)을 기준으로 그린다.
- 왼쪽 드래그 = 회전(그대로). **오른쪽 드래그 또는 Shift + 왼쪽 드래그 = 화면 이동**(target 을 화면 평면에서 옮김). 캔버스에서 오른쪽 클릭 메뉴는 막는다.
- 휠 = **마우스 아래 점을 고정한 채** 확대·축소, 배율 0.5~**40**.
- 더블클릭 또는 3D 칸 오른쪽 위 `<button id="resetView">처음 화면</button>` = 처음 등각 화면(yaw·pitch·배율·target 초기값).
- 함수 `panView`, `zoomAt`, `resetView`.
### 15.4 사람 애니메이션 (F23)
- 사람 = 상자 4개(mm): 다리 2개 150×150×800(좌우 100 간격), 몸통 400×250×600(z 800~1400), 머리 220×220×220(z 1450~1670). 색 #ff8c1a, 불투명. 데이터 파일에는 넣지 않고 viewer.js 가 그린다.
- 경로 `buildWalkPath`: 계단 데이터(stair 18개)로 만든다. 런마다
1. 위 슬래브 위, 입구 앞 300 지점 (x_top − dir·300, 띠 중심 y, z_high)
2. 디딤판마다 가운데 점: k = 단 수−1 … 1 순서로 x = x_top + dir·300·(단 수 − 1 − k + 0.5), z = z_low + 100 + 200·(k − 1)
3. 아래 슬래브에 내려선 점 (x_top + dir·300·단 수, 띠 중심 y, z_low)
4. 다음 런 입구 앞 점으로 옆걸음(같은 높이)
- 1B 런 입구에서 19B 까지 내려가고, 같은 길로 1B 까지 올라오기를 쉬지 않고 반복(속도 1000 mm/s, 점 사이 직선 보간). 함수 `walkerAt`(시각 → 위치), `facesPerson`, `animate`(requestAnimationFrame).
- 3D 칸 오른쪽 위 `<button id="walkToggle">` — 움직일 때 「사람 ❚❚」, 멈췄을 때 「사람 ▶」. 처음에는 움직인다.
- 멈춰 있을 때는 지금처럼 필요할 때만 다시 그린다. 느리면 초당 30번으로 줄인다.
## 16. 추가 — 바닥부: 월류벽·집수조·침사조·침사구·사다리 (2026-10-20 수석 사전 승인, F24~F25)
도면 단면 A-A 하부와 19B·20B 슬래브 평면에서 읽음(mm, 슬래브 중심 기준).
- **정정**: 15.1 의 「19B 집수정 사다리는 펌프 배출구로(Q2)」는 도면과 다르다. 19B 동쪽 벽 쪽에 **침사구 1000×1000** 구멍이 따로 있고, 사다리는 그 아래 동쪽 벽에 붙어 있다. (수석 부재 중 도면 근거로 코치가 바로잡음)
### 16.1 부재 (F24)
| 부재 | kind | type | 값 |
|---|---|---|---|
| 월류벽 (넘침 홈 양옆 2, 홈 아래 1) | weir | prism | ① 다각형 사각형 X 1400~1600, Y −4580~−500, z −69000~−67000 ② Y 500~4580, 같은 z ③ Y −500~500, z −69000~−67500 (홈 깊이 500) |
| 집수조 (펌프 쪽, 서쪽) | pit | zone | r 4850, x_min −4850, x_max 1400, z −69000~−67000, 반투명 0.25 |
| 침사조 (터널 쪽, 동쪽) | pit | zone | r 4850, x_min 1600, x_max 4850, z −69000~−67000, 반투명 0.25 |
| 사다리 | ladder | ladder | x0 4668, x1 4708(기둥 두께), yc 0, width 400, z0 −68700, z1 −66200, rung 300 |
| 19B 슬래브 구멍 추가 | — | — | 침사구 사각형 X 3850~4820, Y −500~500 |
| 19B 패드 구멍 추가 | — | — | 침사구 사각형 X 3850~4720, Y −500~500 |
- `prism`: `polygon`([x, y] 점 목록), `z0`, `z1` — 다각형을 세로로 늘인 기둥(윗면·아랫면·옆면). 함수 `facesPrism`.
- `zone`: 원(반지름 r) 안에서 x_min ≤ X ≤ x_max 인 부분을 z0~z1 로 세운 덩어리(활꼴 기둥). 함수 `facesZone`.
- `ladder`: 기둥 2개(X x0~x1, Y = yc ± width/2 에 두께 40), 가로대(rung 간격, 두께 25)를 상자로. 함수 `facesLadder`.
- 색은 자유(물칸은 서로 다른 옅은 색). 🔶 사다리 폭 400·가로대 300 은 도면에 없어 코치가 정한 값(산업안전보건기준 사다리 폭 30cm 이상 참고).
### 16.2 도면 연결 (F25)
- 단면 A-A 두 그림(view id `secA`, `secA2`)에서: 월류벽·집수조·침사조·사다리를 단면 규칙(u = x0 + X/1000, v = y0 − z/1000)으로 X 범위 × z 범위 사각형 영역. 순서 집수조 → 침사조 → 월류벽 → 사다리(나중 것이 위).
- 하부슬래브(20B) 평면(`slab20`)에서: 위 네 부재를 평면 규칙(u = cx + X/1000, v = cy − Y/1000)으로. 집수조·침사조는 원과 세로선으로 자른 활꼴 다각형.
- 19B 슬래브 평면(`slab19`)의 침사구 자리를 누르면 사다리를 선택.
- 함수 `bottomHotspots`.
## 17. 추가 — 상부 구조·환기구·방호 난간·배관 (2026-10-20 수석 사전 승인, F26~F29)
목표: 2D 도면에 있는 것을 모두 3D 로(수석 지시). 단면 A-A 상부, 단면 B-B(환기 덕트를 펼친 단면), 평면도 1·2, 슬래브 평면 노란 선에서 읽음. 🔶 = 도면에 수치가 없어 코치가 정한 값.
### 17.1 상부 슬래브·맨홀 목·벽 상단 (F26)
- **정정**: 벽체 윗면은 지표가 아니라 상부 슬래브 밑면 **z −2000** (벽 위로는 흙 1500 과 맨홀 목). 4.1 의 벽체 z1 0 → −2000.
- 상부 슬래브: kind `topslab`, type disc, r 5500(벽 바깥면까지), z −2000 ~ −1500, holes: 원 (0, 0, 450), 원 (2223, 3803, 450).
- holes 항목은 다각형 외에 `{"circle": [x, y, r]}` 도 된다(16각형으로 그림). prism 에도 `holes` 를 쓸 수 있다. 함수 `holePolygon`.
- 맨홀 목 2개: kind `neck`, type prism, z −1500 ~ 0, 각각 1300 정사각형을 30° 돌린 다각형 + 가운데 원 구멍 r 450.
- 목 1: [[238, 888], [-888, 238], [-238, -888], [888, -238]], holes [{"circle": [0, 0, 450]}]
- 목 2: [[2461, 4691], [1335, 4041], [1985, 2915], [3111, 3565]], holes [{"circle": [2223, 3803, 450]}]
- **정정**: 전기 배선구 구멍은 벽을 따라 돈 1000×300 사각형이다(난간 선으로 확인). 15.1 의 elec 를 다각형 [[2079, 4079], [2922, 3528], [3072, 3753], [2229, 4304]] 로 바꾼다(홀수층·짝수층·18B).
### 17.2 분기구·환기구 (F27)
- `obox`(돌린 상자): `cx, cy, angle_deg, s0, s1, t0, t1, z0, z1`. 점 = (cx, cy) + s·(cosθ, sinθ) + t·(−sinθ, cosθ). 함수 `facesObox`.
- 분기구 kind `branch`, cx = cy = 0, 안 2200×2100, 벽 350, 바닥 450:
| 이름 | angle | s | t | z |
|---|---|---|---|---|
| 바닥 | 167 | 4850 ~ 8350 | -1450 ~ 1450 | -4550 ~ -4100 |
| 지붕 | 167 | 4850 ~ 8350 | -1450 ~ 1450 | -2000 ~ -1650 |
| 옆벽 1 | 167 | 4850 ~ 8350 | 1100 ~ 1450 | -4100 ~ -2000 |
| 옆벽 2 | 167 | 4850 ~ 8350 | -1450 ~ -1100 | -4100 ~ -2000 |
| 끝벽 | 167 | 8000 ~ 8350 | -1100 ~ 1100 | -4100 ~ -2000 |
- 환기구(강제) 덕트·환기탑 kind `vent`, cx = cy = 0, angle 0 (s = X, t = Y). 덕트 안 3000×2100, 남쪽으로 내려가 동쪽으로 꺾여 환기탑(안 3000 정사각형, 지표 z 0 까지)에 닿는다:
| 이름 | angle | s | t | z |
|---|---|---|---|---|
| 덕트 바닥 1 | 0 | -1850 ~ 1850 | -10500 ~ -4610 | -4600 ~ -4100 |
| 덕트 바닥 2 | 0 | 1850 ~ 7504 | -10500 ~ -6800 | -4600 ~ -4100 |
| 덕트 지붕 1 | 0 | -1850 ~ 1850 | -10500 ~ -4610 | -2000 ~ -1650 |
| 덕트 지붕 2 | 0 | 1850 ~ 7504 | -10500 ~ -6800 | -2000 ~ -1650 |
| 덕트 서벽 | 0 | -1850 ~ -1500 | -10500 ~ -4610 | -4100 ~ -2000 |
| 덕트 동벽 | 0 | 1500 ~ 1850 | -6800 ~ -4610 | -4100 ~ -2000 |
| 덕트 북벽 | 0 | 1500 ~ 7504 | -7150 ~ -6800 | -4100 ~ -2000 |
| 덕트 남벽 | 0 | -1850 ~ 7504 | -10500 ~ -10150 | -4100 ~ -2000 |
| 환기탑 바닥 | 0 | 7504 ~ 11205 | -10500 ~ -6799 | -4600 ~ -4100 |
| 환기탑 서벽 위 | 0 | 7504 ~ 7854 | -10149 ~ -7149 | -2000 ~ 0 |
| 환기탑 동벽 | 0 | 10855 ~ 11205 | -10500 ~ -6799 | -4100 ~ 0 |
| 환기탑 북벽 | 0 | 7504 ~ 11205 | -7149 ~ -6799 | -4100 ~ 0 |
| 환기탑 남벽 | 0 | 7504 ~ 11205 | -10500 ~ -10149 | -4100 ~ 0 |
### 17.3 방호 난간·배관·위쪽 사다리 (F28)
- 방호 난간 kind `railing`, type `railing`: `z`(설치 슬래브 윗면), `h` 1100 🔶, `paths`(꺾은선 목록, 각 [x, y] 점). 꺾은선을 따라 높이 h 의 얇은 면(난간 판)을 세운다. 함수 `facesRailing`.
- 홀수층 9장(1B·3B·…·17B 윗면): paths [[[2140, 4350], [1940, 4050], [2950, 3390], [3150, 3690]], [[1350, -900], [1350, -2300], [-1350, -2300], [-1350, -900], [1350, -900]], [[2100, -100], [-2200, -100], [-2200, 1100], [-1350, 1100], [-1350, 2300], [1350, 2300], [1350, 1100], [2100, 1100]], [[-850, 4780], [-850, 3550], [850, 3550], [850, 4780]]]
- 짝수층 8장(2B·…·16B 윗면): paths [[[-850, 4780], [-850, 3550], [850, 3550], [850, 4780]], [[-2100, 100], [2200, 100], [2200, -1100], [1350, -1100], [1350, -2300], [-1350, -2300], [-1350, -1100], [-2100, -1100]], [[1350, 2300], [1350, 900], [-1350, 900], [-1350, 2300], [1350, 2300]], [[2140, 4350], [1940, 4050], [2950, 3390], [3150, 3690]]]
- 19B(z −66100, 패드 위): paths [[[-850, 4780], [-850, 3550], [850, 3550], [850, 4780]]]
- 18B: 도면에 난간 없음.
- 펌프 배출관(STS Ø100) kind `pipe`, type ring: cx 0, cy 4220, r_out 57, r_in 50, z −68800 ~ −2000 🔶(도면 L=64.0m 는 경로 길이로 보고 집수조 바닥 근처부터 상부 슬래브 밑까지).
- 위쪽 사다리 kind `ladder_up`, type ladder:
- 서쪽(1B → 분기구 바닥): x0 −4720, x1 −4680, yc 1080, width 400, z0 −7400, z1 −4100, rung 300
- 가운데(1B → 맨홀 → 지표): x0 −20, x1 20, yc 0, width 360, z0 −7400, z1 0, rung 300 🔶
### 17.4 도면 연결 (F29)
- 단면 A-A(secA, secA2 위쪽): topslab 은 disc 규칙, neck·branch·vent·ladder_up 은 꼭짓점들의 X 범위 × z 범위 사각형.
- 평면도 1·2(plan1, plan2): 원판 안(r 4850)을 먼저 topslab 영역으로, 그 위에 neck 다각형, branch·vent obox 바닥 윤곽(꼭짓점 4개), 그 위에 기존 벽 고리·터널.
- 함수 `upperHotspots`.
## 18. 변경 — 사람 두 명, 콘크리트 계단, 구동설명서 3쪽 (2026-10-21 수석 지시, F30~F32)
### 18.1 사람 두 명 (F30)
- 지하시설물에 들어갈 때는 2명 이상이 조를 이루어 이동한다. 사람을 두 명으로 하고, 두 번째 사람은 첫 사람과 같은 경로를 **2초 뒤에** 따라온다(속도 1000 mm/s → 약 2m 간격). 경로 끝에서 돌아설 때 잠깐 스쳐 지나가는 것은 받아들인다.
- 색: 첫 사람 #ff8c1a, 두 번째 #e07010. 함수 `teamAt`(시각 → 두 위치). 「사람 ▶/❚❚」 버튼은 두 사람을 함께 멈춘다.
### 18.2 콘크리트 계단 (F31)
- 철근콘크리트 계단: 경사판(계단 슬래브) 위에 단면이 직각삼각형인 단이 올라간 꼴이고, **밑면은 경사지며 계단 아래는 빈 공간**이다(수석 제공 측면도로 비례 확인: 안쪽 모서리 선에서 수직 200 아래에 밑면, 위끝은 위 슬래브 밑보다 100 낮음, 아래끝은 마지막 디딤판보다 약 450 앞에서 아래 슬래브에 닿음).
- 런마다 단면 다각형(s = x_top 에서 내려가는 방향 거리, 실제 x = x_top + dir·s)을 만들어 Y 띠(y0~y1) 폭만큼 늘인다. 함수 `stairProfile`(런 → 점 목록), `facesStair` 가 이것으로 면을 만든다(옆면 2개 + 둘레 변마다 사각형).
1. (0, z_high) 에서 시작
2. j = 0 … n−2: 챌판 (j·300, z_high − 200·(j+1)) 로 내려가고, 디딤판 ((j+1)·300, z_high − 200·(j+1)) 로 간다
3. 첫 단 챌판: ((n−1)·300, z_low)
4. 아래 슬래브 윗면을 따라 (s_b, z_low), s_b = (z_high − z_low − 400) × 1.5
5. 경사 밑면: (0, z_high − 400)
6. 다시 (0, z_high) 로 닫는다
- 디딤판 높이·위치는 4.4·15.2 와 같다(사람 경로 그대로). 색은 그대로.
### 18.3 구동설명서 3쪽 (F32)
- `guide/` 는 목차(index) + **목적(purpose)·구동(run)·조작(control)** 3쪽만 남긴다. plan·scope·limits·method·history 쪽은 지운다(git 기록에는 남는다).
- 🔒 줄은 구동설명서에 넣지 않는다(수석 지시, 외부에 내보낼 문서). README·SPEC 의 표기는 그대로.
- 사람 문장: 「주황색 사람 두 명 — 지하시설물에 들어갈 때는 2명 이상이 조를 이루어 이동해야 한다 — 이 시설물 계획 중 유지관리 동선계획을 따라 이동하는 과정을 보여 준다.」
- 「3D 를 눌러 도면을 찾는 방향은 아직 없다」 문장은 뺀다. 지운 쪽을 가리키는 링크·문구는 남은 쪽으로 바꾸거나 지운다. 도면 연결 표는 조작 쪽으로 옮긴다.
- 쪽마다: 맨 위 「← 3D 화면으로 돌아가기」, h1, 다른 두 쪽 링크, 목차·이전/다음 링크. 수석 지시로 코치 세션이 쓰고, 하이쿠가 커밋한다.
## 19. 추가 — 도면 추출 도구와 대조 검사 (2026-10-21 수석 지시, F33~F34)
목적: 「도면 → 데이터」 단계를 저장소 안 코드로 옮긴다. 범용 변환기가 아니라 **이 도면에 대한 반자동 변환**: 도면에서 기계적으로 읽을 수 있는 값은 도구가 읽고, 사람이 정한 규칙(패드 높이, 계단 첫 단 등)은 SPEC 의 설계 결정으로 남는다.
### 19.1 추출 도구 (F33)
- `python3 tools/extract_from_svg.py <도면.svg> model_extracted.js` — 표준 라이브러리만. 출력은 model.js 와 같은 꼴(`const MODEL = ` + 순수 JSON + `;`), `units` "mm".
- **도면 좌표를 코드에 숫자로 적지 않는다**(그림 위치·기준점은 모두 도면에서 찾는다). 규칙:
1. 축척: 단면도·평면도 모두 1 도면단위 = 1000 mm. 평면은 X = (u − 중심u)·1000, Y = −(v − 중심v)·1000. 단면은 X = (u − 축u)·1000, z = −(v − 지표v)·1000.
2. 그림 찾기: `<text>` 의 글자(공백 무시)와 transform 의 평행이동 값으로 그림 이름 위치를 얻는다. 「단면A-A」 중 가장 왼쪽 것이 전체 단면이다. 슬래브 평면은 「중간슬래브(홀수층)」「(짝수층)」「(18B)」「(19B)」 글자 바로 아래에 있는 원(반지름 5.5 와 4.85 두 개)의 중심.
3. 단면 A-A(전체): 글자 아래에 있는 하늘색 rgb(0,255,255) 세로선 중 가장 긴 두 쌍이 벽 — 바깥 두 선 간격 = 외경, 안 두 선 간격 = 내경, 축u = 바깥 두 선의 가운데. 지표v = 그 근처 녹색 rgb(0,255,0) 수평선(지반선)의 v.
4. 슬래브 윗면: 단면 A-A 의 하늘색 수평선 중 길이가 내경과 같은(±0.05) 선. 바로 0.3 아래에 같은 길이 선이 또 있으면 위 선만 윗면이다. 결과는 위에서부터 19개(1B~19B).
5. 슬래브 구멍: 슬래브 평면 원(반지름 4.85) 안의 검은색 rgb(0,0,0)·회색 rgb(128,128,128) 꺾은선 중 닫힌 사각형은 그대로, 세 변만 있는 꺾은선(벽에 붙은 구멍)은 두 끝점을 이어 닫은 다각형을 구멍으로 한다. 다른 후보를 감싸는 후보(난간 윤곽, 18B 처럼 회색으로 그려진 경우)는 뺀다. 계단 구멍은 5b 로 뽑는다.
6. 방호 난간: 슬래브 평면 원 안의 노란색 rgb(255,255,0) 꺾은선을 그대로 paths 로.
5b. 계단 구멍(난간으로만 그려짐): 노란 난간 꺾은선 중 두 끝점이 서로 다르고 둘 다 벽(반지름 4.85)에서 0.3 넘게 떨어진 것이 계단 입구 난간이다. 꺾은선을 두 끝점으로 닫은 다각형에서, 닫는 변(입구)은 그대로 두고 나머지 변을 0.1(=100 mm) 안쪽으로 옮긴 다각형을 구멍으로 한다. 홀수층·짝수층 평면에서 하나씩 나온다.
7. 평면 → 층: 홀수층 평면 = 1B·3B·…·17B, 짝수층 = 2B·…·16B, 18B, 19B (4 의 윗면 목록 순서로).
- 색은 요소 자신 또는 가장 가까운 조상 `<g>` 의 stroke 를 따른다.
- 출력 부재: 벽 1(type ring, r_out·r_in, z1 −2000, z0 −70200 — z 는 고정값 허용), 슬래브 19(type disc, z1 = 윗면, z0 = 윗면 − 300, r = 내경/2, holes = 5 의 구멍), 난간(type railing, 층마다 1, z = 슬래브 윗면, h 1100, paths = 6). 각 부재 `name` 에 층 이름.
- 함수(파이썬): `load_svg`, `find_views`, `section_walls`, `slab_tops`, `plan_holes`, `plan_railings`, `build_model`.
### 19.2 대조 검사와 비교 화면 (F34)
- `compare.html`: index.html 과 같은 화면인데 `model.js` 대신 `model_extracted.js` 를 읽는다(도면 연결 데이터가 없으면 왼쪽은 안내 글자). 3D 칸 위에 「추출 데이터로 그린 화면」 글자. 추출 결과와 수작업 모델을 눈으로 비교하는 용도.
- check.sh 가 도구를 직접 돌려 model.js 와 대조한다(벽 반지름 ±10, 슬래브 윗면 ±60, 구멍은 추출된 각 구멍이 model.js 의 어느 구멍 범위 안(±40)에 들고, 난간 꺾은선 수·꼭짓점이 ±40 안에서 같다). 또 도면 좌표를 통째로 옮긴 사본으로 다시 돌려 결과가 같아야 한다(숫자를 코드에 박으면 실패). 그리고 도면 내용을 바꾼 사본 두 개로 돌려 결과가 그만큼 바뀌어야 한다 — 1B 슬래브 윗면 선만 0.5 내린 사본(1B 만 약 500 낮아짐), 홀수층 아래 slot 하나를 지운 사본(홀수층 9장에서 구멍이 하나씩 줄어듦). 공식이나 정답 값을 코드에 넣으면 실패한다.
- `model_extracted.js` 는 도구의 출력이며 커밋한다(도면 자체는 들어 있지 않다).
## 20. 변경 — 추출 입력을 DXF 로 (19장 대체, 2026-10-21 수석 지시, F33~F34)
- 수석이 원본 DWG 를 주었고, 코치가 GNU LibreDWG `dwg2dxf` 로 DXF 로 바꾼 뒤 **우리 도면 세트의 그림(전체 단면·평면도·생략 단면·슬래브 평면)만** 남겼다(표제란·다른 도면 제외). 파일 `private/drawing.dxf`(ASCII, git 제외). 19장의 SVG 규칙은 이 장으로 대체한다(SVG 는 왼쪽 도면 표시에만 쓴다).
- 이 SVG 규칙은 도면에 맞지 않았다(18B 윗면이 구멍에 잘려 18개만 잡힘, T자 구멍의 출처 불명확) — 하이쿠가 하드코딩 없이 보고해 드러남.
### 20.1 추출 도구 (F33)
- `python3 tools/extract_from_dxf.py <도면.dxf> model_extracted.js` — 표준 라이브러리만(DXF 는 「그룹 코드 줄 + 값 줄」의 텍스트다). 출력 꼴은 19.1 과 같다. **도면 좌표·정답 값·층 높이 공식을 코드에 적지 않는다.**
- DXF 읽기: ENTITIES 구역의 LINE·LWPOLYLINE(코드 70 의 1 비트 = 닫힘)·POLYLINE(+VERTEX…SEQEND, 머리의 10/20 은 기준점이라 쓰지 않음)·CIRCLE·TEXT. 레이어 = 코드 8, 색 = 코드 62(없으면 레이어 색).
- 규칙(단위: 모델 1 = 1 m → ×1000 mm, Y 위 +):
1. 전체 단면: 레이어 `구조물` 의 세로 선분 중 길이 60 m 넘는 것들. X 가 가장 작은 네 선 = 단면 A-A 의 벽(바깥 둘·안 둘): 외경 = 바깥 두 선 간격, 내경 = 안 두 선 간격, 축 X = 바깥 두 선 가운데.
2. 지표: 색 3(녹색) 선 중 단면 A-A 의 축 X 를 지나는 것의 그 자리 Y. z = (Y − 지표 Y)·1000.
3. 슬래브 윗면: 레이어 `구조물` 의 수평 선분 중 **왼쪽 안쪽 벽 X 에서 시작**하고(±0.01), 0.3(±0.01) 아래에 역시 왼쪽 안쪽 벽 근처(0.2 안)에서 시작하는 `구조물` 수평 선분이 있는 것. 위에서부터 19개 = 1B~19B (구멍에 잘린 층도 왼쪽 조각이 남는다).
4. 슬래브 평면: 레이어 `S-TITLE` 글자 「중간슬래브(홀수층)」「(짝수층)」「(18B)」「(19B)」(공백 무시) 바로 아래에서 가장 가까운, 반지름 4.85 와 5.5 원이 겹친 중심.
5. 구멍: 그 평면의 안쪽 원 안에 있는 레이어 `오프닝`·`구조물 계단` 객체로 만든다. (가) 꼭짓점 4개 이상인 LWPOLYLINE/POLYLINE 하나는 그대로 후보(닫혀 있지 않으면 두 끝점을 이어 닫는다 — 벽에 붙은 세 변 구멍). (나) 꼭짓점 3개인 열린 LWPOLYLINE 은 같은 레이어의 **수평·수직** LINE 중 그 끝점에서 이어지는 것을 차례로 붙여 닫히면 후보. 기울어진 LINE(대각선)과 끝점에 붙지 않는 LINE(디딤판 선)은 쓰지 않는다. 같은 모양 중복은 하나만, 다른 후보를 감싸는 후보(난간 윤곽)는 뺀다.
6. 방호 난간: 그 평면 원 안의 레이어 `계단`, 색 2 인 꼭짓점 3개 이상 꺾은선 → paths (닫힌 것은 첫 점을 끝에 한 번 더).
7. 평면 → 층: 홀수층 = 1B·3B·…·17B, 짝수층 = 2B·…·16B, 18B, 19B (3 의 순서).
- 출력 부재: 19.1 과 같음(벽 1, 슬래브 19 + holes, 난간 층마다 1).
- 함수(파이썬): `load_dxf`, `section_walls`, `ground_y`, `slab_tops`, `find_slab_plans`, `plan_holes`, `plan_railings`, `build_model`.
- 코치는 이 규칙대로만 짠 별도 구현으로 채점이 모두 통과함을 미리 확인했다(그 구현은 주지 않는다). 막히면 규칙 결함일 수 있으니 숫자를 넣지 말고 보고한다.
### 20.2 대조 검사 (F34)
- 19.2 와 같은 대조(벽 ±5, 윗면 ±5, 구멍 ⊆ model.js 구멍 ±10, 난간 ±10)와 compare.html.
- 계단 구멍(폭 4 m 넘는 구멍) 18개(홀수 9·짝수 8·18B 1).
- 변조 사본 셋: 좌표 전체 (+7, +5) m 이동 → 같은 결과 / 1B 슬래브(윗면·아랫면 선)만 0.5 m 내림 → 1B 만 500 낮아짐 / 홀수층 아래 slot 지움 → 구멍 9개 감소.
## 21. 추가 — 계단 추출 (2026-10-21 수석 지시, F35) — 계단이 생기면 사람 두 명도 추출 모델에서 걷는다
도면(DXF)에서 읽는다. 20.1 의 함수에 더한다. 함수 `section_stairs`, `stair_parts`.
1. 단면 계단 윤곽: 레이어 `구조물`, 색 8, **닫힌** 꺾은선, 꼭짓점 6개 이상, 단면 A-A 바깥 벽 두 선 사이.
2. 단 높이·디딤판: 윤곽들의 세로 변 길이(mm) 중 가장 많이 나온 값 = riser, 가장 작은 값 = first_riser, 가로 변 중 가장 많이 나온 값 = tread.
3. 런 i(1~18): z_high = i 층 윗면, z_low = i+1 층 윗면(20.1 ③). risers = (z_high − z_low − first_riser) / riser + 1 (반올림).
4. 방향 dir: 가장 낮은 꼭짓점의 z 가 z_low 인 단면 윤곽에서, 그 가장 낮은 꼭짓점들이 윤곽의 왼쪽 끝에 붙어 있으면 −1, 오른쪽 끝이면 +1. 단면에 윤곽이 없는 런(18)은 바로 위 런의 반대.
5. x_top, y0, y1: i 층 슬래브 평면의 계단 구멍(20.1 ⑤ 의 구멍 중 폭 4 m 넘는 것). dir −1 이면 구멍의 오른쪽(X 최대) 가장자리, +1 이면 왼쪽(X 최소) 가장자리가 x_top. y0·y1 = 그 구멍의 Y 범위.
6. 출력: kind·type `stair` 18개, 필드는 model.js 와 같다(z_low, z_high, risers, first_riser, riser, tread, x_top, dir, y0, y1, 이름·색은 자유).
- 코치는 이 규칙대로만 짠 별도 구현으로 F35 채점(대조·변조 시험)이 통과함을 미리 확인했다(그 구현은 주지 않는다).
## 22. 정정 — 평면도 좌표틀 (2026-10-21 수석 지시, F36)
- 발견(코치, 도면 DXF 재확인): 슬래브 평면(홀수·짝수·18B·19B·20B)과 단면 A-A 는 같은 틀이다(계단 구멍 긴 변 = X). 평면도 1·2 는 이 틀을 **−30.3°** 돌려 그렸다(평면도의 계단 구멍 긴 변·터널 중심선이 −30.3°). 4.5 와 17장의 터널·채움·분기구·환기구·상부 맨홀·목은 평면도에서 읽어 그 틀 그대로 넣었기 때문에, 모델 안에서 안쪽 부재와 30.3° 어긋났다. 예: 19B 평면에서 터널은 +X(계단과 나란히)인데 모델은 330°.
- 원칙: **모델 좌표 = 슬래브 평면 틀.** 평면도에서 온 점 (x, y) 는 +30.3° 돌린다: (x cos30.3° − y sin30.3°, x sin30.3° + y cos30.3°). 그렇게 돌리면 맨홀 2 가 (0, 4405) = 펌프 구멍 바로 위, 목 두 개가 축에 맞는 1300 정사각형이 된다.
- 새 값(나머지 필드는 그대로):
| 부재 | 바꿀 것 |
|---|---|
| tunnel_TBM | x 4850, y 0, angle_deg 0 (19B 평면의 터널 선이 X 축과 나란하고 Y ±1300·±1500) |
| fill 3개 | x 4850, y 0, angle_deg 0 (터널과 같은 축) |
| topslab | holes [{"circle": [0, 0, 450]}, {"circle": [0, 4405, 450]}] |
| neck_1 | polygon [[-650, -650], [650, -650], [650, 650], [-650, 650]], holes [{"circle": [0, 0, 450]}] |
| neck_2 | polygon [[-650, 3755], [650, 3755], [650, 5055], [-650, 5055]], holes [{"circle": [0, 4405, 450]}] |
| branch 5개 | angle_deg 167 → 197.3 (s, t, z 그대로) |
| vent 13개 | angle_deg 0 → 30.3 (s, t, z 그대로) |
| ladder_up_W | x0 −4622, x1 −4582 (단면 A-A 의 사다리 중심 X −4602), yc −1260 🔶 (분기구 입구 쪽, 폭 400 이 벽 안에 드는 한도) |
- 17.1 의 목 다각형은 돌린 방향도 틀렸다(위 표로 대체). 슬래브 구멍·계단·난간·패드·바닥부·배관·가운데 사다리는 이미 슬래브 평면 틀이라 그대로.
- 도면 연결: `MODEL.drawing.views` 의 plan1·plan2 에 `"rot_deg": -30.3` 을 더한다. 평면 그림에 놓을 때 모델 (X, Y) 를 rot_deg 만큼 돌린 뒤(X' = X cos − Y sin, Y' = X sin + Y cos) u = cx + X'/k, v = cy − Y'/k. rot_deg 가 없는 그림은 0. `planXY` 와 `planPoly`(터널 사각형)에 넣는다(원은 그대로).
- 결과: 3D 에서 터널이 계단과 나란히 +X 로 나가 침사구 쪽 벽으로 들어오고, 평면도 클릭 영역은 지금처럼 도면 그림 위에 놓인다.
## 23. 추가 — 터널 추출 (2026-10-21 수석 지시, F37)
도면(DXF)에서 TBM 터널과 터널 안 채움 3개를 읽는다. 20.1·21장 함수에 더한다. 함수 `section_tunnel`, `plan_tunnel`, `tunnel_parts`.
1. 단면(높이·지름): 단면 A-A **오른쪽 바깥 벽선**(20.1 ① 의 X 가 가장 큰 선)에서 시작해 바깥으로 1 m 넘게 뻗는 레이어 `구조물` 수평선(기울기 5% 미만, 터널 경사가 조금 있다). 벽 쪽 끝의 z(20.1 ② 방식) 다섯 개를 위에서부터 [바깥 위, 안 위, 패드 윗면, 안 아래, 바깥 아래] 로 본다.
- 축 z = (바깥 위 + 바깥 아래)/2, r_out = (바깥 위 − 바깥 아래)/2, r_in = (안 위 − 안 아래)/2.
2. 평면(방향): 「중간슬래브(19B)」 평면 중심(20.1 ④)에서 두 끝이 4.8 m 이상 8 m 이하인 레이어 `-평면계획` 색 8 선분. 각 선분을 중심에 가까운 끝 → 먼 끝으로 본 방향의 합 = 터널 방향 d. 축 어긋남 = 그 선분들 가까운 끝의 d 에 수직인 거리 평균(왼쪽 +).
3. 터널(kind `tunnel`, type `pipe`): angle_deg = d 의 각도(X축에서 반시계, 0~360), 시작점 = 축 어긋남 + 벽 안쪽 반지름(20.1 ①)만큼 d 방향, 길이 = 벽 두께 + 20000(4.5 의 설계값: 벽 바깥에서 20 m).
4. 채움 3개(kind `fill`, 터널과 같은 축·길이, r = r_in). v 는 축에서 잰 높이: 패드 윗면 v = ① 의 패드 윗면 − 축 z. 4.5 의 설계값(도면에 없음): 잡석 두께 125, 패드 1단 두께 50, 1단 폭 800.
- 잡석 −r_in ~ −r_in + 125 / 패드 2단 −r_in + 125 ~ 패드 윗면 − 50 / 패드 1단 패드 윗면 − 50 ~ 패드 윗면(half_width 400, 나머지 0).
5. 도면 좌표·정답 값(축 높이, 지름, 패드 높이, 방향 각도)은 코드에 적지 않는다. 고정값은 위 설계값(20000, 125, 50, 400)과 20.1 의 허용값만.
- 대조(F37): 터널 x·y·z·r·길이 ±10, 방향 ±0.5°, 채움 v·폭 ±5. 변조 사본 둘: 단면 A-A 터널 선만 0.5 m 내림 → 터널 축 500 낮아짐 / 19B 평면 터널 선을 좌우로 뒤집음 → 방향 180°.
- 코치는 이 규칙대로만 짠 별도 구현으로 F37 채점이 통과함을 미리 확인했다(그 구현은 주지 않는다).
## 24. 추가 — 분기구·환기구 추출 (2026-10-21 수석 지시, F38)
도면(DXF)에서 분기구(kind `branch`)와 환기구(덕트·환기탑, kind `vent`)를 obox(17.2) 로 읽는다. 함수 `plan_center`, `frame_angle`, `section_heights`, `branch_parts`, `vent_parts`.
- 평면 모양은 평면도 1, 높이는 단면(분기구 = 단면 A-A, 환기구 = 단면 B-B「환기 덕트를 펼친 단면」)에서 읽는다. 평면도는 슬래브 평면 틀과 각도 φ 만큼 다르다(22장) — φ 도 도면에서 구한다.
### 24.1 평면도 1 과 φ
1. 평면도 중심 C: 레이어 `S-TITLE` 글자 「평면도」(공백 무시) 아래에서 가장 가까운 반지름 4.85 원의 중심.
2. 레이어 `-평면계획` 색 1, 꼭짓점 3개이고 가운데 꼭짓점이 C(±0.01)인 꺾은선 → C 에서 나가는 두 다리.
3. 터널 다리 = 그 다리와 나란하고(±0.01 rad) 다리에서 r_out(23장, ±0.02 m) 떨어진 `-평면계획` 색 8 선분이 2개 이상 있는 다리. 다른 다리 = 분기구 다리.
4. φ = 23장 터널 angle_deg − 터널 다리의 각도. 평면도의 점·방향은 모두 C 기준 mm 로 바꾼 뒤 φ 만큼 돌려 모델 좌표로 쓴다(obox 는 cx·cy 를 돌리고 angle_deg 에 φ 를 더하면 된다).
### 24.2 단면 높이 (`section_heights`)
- 단면 B-B 의 벽 네 선 = 20.1 ① 처럼 길이 60 m 넘는 `구조물` 세로선을 X 순으로 놓았을 때 5~8번째(1~4번째가 A-A).
- 벽 개구부 X 표시: 한쪽(왼쪽 = 바깥 왼·안 왼, 오른쪽 = 안 오·바깥 오) 벽 두 면 사이를 잇는 길이 1 m 넘는 `구조물` 사선, 지표 아래 10 m 안. 그 z 범위의 아래끝 = **바닥 윗면**, 위끝 = **지붕 아랫면**. X 가 있는 쪽이 그 구조물 쪽.
- 헌치: 그 쪽 벽 바깥면(±0.01)에 한 끝이 있고 다른 끝이 벽 바깥으로 나간 짧은 `구조물` 사선(길이 0.1~0.5 m). 바깥 끝이 지붕 아랫면보다 위이고 아래로 내려가는 것의 바깥 끝 z = **지붕 윗면**, 바닥 윗면보다 아래이고 위로 올라가는 것의 바깥 끝 z = **바닥 아랫면**.
- 도면 값(참고): 분기구 바닥 −4,550 ~ −4,100, 환기구 바닥 −4,600 ~ −4,100, 둘 다 지붕 −2,000 ~ −1,650. 이 숫자를 코드에 쓰지 않는다.
### 24.3 분기구 (`branch_parts`)
- 분기구 다리 방향 = s, 왼쪽 수직 = t. 평면도의 닫히지 않은 `구조물` 꺾은선(꼭짓점 4개 이상) 중 모든 꼭짓점이 s > 4.55 m, |t| < 2 m, s < 다리 길이 + 0.5 m 인 것 둘: |t| 최대가 작은 것 = 안 윤곽(t_in, 끝 s_in = 최대 s), 큰 것 = 바깥 윤곽(t_out, s_out).
- obox 5개(cx = cy = 0, angle = 다리 각도 + φ, s 는 벽 안쪽면 4850 부터): 바닥·지붕(t ±t_out, s ~ s_out), 옆벽 둘(t_in ~ t_out 양쪽, 높이 바닥 윗면 ~ 지붕 아랫면), 끝벽(s_in ~ s_out, t ±t_in).
### 24.4 환기구 (`vent_parts`)
- 중심선: 레이어 `환기구`, 꼭짓점 3개, 첫 꼭짓점이 C 인 꺾은선 → 꺾인 점 V1, 끝점 T. 다리 1 = C→V1, 다리 2 = V1→T.
- 덕트 벽선: 닫히지 않은 꼭짓점 3개 `구조물` 꺾은선 중 가운데 꼭짓점이 V1 에서 3 m 안인 것 4개. 다리 1 에서 잰 |t| 의 작은 둘 평균 = t_in, 큰 둘 평균 = t_out.
- 환기탑: T 를 품는 닫힌 `구조물` 꺾은선 — 꼭짓점 4개 = 바깥 사각형, 더 많은 것 = 안 윤곽. 다리 2 축으로 잰 범위(s, t)를 쓴다.
- 덕트 obox(높이는 24.2 의 B-B 값):
- 다리 1: 바닥·지붕 s 4850 ~ L1 + t_out, t ±t_out. 꺾이는 안쪽 벽(다리 2 가 나가는 쪽) s 4850 ~ L1 − t_out, 바깥 벽 s 4850 ~ L1 + t_out (t_in ~ t_out).
- 다리 2(V1 에서): 바닥·지붕 s t_out ~ 탑 바깥 사각형의 가까운 변, t ±t_out. 안쪽 벽 s t_in ~ 탑 변, 바깥 벽 s −t_out ~ 탑 변.
- 환기탑 obox: 바닥 = 바깥 사각형(덕트 바닥 높이). 벽 넷 = 바깥 사각형과 안 윤곽 범위 사이의 띠, 높이 바닥 윗면 ~ 지표 z 0. 단 덕트가 들어오는 쪽 벽은 지붕 아랫면 ~ 0 만(B-B 에서 탑 벽이 덕트 지붕 위에만 있음). 탑에는 지붕이 없다.
- 덕트·탑을 obox 로 나누는 방법은 위와 다르게 해도 된다 — 채점은 **입체(차지하는 공간)** 로 비교한다.
### 24.5 대조 (F38)
- 분기구·환기구 각각: 높이 5곳(−4575, −4300, −3000, −1800, −1000) × 100 mm 격자(수직구 바깥 r 5520 넘는 곳)에서 「추출 부재 안인가」가 model.js 와 같은지. 경계 ±20 mm 는 봐준다. 어긋난 점이 안쪽 점의 1% 이하.
- 변조 사본 둘: 평면도 1 의 환기구(중심선·덕트·탑)를 C 기준 좌우로 뒤집음 → 추출 환기구도 뒤집혀 나옴 / 단면 A-A 의 분기구 지붕 헌치 끝을 0.2 m 내림 → 분기구 지붕 윗면만 200 낮아짐.
- 코치는 이 규칙대로만 짠 별도 구현으로 F38 채점이 통과함을 미리 확인했다(그 구현은 주지 않는다).
## 25. 추가 — 나머지 부재 추출 (2026-10-21 수석 지시, F39)
도면(DXF)에서 상부 슬래브·맨홀 목·19B 패드·하부 슬래브·바닥 콘크리트·월류벽·집수조·침사조를 읽고, 19B 슬래브에 침사구 구멍을 더한다. 함수 `top_parts`, `pad_part`, `bottom_parts`, `weir_parts`. id·kind·type·필드는 model.js 와 같게(topslab, neck_1·neck_2, pad_19B, bottom_20B, lean_concrete, weir_L·weir_R·weir_mid, pit_pump·pit_tbm).
1. 단면 A-A 의 「벽 전체 수평선」: 레이어 `구조물` 수평선 중 바깥 왼 벽선에서 바깥 오른 벽선까지(양 끝 ±0.01) 걸친 것. 가장 높은 것 = **상부 슬래브 윗면**, 가장 낮은 것 = **바닥 콘크리트 밑면**, 두 번째로 낮은 것 = **하부 슬래브 밑면**(= 바닥 콘크리트 윗면).
2. 상부 슬래브 밑면 = 윗면보다 낮은 `구조물` 수평선 중 바깥 벽 사이에 있고 길이가 안쪽 지름의 0.9 배 넘는 것 중 가장 높은 것. 상부 슬래브: type disc, r = 바깥 반지름, holes = 평면도 1(24.1) 안의 반지름 1 m 미만 `구조물` 원(중복 하나로) → C 기준 mm 를 φ 만큼 돌린 `{"circle": [x, y, r]}`.
3. 맨홀 목: 평면도 1 의 닫힌 꼭짓점 4개 `구조물` 꺾은선 중 2 의 원 중심을 품는 것(중복 하나로). polygon = 꼭짓점을 φ 만큼 돌린 것, holes = 그 원, z 상부 슬래브 윗면 ~ 지표 0. 구멍 y 가 작은 쪽이 neck_1.
4. 하부 슬래브 윗면 = 단면 A-A 에서 안쪽 벽면에 한 끝이 있고 안쪽·아래로 내려가는 짧은 `구조물` 사선(길이 0.1~0.6 m) 아래 끝 중 가장 낮은 것. 하부 슬래브 disc r = 안쪽 반지름, 바닥 콘크리트 disc r = 바깥 반지름.
5. 19B 패드: 19B 슬래브 윗면(20.1) 위 0~0.3 m 안에 있는, 안쪽 벽 사이 길이 9 m 넘는 `구조물` 수평선 → 윗면, r = 길이/2. 구멍 = 19B 평면 안쪽 원 안의 **X 표시**(같은 레이어(`오프닝` 또는 `구조물`)의 엇갈린 사선 LINE 두 개, 길이 0.5 m 넘고 X 범위가 같은 것)의 끝점 범위 사각형, 패드 반지름 밖은 ±r 로 자른다. 같은 X 표시 사각형(자르지 않은 것)이 19B 슬래브 구멍에 없으면 더한다(침사구).
6. 월류벽: 「하부슬래브(20B)」 평면(제목 아래 가장 가까운 반지름 4.85 원) 안쪽 원 안의 길이 5 m 넘는 `구조물` 세로 LINE 두 개 → x0, x1. 엇갈린 사선 두 개(X 표시)의 Y 범위 → 낮은 칸 y0~y1. 양쪽 칸은 낮은 칸 끝 ~ ±√(안쪽 반지름² − x1²).
- 높이: 단면 A-A 에서 안쪽 벽 사이(벽선에서 0.05 m 넘게 떨어진) 길이 1 m 넘는 세로 `구조물` 선 중 아래 끝이 하부 슬래브 윗면 위 0~0.3 m 안인 것들 → 위끝 = 월류벽 윗면. 그 선들 사이에 걸친 수평선 중 윗면보다 낮은 것 = 낮은 칸 윗면. z0 = 하부 슬래브 윗면.
7. 집수조·침사조(type zone, alpha 0.25, r = 안쪽 반지름): 월류벽 왼쪽 x −r ~ x0, 오른쪽 x1 ~ r, z 하부 슬래브 윗면 ~ 월류벽 윗면. 단면 A-A 글자 「집수조」가 축 왼쪽이면 왼쪽이 pit_pump, 오른쪽이 pit_tbm.
- 도면 값(참고): 상부 슬래브 −2,000~−1,500, 19B 패드 −66,200~−66,100 r 4,750, 하부 슬래브 −70,200~−69,000, 바닥 콘크리트 −70,300~−70,200, 월류벽 x 1,400~1,600·윗면 −67,000·낮은 칸 −67,500. 이 숫자를 코드에 쓰지 않는다.
- 대조(F39): model.js 와 r·z ±5, 원 구멍·윤곽 ±10, 패드 구멍 ±40(정답지는 원 경계에 맞춰 손으로 줄였다). 변조 사본 둘: 20B 평면 월류벽 선을 중심 쪽으로 0.3 m 옮김 → 월류벽·조 경계가 300 옮겨짐 / 평면도 맨홀 2(원·목)를 0.5 m 옮김 → 구멍 2·목 2 만 φ 돌린 만큼 옮겨짐.
- 벽체 높이도 이제 도면에서 읽는다(19.1 의 고정값 허용을 끝냄): wall z0 = 하부 슬래브 밑면, z1 = 상부 슬래브 밑면.
- 사다리·펌프 배출관·사람 등 도면에 위치가 다 나오지 않는 것(🔶)은 이 장에서 다루지 않는다(다음 장의 설계 규칙).
- 코치는 이 규칙대로만 짠 별도 구현으로 F39 채점이 통과함을 미리 확인했다(그 구현은 주지 않는다).
## 26. 추가 — 정답지 보강: 환기구 사다리 2개 (2026-10-21 수석 지시 「2D 도면에 있는 것 모두」, F40)
- 발견(코치, DXF 재확인): 단면 B-B(환기 덕트를 펼친 단면)에 레이어 `사다리` 로 **벽 사다리**(축에서 +4.6 m, 1B ~ 덕트 바닥 위)와 **환기탑 안 사다리**(펼친 거리 18.75 m 근처, 덕트 바닥 ~ 지표 위)가 그려져 있는데 정답지에 없다. 단면 A-A 의 서쪽 벽 사다리(ladder_up_W)와 짝이다.
- `ladder` 에 선택 필드 `angle_deg` 를 더한다: 지금처럼 (x0~x1, yc ± width/2) 로 만든 면의 모든 점을 **원점 (0, 0) 기준으로 angle_deg 만큼** 돌린다(법선도 같이). 없으면 0(지금과 같음). 함수 `facesLadder`.
- 새 부재(kind `ladder_vent`, type `ladder`, color "#d0a040"). kind 가 새것이라 도면 클릭 영역(17.4)에는 넣지 않는다:
| id | name | angle_deg | x0 | x1 | yc | width | z0 | z1 | rung |
|---|---|---|---|---|---|---|---|---|---|
| ladder_vent_wall | 환기구 벽 사다리 (1B→덕트 바닥) | 300.3 | 4582 | 4622 | 0 | 400 | −7400 | −4100 | 300 |
| ladder_vent_tower | 환기탑 사다리 (덕트 바닥→지표) | 30.3 | 10100 | 10140 🔶 | −8650 🔶 | 400 | −4100 | 0 | 300 |
- 300.3° = 환기 덕트 다리 1 방향(24장, 평면도 −90° + 30.3°). 그 방향으로 4.6 m(B-B 의 사다리 앞 기둥) = 벽 안쪽 4.85 m 에서 0.25 m.
- 30.3° 틀의 (x, y) = 평면도의 (x, y). 탑 안 사다리 앞 기둥 = 펼친 거리 18.75 − 덕트 다리 1 길이 8.65 ≈ 10.1 m, yc = 탑 안 가운데(평면도 y −8,650) 🔶.
## 27. 추가 — 설계 규칙 파일, 추출 모델 = 정답지 전체 (2026-10-21 수석 지시, F41)
목표: 추출 모델(model_extracted.js)이 정답지(model.js)의 부재를 **모두** 갖게 한다. 도면에서 읽는 것은 20~25장, 도면에 위치가 다 나오지 않아 사람이 정한 것(🔶)과 화면용 값은 **설계 규칙 파일**에서 온다.
- 파일 `tools/design_rules.json` (하이쿠 작성, 순수 JSON):
- `"parts"`: 🔶 부재 6개를 model.js 의 객체 그대로(id·name·kind·type·color 와 모든 수치) — ladder_19B, ladder_up_W, ladder_up_C, ladder_vent_wall, ladder_vent_tower, pump_pipe. 근거는 16.1·17.3·22·26장(사다리 위치·배관 경로는 도면 수치가 없거나 일부만 있어 설계 결정).
- `"style"`: kind 별 `{"color": ..., "alpha": ...(있을 때만)}` 와 `"ids"`: id 별 덮어쓰기(채움 3개·집수조·침사조처럼 같은 kind 안에서 색이 다른 것). 값은 model.js 의 색·투명도와 같게.
- 도면에서 읽는 부재(벽·슬래브·계단·터널·채움·분기구·환기구·상부 슬래브·목·패드·하부 슬래브·바닥 콘크리트·월류벽·집수조·침사조·난간)의 **치수·좌표는 넣지 않는다.**
- 추출 도구: 도면에서 읽은 부재 뒤에 `parts` 를 더하고, 모든 부재에 style(kind → ids 순으로 덮어씀)의 color·alpha 를 넣는다. 모든 부재에 `name` 이 있다(한국어, 자유). 함수 `apply_design_rules`.
- id: 도면에서 읽는 부재도 model.js 와 같은 id 를 쓴다(분기구·환기구는 나누는 방법이 달라도 되므로 예외).
- 대조(F41): design_rules.json 의 6개 = model.js(±1), 그 밖의 kind 는 넣지 않음, 도면 값 숫자 없음 / 추출 모델의 kind 집합 = model.js, 분기구·환기구 말고 id 집합 = model.js / 같은 id 의 color·alpha = model.js / 모든 부재에 name·color.
## 28. 추가 — 변환 패키지: DWG → DXF → 도면 범위 (2026-10-21 수석 지시, F42)
설계도면은 DWG 로 주고받는다. DWG 는 바이너리라 GNU LibreDWG(`dwg2dxf`)로 DXF(글자 파일)로 바꾸고, 그 안에서 이 과제가 쓰는 그림만 남겨 추출 도구(20~27장)에 넣는다. 지금까지 코치가 리포 밖에서 하던 전처리를 리포 안 도구로 옮긴다.
- 입력: `private/drawing.dwg`(원본 DWG 사본, git 제외 — 코치가 넣었다).
1. `tools/dwg_to_dxf.sh <입력.dwg> <출력.dxf>`: `dwg2dxf -y -o <출력> <입력>`. dwg2dxf 를 PATH 와 `~/.local/bin` 에서 찾고, 없으면 README 의 설치 절을 가리키는 한 줄을 내고 종료 코드 2.
2. `tools/views.json`(사람 입력, 이 장과 함께 커밋): DWG 한 파일에 수직구 두 개와 옛 개정판이 함께 있어 **어느 그림을 쓸지는 사람이 정한다**. `boxes`(그림 상자 4개, 모형 공간 m), `drop_layers`, `drop_block_word`.
3. `tools/select_views.py <전체.dxf> <views.json> <출력.dxf>` (표준 라이브러리만, 좌표 숫자 없음 — 상자는 views.json 에서만):
- ENTITIES 구역을 객체 묶음으로 나눈다: POLYLINE 은 SEQEND 까지, 코드 66 이 1 인 INSERT 는 ATTRIB…SEQEND 까지 한 묶음.
- 뺄 것: 배치 공간(코드 67 이 1), 레이어가 drop_layers 인 것, 블록 이름(INSERT 의 코드 2)에 drop_block_word 가 들어 있는 INSERT, 좌표가 없는 것.
- 좌표: 코드 10·11 의 X 와 그 뒤 20·21 의 Y. POLYLINE 은 머리의 10/20(기준점)을 빼고 VERTEX 부터, INSERT 는 ATTRIB 도 포함. CIRCLE·ARC 는 중심 ± 반지름(코드 40)도 넣는다.
- 남길 것: 좌표 범위(최소~최대)가 상자 하나 안에 다 들어가는 묶음. 순서는 원본 그대로.
- 출력: ENTITIES 앞(HEADER·TABLES·BLOCKS 등)은 원본 그대로, 단 $EXTMIN·$EXTMAX 의 10/20 은 남긴 좌표의 최소·최대. 남긴 묶음, 그 뒤 원본의 나머지. 줄 끝 CRLF.
4. `tools/make_model.sh [입력.dwg] [출력 폴더]` (기본 private/drawing.dwg, private/): 1 → `full.dxf`, 3 → `drawing.dxf`, 추출 도구 → `model_extracted.js`(출력 폴더). 출력 폴더가 private/ 이면 리포 루트의 model_extracted.js 도 새로 쓴다.
5. README 에 「변환 패키지」 절: 설치(LibreDWG 0.14 소스 빌드 — `PKG_CONFIG=/bin/true ./configure --prefix=$HOME/.local --disable-bindings --disable-docs && make && make install`, WSL Ubuntu), 실행(`bash tools/make_model.sh`), 각 파일의 용도(DWG = 주고받는 원본, DXF = 추출 입력, SVG = 웹 표시(다음 장)).
- 대조(F42): make_model.sh 를 임시 폴더로 돌려 나온 drawing.dxf 의 객체가 private/drawing.dxf 와 같고(숫자 1e-6 까지 — LibreDWG 가 z 에 1e-11 잡음을 넣을 때가 있다), 나온 model_extracted.js 의 부재가 리포의 것과 같다. 상자 하나만 남긴 views.json 으로 돌리면 그 상자 안 객체만 남는다.
- 코치는 이 규칙대로만 짠 별도 구현으로 원본 DWG → 3,356 객체가 private/drawing.dxf 와 같게 나옴을 미리 확인했다.
## 29. 추가 — DXF → SVG 와 도면 연결값을 도면에서 (2026-10-21 수석 지시 「웹에서 DXF 를 못 보이면 SVG 로 변환까지 패키지로」, F43)
웹 왼쪽 칸의 도면 SVG 를 DXF 에서 직접 만들고, 그림 연결값(MODEL.drawing)도 추출 도구가 도면에서 계산한다. 화면은 지금과 같아야 한다 — 수석이 준 SVG 는 DXF 의 그림 상자 4개를 **축척 없이 평행이동**해 놓은 배치였다(오차 0.01 m).
- `tools/views.json` 에 더함(사람 입력 — 배치): 상자마다 `place` [dx, dy] — 그 상자의 점 (X, Y) 를 SVG (X − dx, dy − Y) 에 놓는다(1 = 1 m). `viewBox` [0, 0, 443, 443], `fit` [118, 160, 245, 250](처음 화면).
### 29.1 `tools/dxf_to_svg.py <drawing.dxf> <views.json> <출력.svg>` (표준 라이브러리만)
- 객체마다 그 좌표 범위가 들어가는 상자(28장과 같은 방법)의 place 로 옮긴다. 상자에 안 들어가는 객체는 그리지 않는다.
- 레이어 표(TABLES 의 LAYER: 2 이름, 62 색 — 음수면 꺼짐, 70 의 1 비트 = 얼림)에서 꺼짐·얼림·`Defpoints` 레이어는 그리지 않는다.
- 색: 객체 62 가 없거나 256 = 레이어 색, 0 = 블록을 넣은 INSERT 의 색. ACI → RGB: 1 빨강 (255,0,0), 2 노랑, 3 초록, 4 하늘 (0,255,255), 5 파랑, 6 자홍, 7 검정 (0,0,0)(원본 SVG 와 같게), 8 (128,128,128), 9 (192,192,192), 250~255 회색 6단(51, 91, 132, 173, 214, 255), 10~249 는 색상 (i−10)//10 × 15°, i%10 짝수 = 채도 1·홀수 = 0.5, 밝기 [1, 1, .8, .8, .6, .6, .5, .5, .3, .3][i%10] (HSV).
- 그리기: LINE·LWPOLYLINE(코드 42 볼록 → 원호)·POLYLINE(VERTEX, 42)·LEADER(꼭짓점 꺾은선)·CIRCLE·ARC(50·51 도, 반시계)·SOLID(10·11·13·12 순 채움)·TEXT·MTEXT·INSERT(BLOCKS 구역의 블록을 기준점 10/20 빼고, 41·42 배율, 50 회전, 10/20 위치로; 겹친 INSERT 도)·DIMENSION(코드 2 의 블록을 그대로). Y 를 뒤집으므로 원호 방향도 뒤집힌다.
- 글자: 높이 40, 회전 50, 72/73 이 0 이 아니면 11/21 을 기준점, 72 → text-anchor(0 start·1 middle·2 end·4 middle). %%c → Ø, %%d → °, %%p → ±, %%u 지움. MTEXT 는 3·1 을 이어 붙이고 서식 코드(\P → 공백, \f…; 등, 중괄호)를 지운다. font-family "Dotum,'sans-serif'".
- 출력: 루트 `<svg viewBox="0 0 443 443">` + `<style>* { vector-effect: non-scaling-stroke; }</style>`, 색마다 `<g fill="none" stroke="rgb(…)" stroke-width="1">`. 원본처럼 선은 화면 1 px.
- `make_model.sh` 가 이것도 돌려 출력 폴더에 `drawing_gen.svg` 를 만든다(private/ 이면 private/drawing_gen.svg).
### 29.2 도면 연결값 (추출 도구 `drawing_views`)
- 추출 도구는 `tools/views.json`(세 번째 인수로 다른 파일을 줄 수 있다)을 읽어 `MODEL.drawing` 을 만든다: src "private/drawing_gen.svg", viewBox·fit = views.json, section {x0, y0, mm_per_unit 1000} = secA 값, plan {cx, cy, mm_per_unit 1000} = plan1 값, views 11개(id·type·label·필드는 13.2·22장과 같다). 모든 좌표 = 도면 점을 그 상자 place 로 옮긴 값(소수 둘째 자리).
- secA·secB: 전체 단면 A-A(20.1 ① 벽 1~4)·B-B(24.2 벽 5~8)의 축 X 와 그 축의 지반선(20.1 ②) → x0, y0.
- secA2·secB2(section_break): `S-TITLE` 「단면 A-A」·「단면 B-B」(공백 무시) 글자 X 에서 가까운, 길이 10 m 넘는 `구조물` 세로선 X(1 cm 로 반올림, 중복 하나로) 4개 = 그 생략 단면의 벽. 축 = 바깥 두 선 가운데, 지반선(초록, ±0.05 m 로 축을 지나는 선) → x0, y0_upper. 층 글자(「숫자B」, 축에서 8 m 안)마다, 안쪽 왼쪽 벽선에서 시작하고 글자 아래 0.3 m 안에 있는 `구조물` 수평선 Y 로 그 층의 「지표에 해당하는 Y」 = Y − 그 층 윗면 z(20.1 ③)/1000 을 구한다. 지반선과 같은(±0.05) 층들 = 위 구간, 나머지 = 아래 구간: y0_lower = 아래 구간 값의 평균을 같은 place 로 옮긴 것, z_upper_min = 위 구간 가장 깊은 층 윗면 − 300, z_lower_max = 아래 구간 가장 높은 층 윗면.
- plan1 = 평면도 1 중심(24.1 ①), plan2 = 그 아래 30 m 안의 가장 가까운 반지름 4.85 원. rot_deg = −φ(24.1, 소수 첫째 자리). tunnel: plan1 true, plan2 false.
- 슬래브 평면 5개: 20.1 ④ 의 중심과 「하부슬래브(20B)」 중심. z1 = 홀수층(1·3·…·17B 윗면)·짝수층(2·…·16B)·[18B]·[19B]·[하부 슬래브 윗면], kind slab(20B 만 bottom), tunnel 은 19B 만 true.
- 대조(F43): 생성 SVG 가 XML 로 읽히고 viewBox 443, 요소 7000 개 이상·글자 400 개 이상, **원본 SVG(private/drawing.svg)와 선이 지나는 0.5 칸 비교(상자 안)에서 재현율·정밀도 0.85 이상**(원본의 해치는 DXF 에서 기준점이 (0,0) 이라 범위에서 빠져 1 이 되진 않는다) / 추출 drawing = model.js drawing(views 11개 숫자 ±0.05, z1 ±5, 플래그 같음, rot_deg ±0.1, viewBox·fit 같음) / views.json 의 평면도 상자 place 를 10 m 바꾼 사본으로 추출 → plan1·plan2 cx 만 10 바뀜.
- 코치는 이 규칙대로만 짠 별도 구현으로 SVG(재현율 0.885·정밀도 0.928)와 views 11개(정답지와 ±0.01)를 미리 확인했다.
## 30. 변경 — 화면을 도면에서 만든 모델로 전환 (2026-10-21 수석 지시, F44)
이제 3D 와 도면 연결이 모두 도면(DWG)에서 만들어진다(20~29장). 첫 화면을 그 모델로 바꾸고, 손으로 만든 정답 모델은 비교용 화면으로 남긴다.
- `index.html`: `model_extracted.js` 다음 `viewer.js` 를 읽는다(model.js 는 읽지 않는다). **1.x 의 F01 「model.js 다음 viewer.js」 를 이것으로 바꾼다.** 왼쪽 도면 = MODEL.drawing.src(private/drawing_gen.svg).
- `answer.html`(새 파일, 정답지 화면): index.html 과 같은 화면이되 `model.js` 다음 `viewer.js`. 제목에 「정답지」, 맨 위 안내 한 줄: 「손으로 만든 정답 모델 — 도면에서 만든 모델(index.html)을 검사하는 기준」. 왼쪽 도면 = model.js 의 drawing.src(private/drawing.svg, 수석이 준 SVG 처리본).
- `viewer.js`: 도면 그림 파일을 `MODEL.drawing.src` 에서 읽는다(없으면 'private/drawing.svg'). 「도면 파일 없음」 안내도 그 경로를 보인다.
- compare.html 은 그대로 둔다(F34 — 추출 모델 비교 화면, 이제 index.html 과 같은 데이터).
- README: 첫 화면 = 도면에서 만든 모델, answer.html = 정답지, 다시 만들기 = `bash tools/make_model.sh`.
- 구동 설명서(코치 작성, 이 장과 함께 커밋): 구동 쪽 — 여는 방법·도면에서 다시 만들기(형식별 용도 표)·잘 안 열릴 때 / 목적 쪽 — 3D 가 도면에서 만들어진다는 한 줄, 사다리 4개(위 2 + 환기구 쪽 2).
## 31. 추가 — README 「사용 도구」 절 (제출 점검, F45)
- 11.2 로 README 를 코드 설명으로 고쳐 쓸 때 빠진 「사용 도구」 절을 되살린다(제출 가산점: WSL2·git).
- README 에 `## 사용 도구` 절: WSL2 Ubuntu(그 아래 `wsl.exe -l -v` 출력을 코드 블록에 그대로 — WSL 안에서는 `wsl.exe -l -v`, 글자 사이 빈 칸은 지워도 된다), Claude Code(Haiku 5.5)가 코드 작성·수정·커밋(커밋마다 Co-Authored-By 줄), 설계 문서·검사(SPEC.md·FEATURES.json·check.sh·구동 설명서)는 수석 지시로 코치 세션이 쓰고 하이쿠가 KIT 커밋, GNU LibreDWG 0.14(DWG → DXF), Python 3 표준 라이브러리, git, 브라우저(Chrome·Edge).
- 대조(F45): README 에 「사용 도구」, `wsl -l -v` 출력(Ubuntu 와 VERSION 2), Haiku, LibreDWG, check.sh.
## 32. 변경 — 환경에 따라 못 하는 검사는 SKIP (2026-10-21 수석 지시 「압축에 DXF 를 넣어라, 실패가 나오는 건 싫어」)
- 제출 압축에는 `private/drawing.dxf`·`drawing.svg`·`drawing_gen.svg` 가 들어가고 원본 DWG 는 빠진다. 평가자 PC 에는 GNU LibreDWG(dwg2dxf)가 없을 수 있다.
- 그래서 F42 의 「원본 DWG 로 make_model.sh 를 돌려 보는」 검사 3개는 dwg2dxf 나 private/drawing.dwg 가 없으면 FAIL 이 아니라 **SKIP**(이유 표시)으로 센다. 둘이 다 있는 개발 환경에서는 지금처럼 돈다. 다른 검사는 그대로.
## 33. 추가 — WSL2 설치 증빙 파일 (2026-10-22 수석 지시 「WSL2 를 설치했다는 증빙자료가 없다」, F46)
- `evidence/wsl2.txt`: 명령 출력 그대로 모은 증빙.
- 앞부분 「Windows 쪽」: 코치 세션이 Windows PowerShell 에서 실행해 모은 출력(wsl --version, wsl -l -v, wsl --status, 등록된 배포판과 가상 디스크 ext4.vhdx 를 만든 날 = 설치일). 이 장과 함께 커밋한다.
- 뒷부분 「WSL 안쪽」: 하이쿠가 WSL 안에서 직접 실행해 덧붙인다 — 수집 시각(date), `uname -r`(커널 이름에 microsoft-standard-WSL2), `cat /proc/version`, `/etc/os-release` 의 PRETTY_NAME, `pwd`(작업 폴더 /mnt/d/…), `claude --version`, `git --version`, `python3 --version`, `dwg2dxf --version` 첫 줄. 명령마다 `$ 명령` 줄 다음에 출력 그대로.
- README 「사용 도구」: 예전 커밋에서 옮겨 적은 출력 대신 `evidence/wsl2.txt` 를 가리키고, 설치일(가상 디스크 만든 날)과 오늘 `wsl -l -v` 출력을 적는다.
- 대조(F46): evidence/wsl2.txt 에 `wsl -l -v` 출력(Ubuntu, VERSION 2)·설치일·`microsoft-standard-WSL2`·Ubuntu 버전·claude 버전, README 가 그 파일을 가리킨다.
## 34. 공개용 정리본 (2026-10-27 수석 지시 — 사내 Gitea `tdc_engineer/drawing3d-web` 에 올림)
- 이 저장소는 1차 제출본(커밋 81개, 로컬 보존)을 **공개용으로 정리해 새 이력으로 시작**한 것이다. 옛 이력에는 도면 실제 좌표가 들어 있어 그대로 올리지 않았다. 옛 커밋 기록(메시지)은 `evidence/gitlog_v1.txt`.
- 도면 정리(코치, 저장소 밖 도구): 우리가 쓰는 그림 객체와 거기 딸린 블록·레이어·선종류만 남기고, 과업명·지명·외부 참조 이름·저장자·XDATA·OBJECTS 를 지웠다. **모든 좌표를 같은 양만큼 평행이동**해 실제 위치를 알 수 없게 했다(옮긴 양은 기록하지 않는다).
- 같은 양을 `tools/views.json`(그림 상자·배치)과 `check.sh`(채점기만 아는 기준점·변조 시험 좌표)에도 적용했다. 추출 결과(3D 데이터 95부재)·도면 연결 11개·생성 SVG 는 옮기기 전과 같다(SVG 바이트 동일, 부재는 1e-9 mm 잡음 하나).
- `private/` 에 올리는 것: `drawing.dxf`(정리본), `drawing.svg`(정답지 화면용 그림), `drawing_gen.svg`(첫 화면 그림). 원본 DWG 는 올리지 않으므로 F42 의 DWG 재변환 검사 3건은 SKIP(32장). `.gitignore` 는 `private/` 전체 대신 `private/*.dwg`·`private/full.dxf` 등을 뺀다 — F13 의 「.gitignore 에 private/」 기준을 이것으로 바꾼다.
+50
View File
@@ -0,0 +1,50 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>drawing3d-web 정답지</title>
<style>
html, body { margin: 0; height: 100%; overflow: hidden; background: #1e1e1e; }
canvas { display: block; }
#wrap { display: flex; height: 100%; }
#drawPane { position: relative; width: 50%; overflow: hidden; background: #252526; color: #cccccc; }
#drawMsg { margin: 24px; font-family: sans-serif; }
#drawStage { position: absolute; left: 0; top: 0; width: 443px; height: 443px; transform-origin: 0 0; display: none; }
#drawStage img, #drawStage svg { position: absolute; left: 0; top: 0; width: 443px; height: 443px; }
#splitter { width: 6px; flex: none; cursor: col-resize; background: #3c3c3c; }
#viewPane { position: relative; flex: 1; overflow: hidden; }
#hotspots path { fill: transparent; stroke-width: 2; vector-effect: non-scaling-stroke; }
#hotspots path:hover { stroke: #ffd400; }
#hotspots path.on { fill: #ffd400; fill-opacity: 0.35; }
#partLabel { position: absolute; left: 8px; top: 8px; right: 120px; color: #ffd400; font-family: sans-serif; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
#guideLink { position: absolute; right: 8px; top: 8px; padding: 2px 8px; font: 12px sans-serif; color: #cccccc; background: #3c3c3c; border-radius: 3px; text-decoration: none; opacity: 0.85; }
#resetView { position: absolute; right: 8px; top: 36px; padding: 2px 8px; font: 12px sans-serif; color: #cccccc; background: #3c3c3c; border: 0; border-radius: 3px; opacity: 0.85; cursor: pointer; }
#walkToggle { position: absolute; right: 8px; top: 64px; padding: 2px 8px; font: 12px sans-serif; color: #cccccc; background: #3c3c3c; border: 0; border-radius: 3px; opacity: 0.85; cursor: pointer; }
#note { position: fixed; left: 0; right: 0; bottom: 0; padding: 4px 8px; font: 12px sans-serif; color: #cccccc; background: #3c3c3c; opacity: 0.9; }
#note a { color: #ffd400; }
</style>
</head>
<body>
<div id="wrap">
<div id="drawPane">
<p id="drawMsg">도면 파일 없음 — 도면 파일을 넣으세요</p>
<div id="drawStage">
<img id="drawing" alt="" draggable="false">
<svg id="hotspots" viewBox="0 0 443 443"></svg>
</div>
</div>
<div id="splitter"></div>
<div id="viewPane">
<canvas id="view"></canvas>
<div id="partLabel"></div>
<a id="guideLink" href="guide/index.html">구동 설명서</a>
<button id="resetView">처음 화면</button>
<button id="walkToggle">사람 ❚❚</button>
</div>
</div>
<div id="note">손으로 만든 정답 모델 — 도면에서 만든 모델(<a href="index.html">index.html</a>)을 검사하는 기준</div>
<script src="model.js"></script>
<script src="viewer.js"></script>
</body>
</html>
+1087
View File
File diff suppressed because it is too large. Load diff
+49
View File
@@ -0,0 +1,49 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>drawing3d-web — 추출 데이터 비교</title>
<style>
html, body { margin: 0; height: 100%; overflow: hidden; background: #1e1e1e; }
canvas { display: block; }
#wrap { display: flex; height: 100%; }
#drawPane { position: relative; width: 50%; overflow: hidden; background: #252526; color: #cccccc; }
#drawMsg { margin: 24px; font-family: sans-serif; }
#drawStage { position: absolute; left: 0; top: 0; width: 443px; height: 443px; transform-origin: 0 0; display: none; }
#drawStage img, #drawStage svg { position: absolute; left: 0; top: 0; width: 443px; height: 443px; }
#splitter { width: 6px; flex: none; cursor: col-resize; background: #3c3c3c; }
#viewPane { position: relative; flex: 1; overflow: hidden; }
#hotspots path { fill: transparent; stroke-width: 2; vector-effect: non-scaling-stroke; }
#hotspots path:hover { stroke: #ffd400; }
#hotspots path.on { fill: #ffd400; fill-opacity: 0.35; }
#partLabel { position: absolute; left: 8px; top: 8px; right: 120px; color: #ffd400; font-family: sans-serif; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
#compareTag { position: absolute; left: 8px; bottom: 8px; color: #cccccc; font: 12px sans-serif; opacity: 0.85; pointer-events: none; }
#guideLink { position: absolute; right: 8px; top: 8px; padding: 2px 8px; font: 12px sans-serif; color: #cccccc; background: #3c3c3c; border-radius: 3px; text-decoration: none; opacity: 0.85; }
#resetView { position: absolute; right: 8px; top: 36px; padding: 2px 8px; font: 12px sans-serif; color: #cccccc; background: #3c3c3c; border: 0; border-radius: 3px; opacity: 0.85; cursor: pointer; }
#walkToggle { position: absolute; right: 8px; top: 64px; padding: 2px 8px; font: 12px sans-serif; color: #cccccc; background: #3c3c3c; border: 0; border-radius: 3px; opacity: 0.85; cursor: pointer; }
</style>
</head>
<body>
<div id="wrap">
<div id="drawPane">
<p id="drawMsg">추출 데이터 비교 화면 — 도면 연결 데이터가 없어 왼쪽은 글자만 둡니다. 도면과 맞춰 보려면 index.html 을 여세요.</p>
<div id="drawStage">
<img id="drawing" alt="" draggable="false">
<svg id="hotspots" viewBox="0 0 443 443"></svg>
</div>
</div>
<div id="splitter"></div>
<div id="viewPane">
<canvas id="view"></canvas>
<div id="partLabel"></div>
<div id="compareTag">추출 데이터로 그린 화면</div>
<a id="guideLink" href="guide/index.html">구동 설명서</a>
<button id="resetView">처음 화면</button>
<button id="walkToggle">사람 ❚❚</button>
</div>
</div>
<script src="model_extracted.js"></script>
<script src="viewer.js"></script>
</body>
</html>
+667
View File
@@ -0,0 +1,667 @@
0b0eb27 2026-10-22 09:24:34 +0900 오문성
DOC: 제출 전 문서 표현 다듬기 (구동 설명서·README)
수석 지시. guide/run.html 은 코치 세션이 표현을 고침, README 사용 도구 절은 하이쿠가 고침.
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
c577e48 2026-10-22 09:23:11 +0900 오문성
F46: WSL2 설치 증빙 (SPEC 33장)
evidence/wsl2.txt 에 WSL 안쪽 출력(uname -r, /proc/version,
PRETTY_NAME, claude/git/python3/dwg2dxf 버전) 덧붙임.
README 사용 도구에 설치일(2026-07-09)과 증빙 파일 연결.
결과: PASS 154 / FAIL 0 / SKIP 0 (검사 범위 F01~F46)
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2d1a317 2026-10-22 09:21:24 +0900 오문성
KIT24: 설계 추가 — WSL2 설치 증빙 (SPEC 33장, F46)
수석 지시: "WSL2 를 설치했다는 증빙자료가 없다".
설계 문서(SPEC 33장, FEATURES F46, check.sh F46 검사)와
Windows 쪽 출력(evidence/wsl2.txt)은 코치 세션이 작성·수집.
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
a488d7d 2026-10-22 09:05:11 +0900 오문성
DOC: 17B→18B 계단 도면 메모 삭제 (SPEC 21장·PROGRESS)
수석 지시. SPEC 줄은 코치 세션이 지움, PROGRESS 줄은 하이쿠가 고침.
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
61a3a52 2026-10-21 21:17:12 +0900 오문성
KIT23: 설계 변경 — 도구가 없는 환경의 F42 검사는 SKIP (SPEC 32장)
수석 지시 「압축에 DXF 를 넣어라, 실패가 나오는 건 싫어」.
제출 압축에는 원본 DWG 가 없고, 평가자 PC 에는 LibreDWG 가 없을 수 있다.
SPEC 32장: 환경에 따라 못 하는 검사는 SKIP.
check.sh: skip 함수 추가, F42 의 make_model 검사 3개는 dwg2dxf·원본 DWG 가 없으면 SKIP.
설계 변경은 코치 세션 작성.
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
0cf3d38 2026-10-21 21:08:05 +0900 오문성
F45: README 사용 도구 절 복원 (SPEC 31장)
wsl -l -v 출력은 8f606f5 에 기록된 값을 옮겼다 (WSL 안에서 wsl.exe 호출 불가).
check.sh F45 결과: PASS 151 / FAIL 0 / SKIP 0 (검사 범위 F01~F45)
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
95dfc00 2026-10-21 21:06:24 +0900 오문성
KIT22: 설계 추가 — README 사용 도구 절 (SPEC 31장, F45)
제출 점검에서 빠진 항목. 설계 문서는 수석 지시로 코치 세션에서 작성.
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
fb6d9fb 2026-10-21 21:04:33 +0900 오문성
F44: 화면 전환 — index.html = 도면에서 만든 모델, answer.html = 정답지 (SPEC 30장)
결과: PASS 150 / FAIL 0 / SKIP 0 (검사 범위 F01~F44)
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
3864913 2026-10-21 21:02:14 +0900 오문성
KIT21: 설계 변경 — 화면을 도면에서 만든 모델로 전환 (SPEC 30장, F44)
설계 문서와 구동 설명서는 수석 지시로 코치 세션에서 작성.
F01 의 「index.html 이 model.js 를 읽는다」를 「model_extracted.js 를 읽는다」로 바꿈 (30장).
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
4d8a476 2026-10-21 21:01:16 +0900 오문성
F43: DXF → SVG 변환과 도면 연결값을 도면에서 계산 (SPEC 29장)
- tools/dxf_to_svg.py: DXF 를 상자 배치(views.json place)대로 SVG 로 옮긴다 (29.1)
- tools/extract_from_dxf.py: views.json 을 세 번째 인수로 받아 MODEL.drawing 을 계산 (29.2)
- tools/make_model.sh: drawing_gen.svg 단계 추가, 추출 도구에 views.json 전달
- model_extracted.js·README 갱신
check.sh 마지막 줄: 결과: PASS 144 / FAIL 0 / SKIP 0 (검사 범위 F01~F43)
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
9e9cb56 2026-10-21 20:54:08 +0900 오문성
KIT20: 설계 추가 — DXF→SVG 와 도면 연결값 (SPEC 29장, F43)
설계 문서와 배치값은 수석 지시로 코치 세션에서 작성.
코치 별도 구현으로 SVG 재현율 0.885·정밀도 0.928, 도면 연결 11개 정답지 ±0.01 확인.
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
f6a44c5 2026-10-21 20:49:15 +0900 오문성
F42: 변환 패키지 — DWG → DXF → 도면 범위 (SPEC 28장)
- tools/dwg_to_dxf.sh: dwg2dxf 래퍼 (PATH, ~/.local/bin 에서 찾음)
- tools/select_views.py: views.json 의 그림 상자 안 객체만 남김 (좌표 숫자 없음)
- tools/make_model.sh: DWG → full.dxf → drawing.dxf → model_extracted.js
- README: 「변환 패키지」 절 (설치·실행·파일 용도)
결과: PASS 138 / FAIL 0 / SKIP 0 (검사 범위 F01~F42)
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
16d22eb 2026-10-21 20:46:56 +0900 오문성
KIT19: 설계 추가 — 변환 패키지 DWG→DXF→도면 범위 (SPEC 28장, F42)
설계 문서와 범위 파일은 수석 지시로 코치 세션에서 작성.
코치 별도 구현으로 원본 DWG → 3,356 객체 재현과 채점 통과 확인.
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
a40ed94 2026-10-21 20:42:43 +0900 오문성
F41: 설계 규칙 파일 — 추출 모델이 정답지 부재 전체를 갖는다 (SPEC 27장)
- tools/design_rules.json: 🔶 부재 6개(사다리 3·환기구 사다리 2·배출관)와 kind·id 별 색·투명도
- extract_from_dxf.py: load_design_rules, apply_design_rules 로 부재를 붙이고 색·투명도를 덮어씀
- model_extracted.js 다시 생성 (부재 95개), README 도구 설명 갱신
- check.sh F41: PASS 132 / FAIL 0 / SKIP 0
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
727dcc7 2026-10-21 20:40:45 +0900 오문성
KIT18: 설계 추가 — 설계 규칙 파일, 추출 모델 = 정답지 전체 (SPEC 27장, F41)
설계 문서는 수석 지시로 코치 세션에서 작성.
코치 별도 구현으로 채점 통과 가능 확인.
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
290ee4a 2026-10-21 20:39:42 +0900 오문성
F40: 정답지 보강 — 환기구 사다리 2개 (SPEC 26장)
- viewer.js facesLadder: angle_deg 가 있으면 원점 기준으로 돌림 (법선 포함), 없으면 0
- model.js: ladder_vent_wall, ladder_vent_tower 추가 (kind ladder_vent)
- .gitignore: __pycache__/ 추가
결과: PASS 126 / FAIL 0 / SKIP 0 (검사 범위 F01~F40)
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
baefa34 2026-10-21 20:38:40 +0900 오문성
KIT17: 설계 추가 — 정답지 보강, 환기구 사다리 2개 (SPEC 26장, F40)
도면 DXF 단면 B-B 에 있으나 정답지에 없던 사다리 2개.
설계 문서는 수석 지시로 코치 세션에서 작성.
코치 별도 구현으로 채점 통과 가능 확인.
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
a95ae91 2026-10-21 20:37:34 +0900 오문성
F39: 나머지 부재 추출 — 상부 슬래브·맨홀 목·19B 패드·침사구·하부 슬래브·바닥 콘크리트·월류벽·집수조·침사조 (SPEC 25장)
check.sh 본문: 벽 높이 고정값 제거(도면 수평선에서 읽음), 20B 평면 제목 추가.
bash check.sh 마지막 줄: 결과: PASS 124 / FAIL 0 / SKIP 0 (검사 범위 F01~F39)
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
5bf8a32 2026-10-21 20:30:54 +0900 오문성
KIT16: 설계 추가 — 나머지 부재 추출 (SPEC 25장, F39)
설계 문서는 수석 지시로 코치 세션에서 작성.
코치 별도 구현으로 채점 통과 가능 확인.
벽 높이 고정값 허용(19.1)을 끝내고 도면에서 읽게 함.
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
0864c2c 2026-10-21 20:24:49 +0900 오문성
F38: 분기구·환기구 추출 — 추출 모델에도 상부 구조 (SPEC 24장)
결과: PASS 113 / FAIL 0 / SKIP 0 (검사 범위 F01~F38)
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
42fadfd 2026-10-21 20:18:33 +0900 오문성
KIT15: 설계 추가 — 분기구·환기구 추출 (SPEC 24장, F38)
설계 문서는 수석 지시로 코치 세션에서 작성.
코치 별도 구현으로 채점 통과 가능 확인(각도를 일부러 틀린 사본은 FAIL 확인).
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
88351f1 2026-10-21 20:11:29 +0900 오문성
F37: 터널·채움 추출 — 추출 모델에도 TBM 터널 (SPEC 23장)
check.sh 결과: 결과: PASS 108 / FAIL 0 / SKIP 0 (검사 범위 F01~F37)
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
e9ee09d 2026-10-21 20:09:24 +0900 오문성
KIT14: 설계 추가 — 터널 추출 (SPEC 23장, F37)
설계 문서는 수석 지시로 코치 세션에서 작성.
코치 별도 구현으로 채점 통과 가능 확인.
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2d44c44 2026-10-21 20:05:38 +0900 오문성
F36: 정정 — 정답지 좌표틀 슬래브 평면 틀로 (SPEC 22장)
model.js 를 22장 표대로 고침(터널·잡석·패드 0°, 상부 슬래브·맨홀 목 축 정렬, 분기구 197.3°, 환기구 30.3°, 위쪽 사다리 W). 평면도 1·2 에 rot_deg -30.3, viewer.js planXY·planPoly 가 rot_deg 로 돌린다.
check.sh F36: 결과: PASS 103 / FAIL 0 / SKIP 0 (검사 범위 F01~F36)
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
035762f 2026-10-21 20:04:09 +0900 오문성
KIT13: 설계 정정 — 평면도 좌표틀 30.3° (SPEC 22장, F36)
도면 DXF 재확인으로 평면도 출처 부재(터널·채움·분기구·환기구·상부 맨홀·목)가
슬래브 평면 틀과 30.3° 어긋남을 발견. 설계 문서·설명서 문구는 수석 지시로
코치 세션에서 작성. 코치 별도 구현으로 채점 통과 가능 확인.
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
7adbc46 2026-10-21 19:33:01 +0900 오문성
F35: 계단 18런 추출 — 추출 모델에서도 사람 두 명이 걷는다
section_stairs, stair_profile, stair_dir, stair_parts 추가 (SPEC 21장).
model_extracted.js 다시 생성, README 도구 설명 갱신.
결과: PASS 100 / FAIL 0 / SKIP 0 (검사 범위 F01~F35)
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
d3df994 2026-10-21 19:29:42 +0900 오문성
KIT12: 설계 추가 — 계단 추출 (SPEC 21장, F35)
설계 문서는 수석 지시로 코치 세션에서 작성.
코치 별도 구현으로 채점 통과 가능 확인.
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
da956f9 2026-10-21 14:55:12 +0900 오문성
F29: 보완 — 위쪽 사다리 단면 클릭 영역 NaN 고침
xSpan 이 사다리를 kind 로 찾아서 ladder_up 부재가 빠졌다. type 으로 찾게 고쳤다.
결과: PASS 81 / FAIL 0 / SKIP 17 (검사 범위 F01~F29)
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
537709d 2026-10-21 14:53:39 +0900 오문성
F34: 보완 — 계단·도면 데이터 없는 모델도 그리기(compare.html)
check.sh 결과: PASS 98 / FAIL 0 / SKIP 0 (검사 범위 F01~F34)
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
fea9d51 2026-10-21 14:50:40 +0900 오문성
F34: 대조 검사 — compare.html 추가, MODEL.drawing 없을 때 건너뜀 (SPEC 20.2)
결과: PASS 98 / FAIL 0 / SKIP 0 (검사 범위 F01~F34)
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
9f7f145 2026-10-21 14:49:28 +0900 오문성
F33: 도면 추출 도구 — DXF 에서 model_extracted.js 생성 (SPEC 20.1)
bash check.sh F33 의 마지막 줄: 결과: PASS 89 / FAIL 0 / SKIP 9 (검사 범위 F01~F33)
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
71f6908 2026-10-21 14:43:31 +0900 오문성
KIT11: 설계 변경 — 추출 입력을 DXF 로 (SPEC 20장, F33~F34)
설계 문서는 수석 지시로 코치 세션에서 작성. SVG 규칙 결함(18B 윗면, T자 구멍)을 하이쿠 보고로 확인해 DXF 규칙으로 대체.
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
85d3603 2026-10-21 14:16:23 +0900 오문성
KIT10: 설계 추가 — 도면 추출 도구와 대조 검사 (SPEC 19장, F33~F34)
설계 문서는 수석 지시로 코치 세션에서 작성.
추출 규칙만 주고 정답 값은 check.sh 대조에만 둔다.
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
9db5f6c 2026-10-21 10:03:08 +0900 오문성
F32: 구동설명서 3쪽으로 정리 — 목적·구동·조작, 🔒 줄과 나머지 쪽 삭제
설명서 본문은 수석 지시로 코치 세션 작성 (SPEC 18.3).
guide/plan·scope·limits·method·history 삭제, 목차·목적·구동·조작 3쪽만 남김.
결과: PASS 86 / FAIL 0 / SKIP 0 (검사 범위 F01~F32)
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
b0597f4 2026-10-21 10:02:22 +0900 오문성
F31: 콘크리트 계단 — 경사판 밑면, 계단 아래 빈 공간 (SPEC 18.2)
stairProfile 로 런마다 단면을 만들고, facesStair 가 Y 띠 폭만큼 늘인다.
check.sh F31 마지막 줄: PASS 83 / FAIL 0 / SKIP 0 (F01~F31)
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
83eb92b 2026-10-21 10:00:34 +0900 오문성
F30: 사람 두 명 — 두 번째 사람은 2초 뒤 같은 길 (SPEC 18.1)
- teamAt(t): 첫 사람 t, 두 번째 t−2000ms
- facesPerson 에 색 인자, WALK_COLORS (#ff8c1a, #e07010)
- walkerAt 음수 시각도 경로 안으로 접는다
- README 사람 애니메이션 절 갱신
결과: PASS 82 / FAIL 0 / SKIP 4 (검사 범위 F01~F30)
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
9c72f9f 2026-10-21 09:59:41 +0900 오문성
KIT9: 설계 변경 — 사람 두 명, 콘크리트 계단, 구동설명서 3쪽 (SPEC 18장, F30~F32)
설계 문서는 수석 지시로 코치 세션에서 작성. 계단 비례는 수석 제공 측면도 기준.
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2da2051 2026-10-20 21:28:47 +0900 오문성
DOC: 사용 설명서 갱신 — 바닥부·상부 구조·환기구·난간·배관 반영
사용 설명서는 수석 사전 승인 아래 코치 세션이 작성.
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
bce638e 2026-10-20 21:26:52 +0900 오문성
F29: 보완 — 분기구·환기구는 덩어리 전체 선택
분기구·환기구 클릭 영역이 같은 kind 의 부재 전체 id 를 가진다.
선택 문구는 kind 이름 (분기구, 환기구(강제)). 목·사다리는 하나씩.
check.sh: PASS 82 / FAIL 0 / SKIP 0 (F01~F29)
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
1b7780a 2026-10-20 21:24:22 +0900 오문성
F29: 상부 부재 도면 연결 — 단면 A-A·평면도 1·2 (SPEC 17.4)
check.sh 마지막 줄:
결과: PASS 82 / FAIL 0 / SKIP 0 (검사 범위 F01~F29)
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
1315356 2026-10-20 21:23:01 +0900 오문성
F28: 방호 난간 18장·펌프 배출관·위쪽 사다리 2개 (SPEC 17.3)
PASS 81 / FAIL 0 / SKIP 1 (F01~F28, F29 범위 밖)
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
22fbe19 2026-10-20 21:20:55 +0900 오문성
F27: 분기구(obox 5)·환기구 덕트와 환기탑(obox 13) (SPEC 17.2)
결과: PASS 77 / FAIL 0 / SKIP 5 (검사 범위 F01~F27)
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
5835a12 2026-10-20 21:19:24 +0900 오문성
F26: 상부 슬래브·맨홀 목 2개·벽 윗면 −2000·전기 배선구 구멍 정정 (SPEC 17.1)
holePolygon(원 구멍 → 16각형), addHoles(뚜껑·옆면 구멍) 추가, facesPrism 에 holes 지원.
README 데이터·코드 절 갱신.
결과: PASS 74 / FAIL 0 / SKIP 8 (검사 범위 F01~F26)
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
6a3b2eb 2026-10-20 21:16:51 +0900 오문성
KIT8: 설계 추가 — 상부 구조·환기구·방호 난간·배관 (SPEC 17장, F26~F29)
설계 문서는 수석 사전 승인 아래 코치 세션에서 작성.
벽 윗면(-2000)과 전기 배선구 모양은 도면 근거로 정정.
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
91cce5c 2026-10-20 21:15:08 +0900 오문성
F25: 바닥부 도면 연결 — 단면 A-A·20B 평면·19B 침사구 (SPEC 16.2)
- viewer.js: xSpan, planXY, bottomPlanPoly, bottomHotspots 추가; sectionPoly 가 xSpan 사용; buildHotspots 가 bottomHotspots 를 viewHotspots 뒤에 이음
- 순서: 집수조 → 침사조 → 월류벽 → 사다리
- README.md: 코드 절에 새 함수 4개
결과: PASS 71 / FAIL 0 / SKIP 0 (검사 범위 F01~F25)
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
48cfcd1 2026-10-20 21:13:12 +0900 오문성
F24: 바닥부 월류벽·집수조·침사조·침사구·사다리 (SPEC 16장)
model.js: 월류벽 3·집수조·침사조 zone 2·사다리 1, 19B 슬래브·패드 침사구 구멍 (SPEC 16.1)
viewer.js: facesPrism, facesZone, facesLadder 추가, buildFaces 가 prism·zone·ladder 를 받음
README: 데이터 표·코드 절 추가
결과: PASS 70 / FAIL 0 / SKIP 1 (검사 범위 F01~F24)
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
4ff5433 2026-10-20 21:10:56 +0900 오문성
KIT7: 설계 추가 — 바닥부(월류벽·집수조·침사조·침사구·사다리) (SPEC 16장, F24~F25)
설계 문서는 수석 사전 승인 아래 코치 세션에서 작성. 19B 사다리 위치는 도면 근거로 정정.
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
8f0618e 2026-10-20 21:05:42 +0900 오문성
DOC: 사용 설명서 갱신 — 슬래브 구멍, 카메라 이동, 사람 애니메이션 반영
사용 설명서는 수석 지시로 코치 세션이 작성.
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
bc77946 2026-10-20 21:02:13 +0900 오문성
F23: 사람 애니메이션 — 계단 18런을 쉬지 않고 오르내림, 사람 ▶/❚❚ 버튼 (SPEC 15.4)
check.sh: 결과: PASS 65 / FAIL 0 / SKIP 0 (검사 범위 F01~F23)
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
4b79fc6 2026-10-20 20:59:42 +0900 오문성
F22: 카메라 이동 — 오른쪽 드래그·Shift+왼쪽 화면 이동, 휠 마우스 기준 확대 0.5~40, 더블클릭·처음 화면 버튼 (SPEC 15.3)
결과: PASS 62 / FAIL 0 / SKIP 3 (검사 범위 F01~F22)
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
91bd8ce 2026-10-20 20:57:51 +0900 오문성
F21: 슬래브 구멍 holes + 계단 방향 정정 (SPEC 15.1~15.2)
- model.js: 슬래브 19장·19B 패드에 holes (15.1 표), 계단 18런 x_top·dir·Y 띠 정정 (15.2)
- viewer.js: facesDiscHoles 추가, buildFaces 가 disc 에 사용, draw 가 holes 를 evenodd 로 채움
- README.md: facesDiscHoles 한 줄
결과: 결과: PASS 59 / FAIL 0 (F01~F21)
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
cd7a04a 2026-10-20 20:54:19 +0900 오문성
KIT6: 설계 추가 — 슬래브 구멍, 계단 방향 정정, 카메라 이동, 사람 애니메이션 (SPEC 15장, F21~F23)
설계 문서는 수석 지시로 코치 세션에서 작성.
18B·19B 구멍은 수석 확인.
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
5cab441 2026-10-20 19:15:45 +0900 오문성
DOC: 사용 설명서로 다시 쓰기 — guide/ 9쪽 (SPEC 14장)
사용 설명서는 수석 지시로 코치 세션이 작성.
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
b26c6e0 2026-10-20 19:13:19 +0900 오문성
F20: 보완 — 선택 글자 줄임, 설명서 버튼과 겹침 해소
PASS 56 / FAIL 0 (F01~F20)
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
ccee552 2026-10-20 19:11:28 +0900 오문성
F20: 모든 그림 연결 — views 11개, 그림별 클릭 영역, 여러 부재 한 번에 선택 (SPEC 13.2)
- model.js: drawing.views 11개 (SPEC 13.2 표 그대로)
- viewer.js: viewHotspots, sectionPoly(p, view), planPoly(p, view), circlePoly, region, pathD
- 클릭 영역을 path 로 (벽 고리는 evenodd 로 가운데를 비움), selectPart 가 id 배열을 받음
- index.html: 영역 CSS 셀렉터를 polygon → path 로
- README.md: 새 함수 한 줄 설명
결과: PASS 56 / FAIL 0 / SKIP 0 (검사 범위 F01~F20)
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
85c1be6 2026-10-20 19:07:20 +0900 오문성
F19: 선명한 도면 — prep_drawing.py 로 1px 선 SVG, applyDrawView 로 실제 크기 확대 (SPEC 13.1)
결과: PASS 54 / FAIL 0 / SKIP 2 (검사 범위 F01~F19)
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2311d55 2026-10-20 19:06:09 +0900 오문성
KIT5: 설계 추가 — 선명한 도면, 모든 그림 연결, 사용 설명서 (SPEC 13~14장, F19~F20)
설계 문서는 수석 지시로 코치 세션에서 작성.
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
abb0bed 2026-10-20 15:32:08 +0900 오문성
F18: 구동설명서 보완 — 목차 링크, 작업 방식 사실 정정
결과: PASS 50 / FAIL 0 / SKIP 0 (검사 범위 F01~F18)
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
db41daa 2026-10-20 15:30:02 +0900 오문성
F18: 구동설명서를 guide/ HTML 위키로 (목차 + 8쪽), 3D 칸 「구동 설명서」 버튼 (SPEC 12장)
구동설명서.md 내용을 guide/ 8쪽으로 나눠 옮기고 md 는 리포 밖 _소거대기로 옮김.
모든 쪽에 🔒, h1, 「돌아가기」, 다른 쪽 링크. index.html 에 a#guideLink.
결과: PASS 50 / FAIL 0 / SKIP 0 (검사 범위 F01~F18)
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
441a45b 2026-10-20 15:27:35 +0900 오문성
KIT4: 설계 변경 — 구동설명서를 HTML 위키로, 3D 칸 버튼 (SPEC 12장, F18)
설계 문서는 수석 지시로 코치 세션에서 작성.
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
a5b833f 2026-10-20 15:26:50 +0900 오문성
F17: README.md 를 코드 설명으로 고쳐 쓰기 — 파일 구조·실행·데이터·코드·그리기 방식·도면 연결·검사 (SPEC 11.2)
PASS 45 / FAIL 0 (F01~F17)
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
9731f89 2026-10-20 15:25:58 +0900 오문성
F16: 구동설명서.md — 목적·기획·범위·구동·조작·작업 방식·한계·이력 (SPEC 11.1)
근거는 SPEC.md, PROGRESS.md, git log 에서만 적었다.
check.sh 마지막 줄: 결과: PASS 43 / FAIL 0 / SKIP 2 (검사 범위 F01~F16)
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
7c6384a 2026-10-20 15:25:11 +0900 오문성
F15: 도면 클릭 → 3D 강조 — drawing 데이터, 부재별 클릭 영역, 선택 표시
결과: PASS 41 / FAIL 0 / SKIP 4 (검사 범위 F01~F15)
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
ad8b21f 2026-10-20 15:23:05 +0900 오문성
F14: 도면 확대·이동 — 도면 img+겹친 svg, 처음 fit 맞춤, 휠 마우스 기준 확대, 드래그 이동
결과: PASS 39 / FAIL 0 / SKIP 6 (검사 범위 F01~F14)
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
36b85fb 2026-10-20 15:21:28 +0900 오문성
F13: 좌우 분할 화면 — 도면 칸·가운데 막대·3D 칸, 막대로 폭 15%~85%
index.html 에 #drawPane, #splitter, #viewPane 배치.
viewer.js 에 onSplitDown/onSplitMove/onSplitUp 추가, 캔버스는 #viewPane 크기에 맞춘다.
private/drawing.svg 가 없으면 왼쪽 칸에 안내 글자.
결과: PASS 37 / FAIL 0 / SKIP 8 (검사 범위 F01~F13)
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
ce4de76 2026-10-20 15:20:29 +0900 오문성
KIT3: 설계 추가 — 제출 문서 두 개 (SPEC 11장, F16~F17)
설계 문서는 수석 지시로 코치 세션에서 작성.
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
13e9f1b 2026-10-20 15:19:17 +0900 오문성
KIT2: 설계 추가 — 도면 분할 화면·도면 클릭 연결 (SPEC 10장, F13~F15)
설계 문서는 수석 지시로 코치 세션에서 작성.
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
8f606f5 2026-10-20 14:34:45 +0900 오문성
F12: README 에 wsl -l -v 출력 추가
check.sh F12 결과: PASS 33 / FAIL 0 / SKIP 0 (검사 범위 F01~F12)
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
8e2d6c6 2026-10-20 14:31:24 +0900 오문성
F12: README — 구동 방법 3줄, 조작법, 모델 설명, 사용 도구, 🔒 표기
결과: PASS 33 / FAIL 0 / SKIP 0 (검사 범위 F01~F12)
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
e117833 2026-10-20 14:30:51 +0900 오문성
F11: 터널 안 채움 — model.js 에 fill 3개(잡석, 패드 2단, 패드 1단 폭 800), facesFill 로 단면을 축 방향으로 늘여 그리기, buildFaces 에 연결
check.sh F11: PASS 32 / FAIL 0 / SKIP 1 (검사 범위 F01~F11)
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
eab65b4 2026-10-20 14:29:22 +0900 오문성
F10: 계단 18런 — model.js 에 stair 18개(단 수 17, 마지막 런 14, 첫 단 100·단 200·디딤판 300·폭 1000, dir 번갈아), facesStair 로 디딤판마다 블록 그리기, buildFaces 에 연결
check.sh F10: PASS 28 / FAIL 0 / SKIP 5 (검사 범위 F01~F10)
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
cc19664 2026-10-20 14:27:00 +0900 오문성
F09: TBM 터널 — model.js 에 pipe 1개(330도, 길이 20650, r 1500/1300), facesPipe 로 누운 속 빈 원통 그리기, buildFaces 에 pipe 연결
check.sh F09: PASS 23 / FAIL 0 / SKIP 10 (검사 범위 F01~F09)
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
bc91891 2026-10-20 14:26:26 +0900 오문성
F08: ring 그리기 — facesRing 32각형 속 빈 원통(바깥·안쪽·윗고리·아랫고리), buildFaces 가 ring 도 모음, 벽 alpha 0.25
결과: PASS 19 / FAIL 0 / SKIP 14 (검사 범위 F01~F08)
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
9737fc1 2026-10-20 14:25:32 +0900 오문성
F07: disc 그리기 — facesDisc 32각형 원판, buildFaces 로 모델 모으기, 처음 배율을 모델 크기에 맞춤, 시험 큐브 제거
check.sh F07 결과: PASS 18 / FAIL 0 / SKIP 15 (검사 범위 F01~F07)
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
16fa0b5 2026-10-20 14:24:39 +0900 오문성
F06: model.js 데이터 — 벽체, 중간 슬래브 19장, 19B 패드, 하부 슬래브, 바닥콘크리트
SPEC 4.1~4.3 표의 숫자를 그대로 parts 에 적었다(mm). const MODEL = 뒤는 순수 JSON.
결과: PASS 17 / FAIL 0 / SKIP 16 (검사 범위 F01~F06)
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
3d83561 2026-10-20 13:56:42 +0900 오문성
F05: 휠 확대/축소 — onWheel 로 ZOOM 을 0.5~5 사이에서 한 칸 10%씩 바꾼다
결과: PASS 7 / FAIL 0 / SKIP 26 (검사 범위 F01~F05)
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
f75581c 2026-10-20 13:55:18 +0900 오문성
F04: 스파이크 3 — 마우스 드래그로 궤도 회전 (좌우 yaw, 위아래 pitch)
check.sh 마지막 줄: 결과: PASS 6 / FAIL 0 / SKIP 27 (검사 범위 F01~F04)
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
8a572fe 2026-10-20 13:53:45 +0900 오문성
F03: 스파이크 2 — 시험 큐브를 등각 정사 투영, 면 음영, 먼 면부터 그린다
viewer.js: facesBox, rotatePoint, project, shadeColor, sortFaces, draw 추가.
F02 의 고정 삼각형은 시험 큐브로 대체.
FEATURES.json: F03 passes true. PROGRESS.md 한 줄 추가.
PASS 5 / FAIL 0 / SKIP 28 (검사 범위 F01~F03)
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
4f64247 2026-10-20 13:51:43 +0900 오문성
F02: 스파이크 1 — 캔버스에 고정 좌표 삼각형 하나를 칠한다
결과: PASS 4 / FAIL 0 / SKIP 29 (검사 범위 F01~F02)
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
a3e68a2 2026-10-20 13:45:23 +0900 오문성
F01: 골격 — index.html, model.js, viewer.js 빈 뼈대, 캔버스가 창을 채운다
check.sh F01: PASS 3 / FAIL 0 / SKIP 30 (검사 범위 F01~F01)
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
3c2b652 2026-10-20 13:39:45 +0900 오문성
KIT: 설계 키트 추가 (SPEC·FEATURES·CLAUDE·check.sh·PROGRESS)
설계 문서는 설계 면담(수석 + 코치 세션)에서 작성, 수석 승인.
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
+52
View File
@@ -0,0 +1,52 @@
# WSL2 설치 증빙 — Windows 쪽 (수집 시각 2026-10-22 09:20:30 +09:00)
# 아래는 Windows PowerShell 에서 실행한 명령과 그 출력 그대로다.
PS> wsl --version
WSL 버전: 2.7.11.0
커널 버전: 6.18.33.2-2
WSLg 버전: 1.0.73.2
MSRDC 버전: 1.2.7214
Direct3D 버전: 1.611.1-81528511
DXCore 버전: 10.0.26100.1-240331-1435.ge-release
Windows 버전: 10.0.26300.9711
PS> wsl -l -v
NAME STATE VERSION
* Ubuntu Stopped 2
PS> wsl --status
기본 배포: Ubuntu
기본 버전: 2
현재 컴퓨터 구성에서는 WSL1이 지원되지 않습니다.
WSL1을 사용하려면 "Windows Subsystem for Linux” 선택적 구성 요소를 사용하세요.
PS> (등록된 WSL 배포판: HKCU\Software\Microsoft\Windows\CurrentVersion\Lxss)
배포판 Ubuntu / WSL 버전 2
가상 디스크 ext4.vhdx 만든 날 2026-07-09 09:54 (= 설치일), 크기 14,740,881,408 B
# WSL 안쪽 — 하이쿠가 WSL Ubuntu 안에서 직접 실행 (수집 시각 2026-10-22 09:22:19 +0900)
$ uname -r
6.18.33.2-microsoft-standard-WSL2
$ cat /proc/version
Linux version 6.18.33.2-microsoft-standard-WSL2 (root@f1bbfb02316b) (gcc (GCC) 13.2.0, GNU ld (GNU Binutils) 2.41) #1 SMP PREEMPT_DYNAMIC Thu Jun 18 21:54:43 UTC 2026
$ grep PRETTY_NAME /etc/os-release
PRETTY_NAME="Ubuntu 26.04 LTS"
$ pwd
/mnt/d/AX_Tasks/drawing3d-web
$ claude --version
2.1.304 (Claude Code)
$ git --version
git version 2.53.0
$ python3 --version
Python 3.14.4
$ dwg2dxf --version
dwg2dxf 0.14
+63
View File
@@ -0,0 +1,63 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>구동 설명서 — 조작</title>
<link rel="stylesheet" href="guide.css">
</head>
<body>
<nav class="top"><a class="back" href="../index.html">← 3D 화면으로 돌아가기</a></nav>
<main>
<h1>조작 — 화면 구성과 마우스</h1>
<h2>화면 구성</h2>
<table>
<tr><th>자리</th><th>내용</th></tr>
<tr><td>왼쪽 칸</td><td>도면. 그림 위에 마우스를 올리면 누를 수 있는 곳에 노란 테두리가 뜬다.</td></tr>
<tr><td>가운데 막대</td><td>좌우로 끌면 두 칸의 폭이 바뀐다(창 폭의 15%~85%).</td></tr>
<tr><td>오른쪽 칸</td><td>3D 모델. 왼쪽 위에 「선택: …」 글자.</td></tr>
<tr><td>오른쪽 칸 오른쪽 위 버튼</td><td>「구동 설명서」(이 설명서) · 「처음 화면」 · 「사람 ❚❚ / ▶」(두 사람 멈춤·다시 움직임)</td></tr>
</table>
<h2>3D 칸</h2>
<table>
<tr><th>동작</th><th>결과</th></tr>
<tr><td>왼쪽 버튼으로 좌우 끌기</td><td>세로축을 중심으로 돈다</td></tr>
<tr><td>왼쪽 버튼으로 위아래 끌기</td><td>기울기가 바뀐다. 위로 끌면 옆에서 보는 쪽, 더 끌면 아래에서 올려다본다</td></tr>
<tr><td><b>오른쪽 버튼으로 끌기</b> (또는 Shift + 왼쪽 끌기)</td><td>화면을 옮긴다. 마우스를 끈 만큼 모델이 따라온다</td></tr>
<tr><td>휠</td><td><b>마우스가 가리키는 자리</b>를 중심으로 확대·축소(0.5~40배, 한 칸에 10%)</td></tr>
<tr><td>더블클릭 또는 「처음 화면」</td><td>처음 등각 화면으로 돌아간다</td></tr>
<tr><td>「사람 ❚❚ / ▶」</td><td>두 사람을 멈추거나 다시 움직인다</td></tr>
</table>
<p>계단과 사람은 슬래브 구멍 사이로 보인다. 옆에서 보려면 왼쪽 버튼으로 위로 끌어 기울기를 낮춘다(벽은 반투명이다).</p>
<h2>도면 칸</h2>
<table>
<tr><th>동작</th><th>결과</th></tr>
<tr><td>휠</td><td>마우스가 있는 자리를 중심으로 확대·축소(0.5~40배). 확대해도 선은 얇게 유지된다</td></tr>
<tr><td>끌기</td><td>도면을 옮긴다</td></tr>
<tr><td>그림 누르기</td><td>그 부재를 선택한다. 도면 영역과 3D 부재가 노랗게 표시된다(아래 표)</td></tr>
<tr><td>빈 곳 누르기</td><td>선택을 푼다</td></tr>
</table>
<h2>도면 연결 표</h2>
<p>도면의 단면도와 평면도는 1 도면단위 = 1,000mm 축척이라, 3D 부재의 위치를 그대로 도면 위치로 바꿔 누를 자리를 만들었다.</p>
<table>
<tr><th>도면 그림</th><th>누르는 곳</th><th>3D 에서 표시되는 것</th></tr>
<tr><td rowspan="8">단면 A-A (왼쪽 판, 오른쪽 판의 생략 단면 포함)</td><td>층마다 있는 슬래브 띠 / 슬래브 사이</td><td>그 층 슬래브 / 그 층 계단 1런</td></tr>
<tr><td>양쪽 벽 띠</td><td>벽체</td></tr>
<tr><td>맨 위 두꺼운 띠 / 그 위 목</td><td>상부 슬래브 / 맨홀 목</td></tr>
<tr><td>위쪽 왼편 상자 / 위쪽 가운데 상자</td><td>분기구 전체 / 환기구 덕트 전체</td></tr>
<tr><td>1B 위 사다리</td><td>위쪽 사다리</td></tr>
<tr><td>19B 슬래브 위 얇은 띠</td><td>19B 패드</td></tr>
<tr><td>맨 아래 왼쪽 칸 / 가운데 세운 벽 / 오른쪽 칸 / 오른쪽 벽 사다리</td><td>집수조 / 월류벽 / 침사조 / 바닥 사다리</td></tr>
<tr><td>맨 아래 두꺼운 띠 / 그 밑 얇은 띠</td><td>하부 슬래브(20B) / 바닥콘크리트</td></tr>
<tr><td>단면 B-B (왼쪽 판, 생략 단면)</td><td>슬래브·계단·벽</td><td>단면 A-A 와 같음. 4B~15B 생략 구간은 연결 없음</td></tr>
<tr><td>평면도, 평면도 2</td><td>원 안 / 맨홀 목 / 분기구 / 환기구 덕트·환기탑 / 원 고리 / 4시 방향 터널(평면도는 30.3° 돌려 그린 그림)</td><td>상부 슬래브 / 맨홀 목 / 분기구 전체 / 환기구 전체 / 벽체 / TBM 터널</td></tr>
<tr><td>중간슬래브 (홀수층)·(짝수층)·(18B)</td><td>원 안</td><td>그 슬래브들 한 번에 (홀수 9장, 짝수 8장, 18B)</td></tr>
<tr><td>중간슬래브 (19B)</td><td>원 안 / 침사구 / 3시 방향 터널</td><td>19B 슬래브와 패드 / 바닥 사다리 / TBM 터널</td></tr>
<tr><td>하부슬래브 (20B)</td><td>원 안 / 왼쪽 칸 / 세운 벽 / 오른쪽 칸 / 사다리</td><td>하부 슬래브 / 집수조 / 월류벽 / 침사조 / 바닥 사다리</td></tr>
<tr><td>모든 평면</td><td>원 고리</td><td>벽체</td></tr>
</table>
<p>무엇을 보면 좋은지는 <a href="purpose.html">목적</a>, 여는 방법은 <a href="run.html">구동</a>에 있다.</p>
</main>
<nav class="pager"><a href="run.html">← 이전: 구동</a><a href="index.html">목차</a><span></span></nav>
</body>
</html>
+13
View File
@@ -0,0 +1,13 @@
body { margin: 0; font-family: "Malgun Gothic", sans-serif; background: #f7f7f5; color: #222; line-height: 1.6; }
nav.top { background: #333; padding: 10px 24px; }
nav.top a.back { color: #ffd400; text-decoration: none; font-weight: bold; }
main { max-width: 760px; margin: 0 auto; padding: 16px 24px 32px; }
h1 { font-size: 26px; border-bottom: 2px solid #333; padding-bottom: 6px; }
h2 { font-size: 18px; margin-top: 28px; }
.lock { color: #8a3b00; background: #fff4e0; padding: 6px 10px; border-radius: 4px; font-size: 14px; }
table { border-collapse: collapse; width: 100%; margin: 8px 0; font-size: 14px; }
th, td { border: 1px solid #ccc; padding: 6px 8px; text-align: left; vertical-align: top; }
th { background: #eaeaea; }
a { color: #0b5cad; }
ul.toc { font-size: 17px; }
nav.pager { display: flex; justify-content: space-between; border-top: 1px solid #ccc; margin: 0 auto; max-width: 760px; padding: 12px 24px 24px; }
+21
View File
@@ -0,0 +1,21 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>구동 설명서 — 목차</title>
<link rel="stylesheet" href="guide.css">
</head>
<body>
<nav class="top"><a class="back" href="../index.html">← 3D 화면으로 돌아가기</a></nav>
<main>
<h1>구동 설명서</h1>
<p>원형 수직구 3D 뷰어를 처음 쓰는 사람을 위한 설명서다. 위에서부터 차례로 읽으면 된다. 쪽마다 맨 위의 「← 3D 화면으로 돌아가기」로 언제든 3D 화면에 돌아간다.</p>
<ul class="toc">
<li><a href="purpose.html">목적</a> — 이 화면으로 무엇을 보나, 3분 따라 하기</li>
<li><a href="run.html">구동</a> — 여는 방법, 잘 안 열릴 때</li>
<li><a href="control.html">조작</a> — 화면 구성, 3D·도면 마우스 조작, 도면 연결 표</li>
</ul>
</main>
</body>
</html>
+37
View File
@@ -0,0 +1,37 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>구동 설명서 — 목적</title>
<link rel="stylesheet" href="guide.css">
</head>
<body>
<nav class="top"><a class="back" href="../index.html">← 3D 화면으로 돌아가기</a></nav>
<main>
<h1>목적 — 이 화면으로 무엇을 보나</h1>
<p>이 웹은 전력구 공사의 <b>원형 수직구</b> 일반도 한 장을 3D 로 보여 준다. 왼쪽에는 도면, 오른쪽에는 3D 모델이 나오고, 도면의 그림을 누르면 3D 에서 그 부재가 노랗게 표시된다.</p>
<p>3D 데이터는 도면(DWG)에서 도구가 읽어 만든다. 손으로 만든 정답 모델(<code>answer.html</code>)과 부재마다 같은지 검사로 확인했다.</p>
<p>주황색 사람 두 명 — 지하시설물에 들어갈 때는 2명 이상이 조를 이루어 이동해야 한다 — 이 시설물 계획 중 유지관리 동선계획을 따라 1B 와 19B 사이 계단을 오르내리는 과정을 보여 준다.</p>
<h2>화면에서 읽을 수 있는 것</h2>
<ul>
<li><b>위</b>: 지표 아래 1.5m 의 상부 슬래브와 맨홀 목 2개, 서쪽(서북서)으로 나간 분기구, 남쪽으로 나가 동쪽으로 꺾여 환기탑에 닿는 환기구(강제) 덕트</li>
<li><b>몸통</b>: 외경 11m(내경 9.7m) 원통, 중간 슬래브 19장(1B~19B). 슬래브마다 계단·케이블 slot·펌프 배출구·전기 배선구 구멍과 방호 난간이 있고, 홀수층과 짝수층은 구멍 자리가 반대다</li>
<li><b>계단</b>: 경사판 위에 단이 올라간 콘크리트 계단 18런. 밑면이 경사져 있고 계단 아래는 빈 공간이다. 층마다 방향이 바뀐다</li>
<li><b>사다리와 관</b>: 1B 위로 올라가는 사다리 2개(분기구 쪽 벽·가운데 맨홀), 환기구 쪽 벽 사다리와 환기탑 안 사다리, 19B 침사구 아래 사다리, 바닥까지 내려가는 펌프 배출관</li>
<li><b>바닥</b>: 19B 에서 계단과 나란한 방향(평면도로는 4시 방향)으로 나가는 TBM 터널(내경 2.6m, 터널 바닥 패드 = 19B 패드 높이), 하부 슬래브 위의 월류벽과 그 양쪽 집수조·침사조. 물은 터널 → 침사조 → 월류벽을 넘어 → 집수조 → 펌프 배출관으로 흐른다</li>
</ul>
<h2>3분 따라 하기</h2>
<ol>
<li><a href="run.html">여는 방법</a>대로 화면을 연다. 처음에는 위에서 비스듬히 내려다보는 등각 화면이고, 두 사람이 이미 움직이고 있다.</li>
<li>3D 칸 맨 위에 마우스를 두고 <b>휠을 위로</b> 굴린다. 상부 슬래브, 분기구, 환기구 덕트와 환기탑이 보인다.</li>
<li>왼쪽 도면의 평면도에서 분기구를 누른다. 3D 에서 분기구 전체가 노랗게 바뀐다. 환기구 덕트도 눌러 본다.</li>
<li>「중간슬래브 (홀수층)」 원을 누른다. 1B, 3B, … 17B 9장이 노랗게 바뀐다. 도면의 구멍·난간 모양과 3D 를 비교한다.</li>
<li><b>오른쪽 버튼으로 끌어</b> 아래로 내려가며 슬래브 구멍 사이로 계단과 두 사람을 본다. 맨 아래에서 터널과 월류벽, 집수조·침사조를 본다.</li>
<li>3D 칸 오른쪽 위 「처음 화면」으로 돌아간다.</li>
</ol>
<p>마우스 조작과 도면 연결 표는 <a href="control.html">조작</a>에 있다.</p>
</main>
<nav class="pager"><span></span><a href="index.html">목차</a><a href="run.html">다음: 구동 →</a></nav>
</body>
</html>
+45
View File
@@ -0,0 +1,45 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>구동 설명서 — 구동</title>
<link rel="stylesheet" href="guide.css">
</head>
<body>
<nav class="top"><a class="back" href="../index.html">← 3D 화면으로 돌아가기</a></nav>
<main>
<h1>구동 — 여는 방법</h1>
<h2>여는 방법</h2>
<ol>
<li><code>drawing3d-web</code> 폴더의 <code>index.html</code> 을 더블클릭한다. 서버도 인터넷도 필요 없다.</li>
<li>브라우저는 Chrome 또는 Edge 를 쓴다.</li>
<li>보이는 3D 와 왼쪽 도면은 <b>도면(DWG)에서 도구가 만든 것</b>이다 — 3D 데이터 <code>model_extracted.js</code>, 도면 그림 <code>private/drawing_gen.svg</code>. 제출 압축에 들어 있다.</li>
<li>손으로 만든 정답 모델과 견주어 보려면 <code>answer.html</code>(정답지 화면)을 연다. 왼쪽은 원본 도면 그림(CAD 에서 내보낸 SVG) <code>private/drawing.svg</code> 이다. 두 화면은 같은 모양이어야 한다 — 다르면 검사(<code>check.sh</code>)가 먼저 알려 준다.</li>
</ol>
<h2>도면에서 다시 만들기</h2>
<p>도면이 바뀌면 WSL(Ubuntu)에서 한 줄로 다시 만든다. 준비: GNU LibreDWG 0.14(설치는 <code>README.md</code> 「변환 패키지」).</p>
<ol>
<li>원본 DWG 를 <code>private/drawing.dwg</code> 로 둔다.</li>
<li><code>bash tools/make_model.sh</code> 를 실행한다. DWG → DXF → 그림 범위 → SVG·3D 데이터가 차례로 만들어진다.</li>
</ol>
<table>
<tr><th>형식</th><th>파일</th><th>쓰임</th></tr>
<tr><td>DWG</td><td><code>private/drawing.dwg</code></td><td>설계도면을 주고받는 원본(바이너리)</td></tr>
<tr><td>DXF</td><td><code>private/full.dxf</code> → <code>private/drawing.dxf</code></td><td>글자로 된 도면. 전체에서 <code>tools/views.json</code> 의 그림 상자만 남겨 추출 도구가 읽는다</td></tr>
<tr><td>SVG</td><td><code>private/drawing_gen.svg</code></td><td>웹 왼쪽 칸에 보이는 도면 그림</td></tr>
<tr><td>JS</td><td><code>model_extracted.js</code></td><td>3D 데이터. 도면에 없는 사다리 위치·배관·색은 <code>tools/design_rules.json</code></td></tr>
</table>
<p>규칙은 이 도면 한 장에 맞춘 것이다. 다른 수직구 도면에 쓰려면 그림 상자(<code>tools/views.json</code>)를 바꾸고 검사로 결과를 확인해야 한다.</p>
<h2>잘 안 열릴 때</h2>
<table>
<tr><th>증상</th><th>할 일</th></tr>
<tr><td>왼쪽에 「도면 파일 없음」</td><td>안내에 나온 파일(<code>private/drawing_gen.svg</code>, 정답지 화면은 <code>private/drawing.svg</code>)이 있는지 본다. 없으면 <code>bash tools/make_model.sh</code> 로 만든다. 3D 는 도면 없이도 동작한다.</td></tr>
<tr><td>화면이 비어 있다</td><td>압축을 푼 폴더 안의 <code>index.html</code> 을 열었는지 본다(압축 파일 안에서 바로 열면 다른 파일을 못 읽는다).</td></tr>
<tr><td>화면이 느리다</td><td>3D 칸 오른쪽 위 「사람 ❚❚」로 두 사람을 멈추면 가벼워진다.</td></tr>
</table>
<p>연 다음에 할 일은 <a href="control.html">조작</a>과 <a href="purpose.html">3분 따라 하기</a>에 있다.</p>
</main>
<nav class="pager"><a href="purpose.html">← 이전: 목적</a><a href="index.html">목차</a><a href="control.html">다음: 조작 →</a></nav>
</body>
</html>
+47
View File
@@ -0,0 +1,47 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>drawing3d-web</title>
<style>
html, body { margin: 0; height: 100%; overflow: hidden; background: #1e1e1e; }
canvas { display: block; }
#wrap { display: flex; height: 100%; }
#drawPane { position: relative; width: 50%; overflow: hidden; background: #252526; color: #cccccc; }
#drawMsg { margin: 24px; font-family: sans-serif; }
#drawStage { position: absolute; left: 0; top: 0; width: 443px; height: 443px; transform-origin: 0 0; display: none; }
#drawStage img, #drawStage svg { position: absolute; left: 0; top: 0; width: 443px; height: 443px; }
#splitter { width: 6px; flex: none; cursor: col-resize; background: #3c3c3c; }
#viewPane { position: relative; flex: 1; overflow: hidden; }
#hotspots path { fill: transparent; stroke-width: 2; vector-effect: non-scaling-stroke; }
#hotspots path:hover { stroke: #ffd400; }
#hotspots path.on { fill: #ffd400; fill-opacity: 0.35; }
#partLabel { position: absolute; left: 8px; top: 8px; right: 120px; color: #ffd400; font-family: sans-serif; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
#guideLink { position: absolute; right: 8px; top: 8px; padding: 2px 8px; font: 12px sans-serif; color: #cccccc; background: #3c3c3c; border-radius: 3px; text-decoration: none; opacity: 0.85; }
#resetView { position: absolute; right: 8px; top: 36px; padding: 2px 8px; font: 12px sans-serif; color: #cccccc; background: #3c3c3c; border: 0; border-radius: 3px; opacity: 0.85; cursor: pointer; }
#walkToggle { position: absolute; right: 8px; top: 64px; padding: 2px 8px; font: 12px sans-serif; color: #cccccc; background: #3c3c3c; border: 0; border-radius: 3px; opacity: 0.85; cursor: pointer; }
</style>
</head>
<body>
<div id="wrap">
<div id="drawPane">
<p id="drawMsg">도면 파일 없음 — private/drawing.svg 를 넣으세요</p>
<div id="drawStage">
<img id="drawing" alt="" draggable="false">
<svg id="hotspots" viewBox="0 0 443 443"></svg>
</div>
</div>
<div id="splitter"></div>
<div id="viewPane">
<canvas id="view"></canvas>
<div id="partLabel"></div>
<a id="guideLink" href="guide/index.html">구동 설명서</a>
<button id="resetView">처음 화면</button>
<button id="walkToggle">사람 ❚❚</button>
</div>
</div>
<script src="model_extracted.js"></script>
<script src="viewer.js"></script>
</body>
</html>
+120
View File
@@ -0,0 +1,120 @@
const MODEL = {
"units": "mm",
"parts": [
{"id": "wall", "name": "벽체", "kind": "wall", "type": "ring", "color": "#8a9bb0", "alpha": 0.25, "cx": 0, "cy": 0, "z0": -70200, "z1": -2000, "r_out": 5500, "r_in": 4850},
{"id": "topslab", "name": "상부 슬래브", "kind": "topslab", "type": "disc", "color": "#c8c8c8", "cx": 0, "cy": 0, "z0": -2000, "z1": -1500, "r": 5500, "holes": [{"circle": [0, 0, 450]}, {"circle": [0, 4405, 450]}]},
{"id": "neck_1", "name": "맨홀 목 1", "kind": "neck", "type": "prism", "color": "#b8b8b8", "polygon": [[-650, -650], [650, -650], [650, 650], [-650, 650]], "z0": -1500, "z1": 0, "holes": [{"circle": [0, 0, 450]}]},
{"id": "neck_2", "name": "맨홀 목 2", "kind": "neck", "type": "prism", "color": "#b8b8b8", "polygon": [[-650, 3755], [650, 3755], [650, 5055], [-650, 5055]], "z0": -1500, "z1": 0, "holes": [{"circle": [0, 4405, 450]}]},
{"id": "slab_1B", "name": "1B 슬래브", "kind": "slab", "type": "disc", "color": "#c8c8c8", "cx": 0, "cy": 0, "z0": -7700, "z1": -7400, "r": 4850, "holes": [[[-2100, 0], [2100, 0], [2100, 1000], [1250, 1000], [1250, 2200], [-1250, 2200], [-1250, 1000], [-2100, 1000]], [[-1250, -2200], [1250, -2200], [1250, -1000], [-1250, -1000]], [[-750, 3650], [750, 3650], [750, 4790], [-750, 4790]], [[2079, 4079], [2922, 3528], [3072, 3753], [2229, 4304]]]},
{"id": "slab_2B", "name": "2B 슬래브", "kind": "slab", "type": "disc", "color": "#c8c8c8", "cx": 0, "cy": 0, "z0": -11000, "z1": -10700, "r": 4850, "holes": [[[-2100, 0], [2100, 0], [2100, -1000], [1250, -1000], [1250, -2200], [-1250, -2200], [-1250, -1000], [-2100, -1000]], [[-1250, 1000], [1250, 1000], [1250, 2200], [-1250, 2200]], [[-750, 3650], [750, 3650], [750, 4790], [-750, 4790]], [[2079, 4079], [2922, 3528], [3072, 3753], [2229, 4304]]]},
{"id": "slab_3B", "name": "3B 슬래브", "kind": "slab", "type": "disc", "color": "#c8c8c8", "cx": 0, "cy": 0, "z0": -14300, "z1": -14000, "r": 4850, "holes": [[[-2100, 0], [2100, 0], [2100, 1000], [1250, 1000], [1250, 2200], [-1250, 2200], [-1250, 1000], [-2100, 1000]], [[-1250, -2200], [1250, -2200], [1250, -1000], [-1250, -1000]], [[-750, 3650], [750, 3650], [750, 4790], [-750, 4790]], [[2079, 4079], [2922, 3528], [3072, 3753], [2229, 4304]]]},
{"id": "slab_4B", "name": "4B 슬래브", "kind": "slab", "type": "disc", "color": "#c8c8c8", "cx": 0, "cy": 0, "z0": -17600, "z1": -17300, "r": 4850, "holes": [[[-2100, 0], [2100, 0], [2100, -1000], [1250, -1000], [1250, -2200], [-1250, -2200], [-1250, -1000], [-2100, -1000]], [[-1250, 1000], [1250, 1000], [1250, 2200], [-1250, 2200]], [[-750, 3650], [750, 3650], [750, 4790], [-750, 4790]], [[2079, 4079], [2922, 3528], [3072, 3753], [2229, 4304]]]},
{"id": "slab_5B", "name": "5B 슬래브", "kind": "slab", "type": "disc", "color": "#c8c8c8", "cx": 0, "cy": 0, "z0": -20900, "z1": -20600, "r": 4850, "holes": [[[-2100, 0], [2100, 0], [2100, 1000], [1250, 1000], [1250, 2200], [-1250, 2200], [-1250, 1000], [-2100, 1000]], [[-1250, -2200], [1250, -2200], [1250, -1000], [-1250, -1000]], [[-750, 3650], [750, 3650], [750, 4790], [-750, 4790]], [[2079, 4079], [2922, 3528], [3072, 3753], [2229, 4304]]]},
{"id": "slab_6B", "name": "6B 슬래브", "kind": "slab", "type": "disc", "color": "#c8c8c8", "cx": 0, "cy": 0, "z0": -24200, "z1": -23900, "r": 4850, "holes": [[[-2100, 0], [2100, 0], [2100, -1000], [1250, -1000], [1250, -2200], [-1250, -2200], [-1250, -1000], [-2100, -1000]], [[-1250, 1000], [1250, 1000], [1250, 2200], [-1250, 2200]], [[-750, 3650], [750, 3650], [750, 4790], [-750, 4790]], [[2079, 4079], [2922, 3528], [3072, 3753], [2229, 4304]]]},
{"id": "slab_7B", "name": "7B 슬래브", "kind": "slab", "type": "disc", "color": "#c8c8c8", "cx": 0, "cy": 0, "z0": -27500, "z1": -27200, "r": 4850, "holes": [[[-2100, 0], [2100, 0], [2100, 1000], [1250, 1000], [1250, 2200], [-1250, 2200], [-1250, 1000], [-2100, 1000]], [[-1250, -2200], [1250, -2200], [1250, -1000], [-1250, -1000]], [[-750, 3650], [750, 3650], [750, 4790], [-750, 4790]], [[2079, 4079], [2922, 3528], [3072, 3753], [2229, 4304]]]},
{"id": "slab_8B", "name": "8B 슬래브", "kind": "slab", "type": "disc", "color": "#c8c8c8", "cx": 0, "cy": 0, "z0": -30800, "z1": -30500, "r": 4850, "holes": [[[-2100, 0], [2100, 0], [2100, -1000], [1250, -1000], [1250, -2200], [-1250, -2200], [-1250, -1000], [-2100, -1000]], [[-1250, 1000], [1250, 1000], [1250, 2200], [-1250, 2200]], [[-750, 3650], [750, 3650], [750, 4790], [-750, 4790]], [[2079, 4079], [2922, 3528], [3072, 3753], [2229, 4304]]]},
{"id": "slab_9B", "name": "9B 슬래브", "kind": "slab", "type": "disc", "color": "#c8c8c8", "cx": 0, "cy": 0, "z0": -34100, "z1": -33800, "r": 4850, "holes": [[[-2100, 0], [2100, 0], [2100, 1000], [1250, 1000], [1250, 2200], [-1250, 2200], [-1250, 1000], [-2100, 1000]], [[-1250, -2200], [1250, -2200], [1250, -1000], [-1250, -1000]], [[-750, 3650], [750, 3650], [750, 4790], [-750, 4790]], [[2079, 4079], [2922, 3528], [3072, 3753], [2229, 4304]]]},
{"id": "slab_10B", "name": "10B 슬래브", "kind": "slab", "type": "disc", "color": "#c8c8c8", "cx": 0, "cy": 0, "z0": -37400, "z1": -37100, "r": 4850, "holes": [[[-2100, 0], [2100, 0], [2100, -1000], [1250, -1000], [1250, -2200], [-1250, -2200], [-1250, -1000], [-2100, -1000]], [[-1250, 1000], [1250, 1000], [1250, 2200], [-1250, 2200]], [[-750, 3650], [750, 3650], [750, 4790], [-750, 4790]], [[2079, 4079], [2922, 3528], [3072, 3753], [2229, 4304]]]},
{"id": "slab_11B", "name": "11B 슬래브", "kind": "slab", "type": "disc", "color": "#c8c8c8", "cx": 0, "cy": 0, "z0": -40700, "z1": -40400, "r": 4850, "holes": [[[-2100, 0], [2100, 0], [2100, 1000], [1250, 1000], [1250, 2200], [-1250, 2200], [-1250, 1000], [-2100, 1000]], [[-1250, -2200], [1250, -2200], [1250, -1000], [-1250, -1000]], [[-750, 3650], [750, 3650], [750, 4790], [-750, 4790]], [[2079, 4079], [2922, 3528], [3072, 3753], [2229, 4304]]]},
{"id": "slab_12B", "name": "12B 슬래브", "kind": "slab", "type": "disc", "color": "#c8c8c8", "cx": 0, "cy": 0, "z0": -44000, "z1": -43700, "r": 4850, "holes": [[[-2100, 0], [2100, 0], [2100, -1000], [1250, -1000], [1250, -2200], [-1250, -2200], [-1250, -1000], [-2100, -1000]], [[-1250, 1000], [1250, 1000], [1250, 2200], [-1250, 2200]], [[-750, 3650], [750, 3650], [750, 4790], [-750, 4790]], [[2079, 4079], [2922, 3528], [3072, 3753], [2229, 4304]]]},
{"id": "slab_13B", "name": "13B 슬래브", "kind": "slab", "type": "disc", "color": "#c8c8c8", "cx": 0, "cy": 0, "z0": -47300, "z1": -47000, "r": 4850, "holes": [[[-2100, 0], [2100, 0], [2100, 1000], [1250, 1000], [1250, 2200], [-1250, 2200], [-1250, 1000], [-2100, 1000]], [[-1250, -2200], [1250, -2200], [1250, -1000], [-1250, -1000]], [[-750, 3650], [750, 3650], [750, 4790], [-750, 4790]], [[2079, 4079], [2922, 3528], [3072, 3753], [2229, 4304]]]},
{"id": "slab_14B", "name": "14B 슬래브", "kind": "slab", "type": "disc", "color": "#c8c8c8", "cx": 0, "cy": 0, "z0": -50600, "z1": -50300, "r": 4850, "holes": [[[-2100, 0], [2100, 0], [2100, -1000], [1250, -1000], [1250, -2200], [-1250, -2200], [-1250, -1000], [-2100, -1000]], [[-1250, 1000], [1250, 1000], [1250, 2200], [-1250, 2200]], [[-750, 3650], [750, 3650], [750, 4790], [-750, 4790]], [[2079, 4079], [2922, 3528], [3072, 3753], [2229, 4304]]]},
{"id": "slab_15B", "name": "15B 슬래브", "kind": "slab", "type": "disc", "color": "#c8c8c8", "cx": 0, "cy": 0, "z0": -53900, "z1": -53600, "r": 4850, "holes": [[[-2100, 0], [2100, 0], [2100, 1000], [1250, 1000], [1250, 2200], [-1250, 2200], [-1250, 1000], [-2100, 1000]], [[-1250, -2200], [1250, -2200], [1250, -1000], [-1250, -1000]], [[-750, 3650], [750, 3650], [750, 4790], [-750, 4790]], [[2079, 4079], [2922, 3528], [3072, 3753], [2229, 4304]]]},
{"id": "slab_16B", "name": "16B 슬래브", "kind": "slab", "type": "disc", "color": "#c8c8c8", "cx": 0, "cy": 0, "z0": -57200, "z1": -56900, "r": 4850, "holes": [[[-2100, 0], [2100, 0], [2100, -1000], [1250, -1000], [1250, -2200], [-1250, -2200], [-1250, -1000], [-2100, -1000]], [[-1250, 1000], [1250, 1000], [1250, 2200], [-1250, 2200]], [[-750, 3650], [750, 3650], [750, 4790], [-750, 4790]], [[2079, 4079], [2922, 3528], [3072, 3753], [2229, 4304]]]},
{"id": "slab_17B", "name": "17B 슬래브", "kind": "slab", "type": "disc", "color": "#c8c8c8", "cx": 0, "cy": 0, "z0": -60500, "z1": -60200, "r": 4850, "holes": [[[-2100, 0], [2100, 0], [2100, 1000], [1250, 1000], [1250, 2200], [-1250, 2200], [-1250, 1000], [-2100, 1000]], [[-1250, -2200], [1250, -2200], [1250, -1000], [-1250, -1000]], [[-750, 3650], [750, 3650], [750, 4790], [-750, 4790]], [[2079, 4079], [2922, 3528], [3072, 3753], [2229, 4304]]]},
{"id": "slab_18B", "name": "18B 슬래브", "kind": "slab", "type": "disc", "color": "#c8c8c8", "cx": 0, "cy": 0, "z0": -63800, "z1": -63500, "r": 4850, "holes": [[[-2100, -1000], [2100, -1000], [2100, 0], [-2100, 0]], [[-750, 3650], [750, 3650], [750, 4790], [-750, 4790]], [[2079, 4079], [2922, 3528], [3072, 3753], [2229, 4304]]]},
{"id": "slab_19B", "name": "19B 슬래브", "kind": "slab", "type": "disc", "color": "#c8c8c8", "cx": 0, "cy": 0, "z0": -66500, "z1": -66200, "r": 4850, "holes": [[[-750, 3650], [750, 3650], [750, 4790], [-750, 4790]], [[3850, -500], [4820, -500], [4820, 500], [3850, 500]]]},
{"id": "pad_19B", "name": "19B 패드", "kind": "pad", "type": "disc", "color": "#e0b060", "cx": 0, "cy": 0, "z0": -66200, "z1": -66100, "r": 4750, "holes": [[[-750, 3650], [750, 3650], [750, 4750], [-750, 4750]], [[3850, -500], [4720, -500], [4720, 500], [3850, 500]]]},
{"id": "bottom_20B", "name": "하부 슬래브(20B)", "kind": "bottom", "type": "disc", "color": "#a0a0a0", "cx": 0, "cy": 0, "z0": -70200, "z1": -69000, "r": 4850},
{"id": "lean_concrete", "name": "바닥콘크리트", "kind": "lean", "type": "disc", "color": "#707070", "cx": 0, "cy": 0, "z0": -70300, "z1": -70200, "r": 5500},
{"id": "tunnel_TBM", "name": "TBM 터널", "kind": "tunnel", "type": "pipe", "color": "#b08a5a", "x": 4850, "y": 0, "z": -65050, "angle_deg": 0, "length": 20650, "r_out": 1500, "r_in": 1300},
{"id": "stair_01", "name": "계단 1런 (1B→2B)", "kind": "stair", "type": "stair", "color": "#7fb07f", "z_low": -10700, "z_high": -7400, "risers": 17, "first_riser": 100, "riser": 200, "tread": 300, "x_top": 2100, "dir": -1, "y0": 0, "y1": 1000},
{"id": "stair_02", "name": "계단 2런 (2B→3B)", "kind": "stair", "type": "stair", "color": "#7fb07f", "z_low": -14000, "z_high": -10700, "risers": 17, "first_riser": 100, "riser": 200, "tread": 300, "x_top": -2100, "dir": 1, "y0": -1000, "y1": 0},
{"id": "stair_03", "name": "계단 3런 (3B→4B)", "kind": "stair", "type": "stair", "color": "#7fb07f", "z_low": -17300, "z_high": -14000, "risers": 17, "first_riser": 100, "riser": 200, "tread": 300, "x_top": 2100, "dir": -1, "y0": 0, "y1": 1000},
{"id": "stair_04", "name": "계단 4런 (4B→5B)", "kind": "stair", "type": "stair", "color": "#7fb07f", "z_low": -20600, "z_high": -17300, "risers": 17, "first_riser": 100, "riser": 200, "tread": 300, "x_top": -2100, "dir": 1, "y0": -1000, "y1": 0},
{"id": "stair_05", "name": "계단 5런 (5B→6B)", "kind": "stair", "type": "stair", "color": "#7fb07f", "z_low": -23900, "z_high": -20600, "risers": 17, "first_riser": 100, "riser": 200, "tread": 300, "x_top": 2100, "dir": -1, "y0": 0, "y1": 1000},
{"id": "stair_06", "name": "계단 6런 (6B→7B)", "kind": "stair", "type": "stair", "color": "#7fb07f", "z_low": -27200, "z_high": -23900, "risers": 17, "first_riser": 100, "riser": 200, "tread": 300, "x_top": -2100, "dir": 1, "y0": -1000, "y1": 0},
{"id": "stair_07", "name": "계단 7런 (7B→8B)", "kind": "stair", "type": "stair", "color": "#7fb07f", "z_low": -30500, "z_high": -27200, "risers": 17, "first_riser": 100, "riser": 200, "tread": 300, "x_top": 2100, "dir": -1, "y0": 0, "y1": 1000},
{"id": "stair_08", "name": "계단 8런 (8B→9B)", "kind": "stair", "type": "stair", "color": "#7fb07f", "z_low": -33800, "z_high": -30500, "risers": 17, "first_riser": 100, "riser": 200, "tread": 300, "x_top": -2100, "dir": 1, "y0": -1000, "y1": 0},
{"id": "stair_09", "name": "계단 9런 (9B→10B)", "kind": "stair", "type": "stair", "color": "#7fb07f", "z_low": -37100, "z_high": -33800, "risers": 17, "first_riser": 100, "riser": 200, "tread": 300, "x_top": 2100, "dir": -1, "y0": 0, "y1": 1000},
{"id": "stair_10", "name": "계단 10런 (10B→11B)", "kind": "stair", "type": "stair", "color": "#7fb07f", "z_low": -40400, "z_high": -37100, "risers": 17, "first_riser": 100, "riser": 200, "tread": 300, "x_top": -2100, "dir": 1, "y0": -1000, "y1": 0},
{"id": "stair_11", "name": "계단 11런 (11B→12B)", "kind": "stair", "type": "stair", "color": "#7fb07f", "z_low": -43700, "z_high": -40400, "risers": 17, "first_riser": 100, "riser": 200, "tread": 300, "x_top": 2100, "dir": -1, "y0": 0, "y1": 1000},
{"id": "stair_12", "name": "계단 12런 (12B→13B)", "kind": "stair", "type": "stair", "color": "#7fb07f", "z_low": -47000, "z_high": -43700, "risers": 17, "first_riser": 100, "riser": 200, "tread": 300, "x_top": -2100, "dir": 1, "y0": -1000, "y1": 0},
{"id": "stair_13", "name": "계단 13런 (13B→14B)", "kind": "stair", "type": "stair", "color": "#7fb07f", "z_low": -50300, "z_high": -47000, "risers": 17, "first_riser": 100, "riser": 200, "tread": 300, "x_top": 2100, "dir": -1, "y0": 0, "y1": 1000},
{"id": "stair_14", "name": "계단 14런 (14B→15B)", "kind": "stair", "type": "stair", "color": "#7fb07f", "z_low": -53600, "z_high": -50300, "risers": 17, "first_riser": 100, "riser": 200, "tread": 300, "x_top": -2100, "dir": 1, "y0": -1000, "y1": 0},
{"id": "stair_15", "name": "계단 15런 (15B→16B)", "kind": "stair", "type": "stair", "color": "#7fb07f", "z_low": -56900, "z_high": -53600, "risers": 17, "first_riser": 100, "riser": 200, "tread": 300, "x_top": 2100, "dir": -1, "y0": 0, "y1": 1000},
{"id": "stair_16", "name": "계단 16런 (16B→17B)", "kind": "stair", "type": "stair", "color": "#7fb07f", "z_low": -60200, "z_high": -56900, "risers": 17, "first_riser": 100, "riser": 200, "tread": 300, "x_top": -2100, "dir": 1, "y0": -1000, "y1": 0},
{"id": "stair_17", "name": "계단 17런 (17B→18B)", "kind": "stair", "type": "stair", "color": "#7fb07f", "z_low": -63500, "z_high": -60200, "risers": 17, "first_riser": 100, "riser": 200, "tread": 300, "x_top": 2100, "dir": -1, "y0": 0, "y1": 1000},
{"id": "stair_18", "name": "계단 18런 (18B→19B)", "kind": "stair", "type": "stair", "color": "#7fb07f", "z_low": -66200, "z_high": -63500, "risers": 14, "first_riser": 100, "riser": 200, "tread": 300, "x_top": -2100, "dir": 1, "y0": -1000, "y1": 0},
{"id": "fill_gravel", "name": "터널 잡석", "kind": "fill", "type": "fill", "color": "#7a7a7a", "x": 4850, "y": 0, "z": -65050, "angle_deg": 0, "length": 20650, "r": 1300, "v0": -1300, "v1": -1175, "half_width": 0},
{"id": "fill_pad_low", "name": "터널 패드 2단 (아래)", "kind": "fill", "type": "fill", "color": "#c8bfa8", "x": 4850, "y": 0, "z": -65050, "angle_deg": 0, "length": 20650, "r": 1300, "v0": -1175, "v1": -1100, "half_width": 0},
{"id": "fill_pad_high", "name": "터널 패드 1단 (위)", "kind": "fill", "type": "fill", "color": "#e0d8c4", "x": 4850, "y": 0, "z": -65050, "angle_deg": 0, "length": 20650, "r": 1300, "v0": -1100, "v1": -1050, "half_width": 400},
{"id": "weir_L", "name": "월류벽 (넘침 홈 서쪽)", "kind": "weir", "type": "prism", "color": "#9a9a9a", "polygon": [[1400, -4580], [1600, -4580], [1600, -500], [1400, -500]], "z0": -69000, "z1": -67000},
{"id": "weir_R", "name": "월류벽 (넘침 홈 동쪽)", "kind": "weir", "type": "prism", "color": "#9a9a9a", "polygon": [[1400, 500], [1600, 500], [1600, 4580], [1400, 4580]], "z0": -69000, "z1": -67000},
{"id": "weir_mid", "name": "월류벽 (홈 아래)", "kind": "weir", "type": "prism", "color": "#9a9a9a", "polygon": [[1400, -500], [1600, -500], [1600, 500], [1400, 500]], "z0": -69000, "z1": -67500},
{"id": "pit_pump", "name": "집수조 (펌프 쪽)", "kind": "pit", "type": "zone", "color": "#8fc3e0", "alpha": 0.25, "r": 4850, "x_min": -4850, "x_max": 1400, "z0": -69000, "z1": -67000},
{"id": "pit_tbm", "name": "침사조 (터널 쪽)", "kind": "pit", "type": "zone", "color": "#a8d8f0", "alpha": 0.25, "r": 4850, "x_min": 1600, "x_max": 4850, "z0": -69000, "z1": -67000},
{"id": "ladder_19B", "name": "사다리 (침사구 아래 동쪽 벽)", "kind": "ladder", "type": "ladder", "color": "#d0a040", "x0": 4668, "x1": 4708, "yc": 0, "width": 400, "z0": -68700, "z1": -66200, "rung": 300},
{"id": "branch_floor", "name": "분기구 바닥", "kind": "branch", "type": "obox", "color": "#9fb8c8", "cx": 0, "cy": 0, "angle_deg": 197.3, "s0": 4850, "s1": 8350, "t0": -1450, "t1": 1450, "z0": -4550, "z1": -4100},
{"id": "branch_roof", "name": "분기구 지붕", "kind": "branch", "type": "obox", "color": "#9fb8c8", "cx": 0, "cy": 0, "angle_deg": 197.3, "s0": 4850, "s1": 8350, "t0": -1450, "t1": 1450, "z0": -2000, "z1": -1650},
{"id": "branch_wall_1", "name": "분기구 옆벽 1", "kind": "branch", "type": "obox", "color": "#9fb8c8", "cx": 0, "cy": 0, "angle_deg": 197.3, "s0": 4850, "s1": 8350, "t0": 1100, "t1": 1450, "z0": -4100, "z1": -2000},
{"id": "branch_wall_2", "name": "분기구 옆벽 2", "kind": "branch", "type": "obox", "color": "#9fb8c8", "cx": 0, "cy": 0, "angle_deg": 197.3, "s0": 4850, "s1": 8350, "t0": -1450, "t1": -1100, "z0": -4100, "z1": -2000},
{"id": "branch_end", "name": "분기구 끝벽", "kind": "branch", "type": "obox", "color": "#9fb8c8", "cx": 0, "cy": 0, "angle_deg": 197.3, "s0": 8000, "s1": 8350, "t0": -1100, "t1": 1100, "z0": -4100, "z1": -2000},
{"id": "duct_floor_1", "name": "환기 덕트 바닥 1", "kind": "vent", "type": "obox", "color": "#b8c8a0", "cx": 0, "cy": 0, "angle_deg": 30.3, "s0": -1850, "s1": 1850, "t0": -10500, "t1": -4610, "z0": -4600, "z1": -4100},
{"id": "duct_floor_2", "name": "환기 덕트 바닥 2", "kind": "vent", "type": "obox", "color": "#b8c8a0", "cx": 0, "cy": 0, "angle_deg": 30.3, "s0": 1850, "s1": 7504, "t0": -10500, "t1": -6800, "z0": -4600, "z1": -4100},
{"id": "duct_roof_1", "name": "환기 덕트 지붕 1", "kind": "vent", "type": "obox", "color": "#b8c8a0", "cx": 0, "cy": 0, "angle_deg": 30.3, "s0": -1850, "s1": 1850, "t0": -10500, "t1": -4610, "z0": -2000, "z1": -1650},
{"id": "duct_roof_2", "name": "환기 덕트 지붕 2", "kind": "vent", "type": "obox", "color": "#b8c8a0", "cx": 0, "cy": 0, "angle_deg": 30.3, "s0": 1850, "s1": 7504, "t0": -10500, "t1": -6800, "z0": -2000, "z1": -1650},
{"id": "duct_wall_w", "name": "환기 덕트 서벽", "kind": "vent", "type": "obox", "color": "#b8c8a0", "cx": 0, "cy": 0, "angle_deg": 30.3, "s0": -1850, "s1": -1500, "t0": -10500, "t1": -4610, "z0": -4100, "z1": -2000},
{"id": "duct_wall_e", "name": "환기 덕트 동벽", "kind": "vent", "type": "obox", "color": "#b8c8a0", "cx": 0, "cy": 0, "angle_deg": 30.3, "s0": 1500, "s1": 1850, "t0": -6800, "t1": -4610, "z0": -4100, "z1": -2000},
{"id": "duct_wall_n", "name": "환기 덕트 북벽", "kind": "vent", "type": "obox", "color": "#b8c8a0", "cx": 0, "cy": 0, "angle_deg": 30.3, "s0": 1500, "s1": 7504, "t0": -7150, "t1": -6800, "z0": -4100, "z1": -2000},
{"id": "duct_wall_s", "name": "환기 덕트 남벽", "kind": "vent", "type": "obox", "color": "#b8c8a0", "cx": 0, "cy": 0, "angle_deg": 30.3, "s0": -1850, "s1": 7504, "t0": -10500, "t1": -10150, "z0": -4100, "z1": -2000},
{"id": "tower_floor", "name": "환기탑 바닥", "kind": "vent", "type": "obox", "color": "#b8c8a0", "cx": 0, "cy": 0, "angle_deg": 30.3, "s0": 7504, "s1": 11205, "t0": -10500, "t1": -6799, "z0": -4600, "z1": -4100},
{"id": "tower_wall_w_top", "name": "환기탑 서벽 (지붕 위)", "kind": "vent", "type": "obox", "color": "#b8c8a0", "cx": 0, "cy": 0, "angle_deg": 30.3, "s0": 7504, "s1": 7854, "t0": -10149, "t1": -7149, "z0": -2000, "z1": 0},
{"id": "tower_wall_e", "name": "환기탑 동벽", "kind": "vent", "type": "obox", "color": "#b8c8a0", "cx": 0, "cy": 0, "angle_deg": 30.3, "s0": 10855, "s1": 11205, "t0": -10500, "t1": -6799, "z0": -4100, "z1": 0},
{"id": "tower_wall_n", "name": "환기탑 북벽", "kind": "vent", "type": "obox", "color": "#b8c8a0", "cx": 0, "cy": 0, "angle_deg": 30.3, "s0": 7504, "s1": 11205, "t0": -7149, "t1": -6799, "z0": -4100, "z1": 0},
{"id": "tower_wall_s", "name": "환기탑 남벽", "kind": "vent", "type": "obox", "color": "#b8c8a0", "cx": 0, "cy": 0, "angle_deg": 30.3, "s0": 7504, "s1": 11205, "t0": -10500, "t1": -10149, "z0": -4100, "z1": 0},
{"id": "rail_1B", "name": "방호 난간 (1B 윗면)", "kind": "railing", "type": "railing", "color": "#d8c890", "z": -7400, "h": 1100, "paths": [[[2140, 4350], [1940, 4050], [2950, 3390], [3150, 3690]], [[1350, -900], [1350, -2300], [-1350, -2300], [-1350, -900], [1350, -900]], [[2100, -100], [-2200, -100], [-2200, 1100], [-1350, 1100], [-1350, 2300], [1350, 2300], [1350, 1100], [2100, 1100]], [[-850, 4780], [-850, 3550], [850, 3550], [850, 4780]]]},
{"id": "rail_3B", "name": "방호 난간 (3B 윗면)", "kind": "railing", "type": "railing", "color": "#d8c890", "z": -14000, "h": 1100, "paths": [[[2140, 4350], [1940, 4050], [2950, 3390], [3150, 3690]], [[1350, -900], [1350, -2300], [-1350, -2300], [-1350, -900], [1350, -900]], [[2100, -100], [-2200, -100], [-2200, 1100], [-1350, 1100], [-1350, 2300], [1350, 2300], [1350, 1100], [2100, 1100]], [[-850, 4780], [-850, 3550], [850, 3550], [850, 4780]]]},
{"id": "rail_5B", "name": "방호 난간 (5B 윗면)", "kind": "railing", "type": "railing", "color": "#d8c890", "z": -20600, "h": 1100, "paths": [[[2140, 4350], [1940, 4050], [2950, 3390], [3150, 3690]], [[1350, -900], [1350, -2300], [-1350, -2300], [-1350, -900], [1350, -900]], [[2100, -100], [-2200, -100], [-2200, 1100], [-1350, 1100], [-1350, 2300], [1350, 2300], [1350, 1100], [2100, 1100]], [[-850, 4780], [-850, 3550], [850, 3550], [850, 4780]]]},
{"id": "rail_7B", "name": "방호 난간 (7B 윗면)", "kind": "railing", "type": "railing", "color": "#d8c890", "z": -27200, "h": 1100, "paths": [[[2140, 4350], [1940, 4050], [2950, 3390], [3150, 3690]], [[1350, -900], [1350, -2300], [-1350, -2300], [-1350, -900], [1350, -900]], [[2100, -100], [-2200, -100], [-2200, 1100], [-1350, 1100], [-1350, 2300], [1350, 2300], [1350, 1100], [2100, 1100]], [[-850, 4780], [-850, 3550], [850, 3550], [850, 4780]]]},
{"id": "rail_9B", "name": "방호 난간 (9B 윗면)", "kind": "railing", "type": "railing", "color": "#d8c890", "z": -33800, "h": 1100, "paths": [[[2140, 4350], [1940, 4050], [2950, 3390], [3150, 3690]], [[1350, -900], [1350, -2300], [-1350, -2300], [-1350, -900], [1350, -900]], [[2100, -100], [-2200, -100], [-2200, 1100], [-1350, 1100], [-1350, 2300], [1350, 2300], [1350, 1100], [2100, 1100]], [[-850, 4780], [-850, 3550], [850, 3550], [850, 4780]]]},
{"id": "rail_11B", "name": "방호 난간 (11B 윗면)", "kind": "railing", "type": "railing", "color": "#d8c890", "z": -40400, "h": 1100, "paths": [[[2140, 4350], [1940, 4050], [2950, 3390], [3150, 3690]], [[1350, -900], [1350, -2300], [-1350, -2300], [-1350, -900], [1350, -900]], [[2100, -100], [-2200, -100], [-2200, 1100], [-1350, 1100], [-1350, 2300], [1350, 2300], [1350, 1100], [2100, 1100]], [[-850, 4780], [-850, 3550], [850, 3550], [850, 4780]]]},
{"id": "rail_13B", "name": "방호 난간 (13B 윗면)", "kind": "railing", "type": "railing", "color": "#d8c890", "z": -47000, "h": 1100, "paths": [[[2140, 4350], [1940, 4050], [2950, 3390], [3150, 3690]], [[1350, -900], [1350, -2300], [-1350, -2300], [-1350, -900], [1350, -900]], [[2100, -100], [-2200, -100], [-2200, 1100], [-1350, 1100], [-1350, 2300], [1350, 2300], [1350, 1100], [2100, 1100]], [[-850, 4780], [-850, 3550], [850, 3550], [850, 4780]]]},
{"id": "rail_15B", "name": "방호 난간 (15B 윗면)", "kind": "railing", "type": "railing", "color": "#d8c890", "z": -53600, "h": 1100, "paths": [[[2140, 4350], [1940, 4050], [2950, 3390], [3150, 3690]], [[1350, -900], [1350, -2300], [-1350, -2300], [-1350, -900], [1350, -900]], [[2100, -100], [-2200, -100], [-2200, 1100], [-1350, 1100], [-1350, 2300], [1350, 2300], [1350, 1100], [2100, 1100]], [[-850, 4780], [-850, 3550], [850, 3550], [850, 4780]]]},
{"id": "rail_17B", "name": "방호 난간 (17B 윗면)", "kind": "railing", "type": "railing", "color": "#d8c890", "z": -60200, "h": 1100, "paths": [[[2140, 4350], [1940, 4050], [2950, 3390], [3150, 3690]], [[1350, -900], [1350, -2300], [-1350, -2300], [-1350, -900], [1350, -900]], [[2100, -100], [-2200, -100], [-2200, 1100], [-1350, 1100], [-1350, 2300], [1350, 2300], [1350, 1100], [2100, 1100]], [[-850, 4780], [-850, 3550], [850, 3550], [850, 4780]]]},
{"id": "rail_2B", "name": "방호 난간 (2B 윗면)", "kind": "railing", "type": "railing", "color": "#d8c890", "z": -10700, "h": 1100, "paths": [[[-850, 4780], [-850, 3550], [850, 3550], [850, 4780]], [[-2100, 100], [2200, 100], [2200, -1100], [1350, -1100], [1350, -2300], [-1350, -2300], [-1350, -1100], [-2100, -1100]], [[1350, 2300], [1350, 900], [-1350, 900], [-1350, 2300], [1350, 2300]], [[2140, 4350], [1940, 4050], [2950, 3390], [3150, 3690]]]},
{"id": "rail_4B", "name": "방호 난간 (4B 윗면)", "kind": "railing", "type": "railing", "color": "#d8c890", "z": -17300, "h": 1100, "paths": [[[-850, 4780], [-850, 3550], [850, 3550], [850, 4780]], [[-2100, 100], [2200, 100], [2200, -1100], [1350, -1100], [1350, -2300], [-1350, -2300], [-1350, -1100], [-2100, -1100]], [[1350, 2300], [1350, 900], [-1350, 900], [-1350, 2300], [1350, 2300]], [[2140, 4350], [1940, 4050], [2950, 3390], [3150, 3690]]]},
{"id": "rail_6B", "name": "방호 난간 (6B 윗면)", "kind": "railing", "type": "railing", "color": "#d8c890", "z": -23900, "h": 1100, "paths": [[[-850, 4780], [-850, 3550], [850, 3550], [850, 4780]], [[-2100, 100], [2200, 100], [2200, -1100], [1350, -1100], [1350, -2300], [-1350, -2300], [-1350, -1100], [-2100, -1100]], [[1350, 2300], [1350, 900], [-1350, 900], [-1350, 2300], [1350, 2300]], [[2140, 4350], [1940, 4050], [2950, 3390], [3150, 3690]]]},
{"id": "rail_8B", "name": "방호 난간 (8B 윗면)", "kind": "railing", "type": "railing", "color": "#d8c890", "z": -30500, "h": 1100, "paths": [[[-850, 4780], [-850, 3550], [850, 3550], [850, 4780]], [[-2100, 100], [2200, 100], [2200, -1100], [1350, -1100], [1350, -2300], [-1350, -2300], [-1350, -1100], [-2100, -1100]], [[1350, 2300], [1350, 900], [-1350, 900], [-1350, 2300], [1350, 2300]], [[2140, 4350], [1940, 4050], [2950, 3390], [3150, 3690]]]},
{"id": "rail_10B", "name": "방호 난간 (10B 윗면)", "kind": "railing", "type": "railing", "color": "#d8c890", "z": -37100, "h": 1100, "paths": [[[-850, 4780], [-850, 3550], [850, 3550], [850, 4780]], [[-2100, 100], [2200, 100], [2200, -1100], [1350, -1100], [1350, -2300], [-1350, -2300], [-1350, -1100], [-2100, -1100]], [[1350, 2300], [1350, 900], [-1350, 900], [-1350, 2300], [1350, 2300]], [[2140, 4350], [1940, 4050], [2950, 3390], [3150, 3690]]]},
{"id": "rail_12B", "name": "방호 난간 (12B 윗면)", "kind": "railing", "type": "railing", "color": "#d8c890", "z": -43700, "h": 1100, "paths": [[[-850, 4780], [-850, 3550], [850, 3550], [850, 4780]], [[-2100, 100], [2200, 100], [2200, -1100], [1350, -1100], [1350, -2300], [-1350, -2300], [-1350, -1100], [-2100, -1100]], [[1350, 2300], [1350, 900], [-1350, 900], [-1350, 2300], [1350, 2300]], [[2140, 4350], [1940, 4050], [2950, 3390], [3150, 3690]]]},
{"id": "rail_14B", "name": "방호 난간 (14B 윗면)", "kind": "railing", "type": "railing", "color": "#d8c890", "z": -50300, "h": 1100, "paths": [[[-850, 4780], [-850, 3550], [850, 3550], [850, 4780]], [[-2100, 100], [2200, 100], [2200, -1100], [1350, -1100], [1350, -2300], [-1350, -2300], [-1350, -1100], [-2100, -1100]], [[1350, 2300], [1350, 900], [-1350, 900], [-1350, 2300], [1350, 2300]], [[2140, 4350], [1940, 4050], [2950, 3390], [3150, 3690]]]},
{"id": "rail_16B", "name": "방호 난간 (16B 윗면)", "kind": "railing", "type": "railing", "color": "#d8c890", "z": -56900, "h": 1100, "paths": [[[-850, 4780], [-850, 3550], [850, 3550], [850, 4780]], [[-2100, 100], [2200, 100], [2200, -1100], [1350, -1100], [1350, -2300], [-1350, -2300], [-1350, -1100], [-2100, -1100]], [[1350, 2300], [1350, 900], [-1350, 900], [-1350, 2300], [1350, 2300]], [[2140, 4350], [1940, 4050], [2950, 3390], [3150, 3690]]]},
{"id": "rail_19B", "name": "방호 난간 (19B 패드 위)", "kind": "railing", "type": "railing", "color": "#d8c890", "z": -66100, "h": 1100, "paths": [[[-850, 4780], [-850, 3550], [850, 3550], [850, 4780]]]},
{"id": "pump_pipe", "name": "펌프 배출관 (STS Ø100)", "kind": "pipe", "type": "ring", "color": "#9aa0a6", "cx": 0, "cy": 4220, "z0": -68800, "z1": -2000, "r_out": 57, "r_in": 50},
{"id": "ladder_up_W", "name": "위쪽 사다리 서쪽 (1B→분기구 바닥)", "kind": "ladder_up", "type": "ladder", "color": "#d0a040", "x0": -4622, "x1": -4582, "yc": -1260, "width": 400, "z0": -7400, "z1": -4100, "rung": 300},
{"id": "ladder_up_C", "name": "위쪽 사다리 가운데 (1B→맨홀→지표)", "kind": "ladder_up", "type": "ladder", "color": "#d0a040", "x0": -20, "x1": 20, "yc": 0, "width": 360, "z0": -7400, "z1": 0, "rung": 300},
{"id": "ladder_vent_wall", "name": "환기구 벽 사다리 (1B→덕트 바닥)", "kind": "ladder_vent", "type": "ladder", "color": "#d0a040", "angle_deg": 300.3, "x0": 4582, "x1": 4622, "yc": 0, "width": 400, "z0": -7400, "z1": -4100, "rung": 300},
{"id": "ladder_vent_tower", "name": "환기탑 사다리 (덕트 바닥→지표)", "kind": "ladder_vent", "type": "ladder", "color": "#d0a040", "angle_deg": 30.3, "x0": 10100, "x1": 10140, "yc": -8650, "width": 400, "z0": -4100, "z1": 0, "rung": 300}
],
"drawing": {
"src": "private/drawing.svg",
"viewBox": [0, 0, 443, 443],
"fit": [118, 160, 245, 250],
"section": {"x0": 139.0, "y0": 174.08, "mm_per_unit": 1000},
"plan": {"cx": 227.51, "cy": 177.1, "mm_per_unit": 1000},
"views": [
{"id": "secA", "type": "section", "label": "단면 A-A", "x0": 139.0, "y0": 174.08},
{"id": "secB", "type": "section", "label": "단면 B-B", "x0": 179.01, "y0": 174.08},
{"id": "secA2", "type": "section_break", "label": "단면 A-A (4B~15B 생략)", "x0": 263.41, "y0_upper": 173.04, "y0_lower": 140.04, "z_upper_min": -17600, "z_lower_max": -53600},
{"id": "secB2", "type": "section_break", "label": "단면 B-B (4B~15B 생략)", "x0": 284.23, "y0_upper": 173.04, "y0_lower": 140.04, "z_upper_min": -17600, "z_lower_max": -53600},
{"id": "plan1", "type": "plan", "rot_deg": -30.3, "label": "평면도", "cx": 227.51, "cy": 177.1, "tunnel": true},
{"id": "plan2", "type": "plan", "rot_deg": -30.3, "label": "평면도 2", "cx": 227.51, "cy": 197.1, "tunnel": false},
{"id": "slabOdd", "type": "slabplan", "label": "중간슬래브 (홀수층)", "cx": 227.51, "cy": 226.31, "kind": "slab", "z1": [-7400, -14000, -20600, -27200, -33800, -40400, -47000, -53600, -60200], "tunnel": false},
{"id": "slabEven", "type": "slabplan", "label": "중간슬래브 (짝수층)", "cx": 252.27, "cy": 226.31, "kind": "slab", "z1": [-10700, -17300, -23900, -30500, -37100, -43700, -50300, -56900], "tunnel": false},
{"id": "slab18", "type": "slabplan", "label": "중간슬래브 (18B)", "cx": 277.02, "cy": 226.31, "kind": "slab", "z1": [-63500], "tunnel": false},
{"id": "slab19", "type": "slabplan", "label": "중간슬래브 (19B)", "cx": 227.51, "cy": 247.32, "kind": "slab", "z1": [-66200], "tunnel": true},
{"id": "slab20", "type": "slabplan", "label": "하부슬래브 (20B)", "cx": 252.27, "cy": 247.32, "kind": "bottom", "z1": [-69000], "tunnel": false}
]
}
};
+4861
View File
File diff suppressed because it is too large. Load diff
+340258
View File
File diff suppressed because it is too large. Load diff
+13861
View File
File diff suppressed because it is too large. Load diff

After

Width:  |  Height:  |  Size: 815 KiB

File diff suppressed because it is too large. Load diff

After

Width:  |  Height:  |  Size: 910 KiB

+162
View File
@@ -0,0 +1,162 @@
{
"_": "설계 규칙 (SPEC 27장). 도면 치수·좌표는 넣지 않는다.",
"parts": [
{
"id": "ladder_19B",
"name": "사다리 (침사구 아래 동쪽 벽)",
"kind": "ladder",
"type": "ladder",
"color": "#d0a040",
"x0": 4668,
"x1": 4708,
"yc": 0,
"width": 400,
"z0": -68700,
"z1": -66200,
"rung": 300
},
{
"id": "pump_pipe",
"name": "펌프 배출관 (STS Ø100)",
"kind": "pipe",
"type": "ring",
"color": "#9aa0a6",
"cx": 0,
"cy": 4220,
"z0": -68800,
"z1": -2000,
"r_out": 57,
"r_in": 50
},
{
"id": "ladder_up_W",
"name": "위쪽 사다리 서쪽 (1B→분기구 바닥)",
"kind": "ladder_up",
"type": "ladder",
"color": "#d0a040",
"x0": -4622,
"x1": -4582,
"yc": -1260,
"width": 400,
"z0": -7400,
"z1": -4100,
"rung": 300
},
{
"id": "ladder_up_C",
"name": "위쪽 사다리 가운데 (1B→맨홀→지표)",
"kind": "ladder_up",
"type": "ladder",
"color": "#d0a040",
"x0": -20,
"x1": 20,
"yc": 0,
"width": 360,
"z0": -7400,
"z1": 0,
"rung": 300
},
{
"id": "ladder_vent_wall",
"name": "환기구 벽 사다리 (1B→덕트 바닥)",
"kind": "ladder_vent",
"type": "ladder",
"color": "#d0a040",
"angle_deg": 300.3,
"x0": 4582,
"x1": 4622,
"yc": 0,
"width": 400,
"z0": -7400,
"z1": -4100,
"rung": 300
},
{
"id": "ladder_vent_tower",
"name": "환기탑 사다리 (덕트 바닥→지표)",
"kind": "ladder_vent",
"type": "ladder",
"color": "#d0a040",
"angle_deg": 30.3,
"x0": 10100,
"x1": 10140,
"yc": -8650,
"width": 400,
"z0": -4100,
"z1": 0,
"rung": 300
}
],
"style": {
"wall": {
"color": "#8a9bb0",
"alpha": 0.25
},
"topslab": {
"color": "#c8c8c8"
},
"neck": {
"color": "#b8b8b8"
},
"slab": {
"color": "#c8c8c8"
},
"pad": {
"color": "#e0b060"
},
"bottom": {
"color": "#a0a0a0"
},
"lean": {
"color": "#707070"
},
"tunnel": {
"color": "#b08a5a"
},
"stair": {
"color": "#7fb07f"
},
"fill": {
"color": "#c8bfa8"
},
"weir": {
"color": "#9a9a9a"
},
"pit": {
"color": "#8fc3e0",
"alpha": 0.25
},
"branch": {
"color": "#9fb8c8"
},
"vent": {
"color": "#b8c8a0"
},
"railing": {
"color": "#d8c890"
},
"pipe": {
"color": "#9aa0a6"
},
"ladder": {
"color": "#d0a040"
},
"ladder_up": {
"color": "#d0a040"
},
"ladder_vent": {
"color": "#d0a040"
}
},
"ids": {
"fill_gravel": {
"color": "#7a7a7a"
},
"fill_pad_high": {
"color": "#e0d8c4"
},
"pit_tbm": {
"color": "#a8d8f0"
}
}
}
+21
View File
@@ -0,0 +1,21 @@
#!/usr/bin/env bash
# dwg_to_dxf.sh — DWG 를 DXF(글자 파일)로 바꾼다 (GNU LibreDWG 의 dwg2dxf 사용, SPEC 28장)
# 사용: bash tools/dwg_to_dxf.sh <입력.dwg> <출력.dxf>
set -eu
if [ "$#" -ne 2 ]; then
echo "사용법: bash tools/dwg_to_dxf.sh <입력.dwg> <출력.dxf>"
exit 2
fi
DWG2DXF="$(command -v dwg2dxf || true)"
if [ -z "$DWG2DXF" ] && [ -x "$HOME/.local/bin/dwg2dxf" ]; then
DWG2DXF="$HOME/.local/bin/dwg2dxf"
fi
if [ -z "$DWG2DXF" ]; then
echo "dwg2dxf 를 찾지 못했다. 설치는 README.md 의 「변환 패키지」 절을 본다."
exit 2
fi
"$DWG2DXF" -y -o "$2" "$1"
echo "완료: $2"
+357
View File
@@ -0,0 +1,357 @@
#!/usr/bin/env python3
# dxf_to_svg.py — DXF 를 웹 왼쪽 칸의 SVG 로 바꾼다 (표준 라이브러리만, SPEC 29.1)
# 사용: python3 tools/dxf_to_svg.py <drawing.dxf> <views.json> <출력.svg>
# 상자·배치는 views.json 에서만 읽는다. 이 파일에는 도면 좌표가 없다.
import colorsys
import json
import math
import re
import sys
COORD_X = ("10", "11")
COORD_Y = ("20", "21")
ARC_STEP = 2.0 # 원호를 꺾은선으로 나눌 때 한 조각의 각도 (도)
TEXT_EM = 1.38942 # 글자 크기 = DXF 글자 높이 × 이 값 (원본 SVG 와 같게)
HIDDEN_LAYER = "Defpoints"
MAX_DEPTH = 8 # 블록 안 블록 깊이 한계
BASIC_ACI = {1: (255, 0, 0), 2: (255, 255, 0), 3: (0, 255, 0), 4: (0, 255, 255), 5: (0, 0, 255),
6: (255, 0, 255), 7: (0, 0, 0), 8: (128, 128, 128), 9: (192, 192, 192)}
GRAY_ACI = (51, 91, 132, 173, 214, 255) # 250~255
BRIGHT_ACI = (1, 1, 0.8, 0.8, 0.6, 0.6, 0.5, 0.5, 0.3, 0.3)
TEXT_ANCHOR_72 = {1: "middle", 2: "end", 4: "middle", 5: "middle"}
def aci_rgb(i):
if i in BASIC_ACI:
return BASIC_ACI[i]
if 250 <= i <= 255:
g = GRAY_ACI[i - 250]
return (g, g, g)
if 10 <= i <= 249:
hue = ((i - 10) // 10) * 15 / 360
sat = 1.0 if i % 2 == 0 else 0.5
r, g, b = colorsys.hsv_to_rgb(hue, sat, BRIGHT_ACI[i % 10])
return (round(r * 255), round(g * 255), round(b * 255))
return BASIC_ACI[7]
def read_pairs(path):
# DXF 는 (그룹 코드 줄, 값 줄) 쌍이다
with open(path, encoding="utf-8", errors="replace") as f:
lines = [ln[:-1] if ln.endswith("\r") else ln for ln in f.read().split("\n")]
if lines and lines[-1] == "":
lines.pop()
return [(lines[i].strip(), lines[i + 1].strip()) for i in range(0, len(lines) - 1, 2)]
def sections(pairs):
# 구역 이름 → (내용 시작 줄, ENDSEC 줄)
out = {}
for i in range(len(pairs) - 1):
if pairs[i] == ("0", "SECTION") and pairs[i + 1][0] == "2":
end = next(j for j in range(i + 2, len(pairs)) if pairs[j] == ("0", "ENDSEC"))
out[pairs[i + 1][1]] = (i + 2, end)
return out
def groups_of(pairs, lo, hi):
# 객체 묶음: 0 줄마다 시작. POLYLINE 과 코드 66 이 1 인 INSERT 는 SEQEND 까지 한 묶음
groups = []
i = lo
while i < hi:
j = i + 1
while j < hi and pairs[j][0] != "0":
j += 1
head = pairs[i][1]
flag66 = next((v for c, v in pairs[i + 1:j] if c == "66"), None)
if head == "POLYLINE" or (head == "INSERT" and flag66 == "1"):
while j < hi and pairs[j] != ("0", "SEQEND"):
j += 1
while j < hi and pairs[j][0] != "0":
j += 1
if j < hi:
j += 1
while j < hi and pairs[j][0] != "0":
j += 1
groups.append(pairs[i:j])
i = j
return groups
def val(g, code):
return next((v for c, v in g[1:] if c == code), None)
def num(g, code, default=0.0):
v = val(g, code)
return float(v) if v is not None else default
def layers_of(pairs, lo, hi):
# 레이어 표: 이름 → 색(절댓값), 꺼짐(음수), 얼림(70 의 1 비트)
tab = {}
for g in groups_of(pairs, lo, hi):
if g[0][1] != "LAYER":
continue
color = int(val(g, "62") or 7)
tab[val(g, "2")] = {"color": abs(color), "off": color < 0, "frozen": bool(int(val(g, "70") or 0) & 1)}
return tab
def blocks_of(pairs, lo, hi):
# 블록 이름 → 기준점과 안의 객체 묶음
blocks = {}
cur = None
for g in groups_of(pairs, lo, hi):
head = g[0][1]
if head == "BLOCK":
cur = {"base": (num(g, "10"), num(g, "20")), "ents": []}
blocks[val(g, "2")] = cur
elif head == "ENDBLK":
cur = None
elif cur is not None:
cur["ents"].append(g)
return blocks
def pts_of(g):
# 객체 좌표 (select_views.py 와 같은 규칙): 코드 10·11 의 X 와 뒤따르는 20·21 의 Y.
# POLYLINE 은 머리 기준점을 빼고 VERTEX 부터, CIRCLE·ARC 는 중심 ± 반지름도
start = 0
if g[0][1] == "POLYLINE":
start = next((k for k, p in enumerate(g) if p == ("0", "VERTEX")), None)
if start is None:
return []
body = g[start:]
pts = []
for k, (c, v) in enumerate(body[:-1]):
if c in COORD_X and body[k + 1][0] in COORD_Y:
pts.append((float(v), float(body[k + 1][1])))
if g[0][1] in ("CIRCLE", "ARC") and pts:
r = num(g, "40")
cx, cy = pts[0]
pts += [(cx - r, cy - r), (cx + r, cy + r)]
return pts
def inside(pts, box):
x0, y0, x1, y1 = box
return all(x0 <= x <= x1 and y0 <= y <= y1 for x, y in pts)
def mul(p, q):
# 행렬 (a, b, c, d, e, f): x' = a x + c y + e, y' = b x + d y + f. mul(p, q) = p∘q, q 를 먼저 적용한다
a1, b1, c1, d1, e1, f1 = p
a2, b2, c2, d2, e2, f2 = q
return (a1 * a2 + c1 * b2, b1 * a2 + d1 * b2, a1 * c2 + c1 * d2, b1 * c2 + d1 * d2,
a1 * e2 + c1 * f2 + e1, b1 * e2 + d1 * f2 + f1)
def apply(m, p):
a, b, c, d, e, f = m
return (a * p[0] + c * p[1] + e, b * p[0] + d * p[1] + f)
def det(m):
return m[0] * m[3] - m[1] * m[2]
def dir_angle(m, deg):
# 도면에서 deg 방향인 선이 SVG 에서 향하는 각도 (도)
rot = math.radians(deg)
vx = m[0] * math.cos(rot) + m[2] * math.sin(rot)
vy = m[1] * math.cos(rot) + m[3] * math.sin(rot)
return math.degrees(math.atan2(vy, vx))
def fmt(v):
s = ("%.3f" % v).rstrip("0").rstrip(".")
return "0" if s in ("", "-0") else s
def pts_attr(points):
return " ".join(f"{fmt(x)},{fmt(y)}" for x, y in points)
def arc_pts(p, q, b):
# 꼭짓점 p→q 사이 볼록 원호(bulge b, 양수 = 반시계)의 안쪽 점들 (p, q 제외)
th = 4 * math.atan(b)
chord = math.dist(p, q)
h = chord / 2 / math.tan(th / 2)
nx, ny = -(q[1] - p[1]) / chord, (q[0] - p[0]) / chord
cx = (p[0] + q[0]) / 2 + nx * h
cy = (p[1] + q[1]) / 2 + ny * h
r = math.dist(p, (cx, cy))
a0 = math.atan2(p[1] - cy, p[0] - cx)
k = max(2, math.ceil(abs(math.degrees(th)) / ARC_STEP))
return [(cx + r * math.cos(a0 + th * t / k), cy + r * math.sin(a0 + th * t / k)) for t in range(1, k)]
def verts_of(codes):
# 꼭짓점: 코드 10 마다 새 점, 20 = Y, 42 = 볼록
vs = []
for c, v in codes:
if c == "10":
vs.append([float(v), 0.0, 0.0])
elif c == "20" and vs:
vs[-1][1] = float(v)
elif c == "42" and vs:
vs[-1][2] = float(v)
return vs
def mtext_clean(s):
# MTEXT 서식 코드 지우기: \P 는 공백, \f…; 와 높이·폭 같은 코드, 중괄호
s = s.replace("\\P", " ")
s = re.sub(r"\\[fF][^;]*;", "", s)
s = re.sub(r"\\[HhWwQqTtCcAa][^;]*;", "", s)
return s.replace("{", "").replace("}", "")
def text_clean(s):
return s.replace("%%c", "Ø").replace("%%d", "°").replace("%%p", "±").replace("%%u", "")
def xml_escape(s):
return s.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace('"', "&quot;")
def emit(g, m, layers, blocks, parent_layer, inherit, out, depth):
# 객체 하나를 m(도면 → SVG 행렬)으로 옮겨 색별 목록 out 에 넣는다
head = g[0][1]
layer = val(g, "8") or "0"
if depth and layer == "0":
layer = parent_layer
lay = layers.get(layer, {"color": 7, "off": False, "frozen": False})
if lay["off"] or lay["frozen"] or layer == HIDDEN_LAYER:
return
c = int(val(g, "62") or 256)
if c == 256:
rgb = aci_rgb(lay["color"])
elif c == 0:
rgb = inherit if inherit is not None else aci_rgb(lay["color"])
else:
rgb = aci_rgb(abs(c))
key = "rgb(%d,%d,%d)" % rgb
els = out.setdefault(key, [])
scale = math.sqrt(abs(det(m)))
if head == "LINE":
pts = [apply(m, (num(g, "10"), num(g, "20"))), apply(m, (num(g, "11"), num(g, "21")))]
els.append(f'<polyline points="{pts_attr(pts)}" />')
elif head in ("LWPOLYLINE", "POLYLINE"):
codes = [p for p in g[1:] if p[0] != "0"] if head == "LWPOLYLINE" else \
[p for p in g[next(k for k, q in enumerate(g) if q == ("0", "VERTEX")):] if p[0] != "0"]
vs = verts_of(codes)
closed = bool(int(val(g, "70") or 0) & 1)
if len(vs) >= 2:
ring = vs + ([vs[0]] if closed else [])
pts = []
for (x0, y0, b0), (x1, y1, _) in zip(ring, ring[1:]):
pts.append((x0, y0))
if b0:
pts += arc_pts((x0, y0), (x1, y1), b0)
pts.append((ring[-1][0], ring[-1][1]))
els.append(f'<polyline points="{pts_attr([apply(m, p) for p in pts])}" />')
elif head == "LEADER":
vs = verts_of([p for p in g[1:] if p[0] in ("10", "20")])
if len(vs) >= 2:
els.append(f'<polyline points="{pts_attr([apply(m, (v[0], v[1])) for v in vs])}" />')
elif head == "CIRCLE":
cx, cy = apply(m, (num(g, "10"), num(g, "20")))
els.append(f'<circle cx="{fmt(cx)}" cy="{fmt(cy)}" r="{fmt(num(g, "40") * scale)}" />')
elif head == "ARC":
cx, cy, r = num(g, "10"), num(g, "20"), num(g, "40")
s0 = num(g, "50")
span = (num(g, "51") - s0) % 360 or 360
k = max(2, math.ceil(span / ARC_STEP))
pts = [(cx + r * math.cos(math.radians(s0 + span * t / k)), cy + r * math.sin(math.radians(s0 + span * t / k)))
for t in range(k + 1)]
els.append(f'<polyline points="{pts_attr([apply(m, p) for p in pts])}" />')
elif head == "SOLID":
# 채움 사각형: 점 순서 10·11·13·12
order = [("10", "20"), ("11", "21"), ("13", "23"), ("12", "22")]
pts = [apply(m, (num(g, x), num(g, y))) for x, y in order]
els.append(f'<polygon points="{pts_attr(pts)}" fill="{key}" stroke="none" />')
elif head == "TEXT":
h = num(g, "40")
ax, ay = ("11", "21") if (val(g, "72") or "0") != "0" or (val(g, "73") or "0") != "0" else ("10", "20")
x, y = apply(m, (num(g, ax), num(g, ay)))
ang = dir_angle(m, num(g, "50"))
anchor = TEXT_ANCHOR_72.get(int(val(g, "72") or 0), "start")
tr = f' transform="rotate({fmt(ang)} {fmt(x)} {fmt(y)})"' if abs(ang) > 1e-9 else ""
els.append(f'<text x="{fmt(x)}" y="{fmt(y)}" font-family="Dotum,\'sans-serif\'" font-size="{fmt(h * TEXT_EM * scale)}" '
f'text-anchor="{anchor}" fill="{key}" stroke="none"{tr}>{xml_escape(text_clean(val(g, "1") or ""))}</text>')
elif head == "MTEXT":
h = num(g, "40")
text = "".join(v for c, v in g if c == "3") + (val(g, "1") or "")
x, y = apply(m, (num(g, "10"), num(g, "20")))
ang = dir_angle(m, num(g, "50"))
col = (int(val(g, "71") or 1) - 1) % 3
anchor = ("start", "middle", "end")[col]
tr = f' transform="rotate({fmt(ang)} {fmt(x)} {fmt(y)})"' if abs(ang) > 1e-9 else ""
els.append(f'<text x="{fmt(x)}" y="{fmt(y)}" font-family="Dotum,\'sans-serif\'" font-size="{fmt(h * TEXT_EM * scale)}" '
f'text-anchor="{anchor}" fill="{key}" stroke="none"{tr}>{xml_escape(mtext_clean(text))}</text>')
elif head == "INSERT":
name = val(g, "2")
if name in blocks and depth < MAX_DEPTH:
# 블록 기준점을 빼고 (배율 → 회전 → 위치) 순으로 놓는다
bx, by = blocks[name]["base"]
sx, sy = num(g, "41", 1.0), num(g, "42", 1.0)
rot = math.radians(num(g, "50"))
ix, iy = num(g, "10"), num(g, "20")
t = mul((1, 0, 0, 1, ix, iy), mul((math.cos(rot), math.sin(rot), -math.sin(rot), math.cos(rot), 0, 0),
mul((sx, 0, 0, sy, 0, 0), (1, 0, 0, 1, -bx, -by))))
for child in blocks[name]["ents"]:
emit(child, mul(m, t), layers, blocks, layer, rgb, out, depth + 1)
elif head == "DIMENSION":
name = val(g, "2")
if name in blocks and depth < MAX_DEPTH:
for child in blocks[name]["ents"]:
emit(child, m, layers, blocks, layer, rgb, out, depth + 1)
def main(argv):
if len(argv) != 4:
print("사용법: python3 tools/dxf_to_svg.py <drawing.dxf> <views.json> <출력.svg>")
return 2
with open(argv[2], encoding="utf-8") as f:
cfg = json.load(f)
pairs = read_pairs(argv[1])
secs = sections(pairs)
layers = layers_of(pairs, *secs["TABLES"])
blocks = blocks_of(pairs, *secs["BLOCKS"])
groups = groups_of(pairs, *secs["ENTITIES"])
out = {}
kept = 0
for g in groups:
pts = pts_of(g)
if not pts:
continue
k = next((k for k, b in enumerate(cfg["boxes"]) if inside(pts, b["range"])), None)
if k is None:
continue
dx, dy = cfg["boxes"][k]["place"]
# 도면 (X, Y) → SVG (X − dx, dy − Y): 축척 없이 옮기고 Y 를 뒤집는다
emit(g, (1.0, 0.0, 0.0, -1.0, -dx, dy), layers, blocks, "0", None, out, 0)
kept += 1
vb = " ".join(str(v) for v in cfg["viewBox"])
lines = [f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="{vb}">',
"<style>* { vector-effect: non-scaling-stroke; }</style>"]
n_el = 0
for key, els in out.items():
lines.append(f'<g fill="none" stroke="{key}" stroke-width="1">')
lines += els
lines.append("</g>")
n_el += len(els)
lines.append("</svg>")
with open(argv[3], "w", encoding="utf-8") as f:
f.write("\n".join(lines) + "\n")
print(f"완료: {argv[3]} (객체 {kept}개 → 요소 {n_el}개)")
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv))
File diff suppressed because it is too large. Load diff
+28
View File
@@ -0,0 +1,28 @@
#!/usr/bin/env bash
# make_model.sh — DWG 한 장에서 추출 모델까지 한 번에 만든다 (SPEC 28장)
# 사용: bash tools/make_model.sh [입력.dwg] [출력 폴더]
# 기본: 입력 private/drawing.dwg, 출력 private/
# 만드는 것: full.dxf(전체 DXF) → drawing.dxf(상자 안만) → drawing_gen.svg(웹 도면) → model_extracted.js
# 출력 폴더가 private/ 이면 리포 루트의 model_extracted.js 도 새로 쓴다.
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
IN="${1:-$ROOT/private/drawing.dwg}"
OUT="${2:-$ROOT/private}"
if [ ! -f "$IN" ]; then
echo "입력 DWG 가 없다: $IN"
exit 2
fi
mkdir -p "$OUT"
OUT="$(cd "$OUT" && pwd)"
bash "$ROOT/tools/dwg_to_dxf.sh" "$IN" "$OUT/full.dxf"
python3 "$ROOT/tools/select_views.py" "$OUT/full.dxf" "$ROOT/tools/views.json" "$OUT/drawing.dxf"
python3 "$ROOT/tools/dxf_to_svg.py" "$OUT/drawing.dxf" "$ROOT/tools/views.json" "$OUT/drawing_gen.svg"
python3 "$ROOT/tools/extract_from_dxf.py" "$OUT/drawing.dxf" "$OUT/model_extracted.js" "$ROOT/tools/views.json"
if [ "$OUT" = "$ROOT/private" ]; then
cp "$OUT/model_extracted.js" "$ROOT/model_extracted.js"
echo "갱신: $ROOT/model_extracted.js"
fi
+56
View File
@@ -0,0 +1,56 @@
#!/usr/bin/env python3
# prep_drawing.py — 도면 SVG 를 화면에서 선명하게 만든다 (표준 라이브러리만 사용)
# 사용: python3 tools/prep_drawing.py <원본.svg> private/drawing.svg
# 원본은 고치지 않는다. 0 이 아닌 stroke-width 를 1 로 바꾸고,
# 루트 <svg> 바로 안에 <style> 을 넣어 모든 선이 화면 1px 로 그려지게 한다.
import re
import sys
def prep(text):
# 선 굵기 속성: stroke-width="0.75" → "1" (0 은 그대로 둔다: 선 없음)
def fix_attr(m):
value = m.group(1)
try:
if float(value) == 0:
return m.group(0)
except ValueError:
return m.group(0)
return 'stroke-width="1"'
text = re.sub(r'stroke-width="([^"]*)"', fix_attr, text)
# style 속성 안의 stroke-width:0.75 도 같은 규칙
def fix_style(m):
try:
if float(m.group(1)) == 0:
return m.group(0)
except ValueError:
return m.group(0)
return 'stroke-width:1'
text = re.sub(r'stroke-width:\s*([0-9.]+)', fix_style, text)
# 루트 <svg ...> 바로 뒤에 모든 요소의 선을 화면 기준으로 고정
style = '<style>* { vector-effect: non-scaling-stroke; }</style>'
text, n = re.subn(r'(<svg\b[^>]*>)', lambda m: m.group(1) + style, text, count=1)
if n != 1:
raise SystemExit('루트 <svg> 태그를 찾지 못했다')
return text
def main(argv):
if len(argv) != 3:
print('사용법: python3 tools/prep_drawing.py <원본.svg> <출력.svg>')
return 2
src, dst = argv[1], argv[2]
with open(src, encoding='utf-8') as f:
text = f.read()
with open(dst, 'w', encoding='utf-8') as f:
f.write(prep(text))
print(f'완료: {dst}')
return 0
if __name__ == '__main__':
sys.exit(main(sys.argv))
+178
View File
@@ -0,0 +1,178 @@
#!/usr/bin/env python3
# select_views.py — DXF 에서 이 과제가 쓰는 그림 상자 안의 객체만 남긴다 (표준 라이브러리만, SPEC 28장)
# 사용: python3 tools/select_views.py <전체.dxf> <views.json> <출력.dxf>
# 상자·뺄 레이어·뺄 블록 이름은 views.json 에서만 읽는다. 이 파일에는 도면 좌표·레이어 이름이 없다.
import json
import sys
COORD_X = ("10", "11")
COORD_Y = ("20", "21")
def read_pairs(path):
# DXF 는 (코드 줄, 값 줄) 쌍이다. 원본 글자를 그대로 두려고 줄 끝만 떼고 저장한다.
with open(path, encoding="utf-8", errors="surrogateescape", newline="") as f:
lines = f.read().split("\n")
lines = [ln[:-1] if ln.endswith("\r") else ln for ln in lines]
if lines and lines[-1] == "":
lines.pop()
return [(lines[i], lines[i + 1]) for i in range(0, len(lines) - 1, 2)]
def code_of(pair):
return pair[0].strip()
def value_of(pair):
return pair[1].strip()
def find_entities(pairs):
# ENTITIES 구역의 시작(SECTION 다음 줄이 ENTITIES)과 끝(ENDSEC) 위치
for s in range(len(pairs) - 1):
if code_of(pairs[s]) == "0" and value_of(pairs[s]) == "SECTION" and value_of(pairs[s + 1]) == "ENTITIES":
for e in range(s + 2, len(pairs)):
if code_of(pairs[e]) == "0" and value_of(pairs[e]) == "ENDSEC":
return s, e
raise SystemExit("ENTITIES 구역을 찾지 못했다")
def group_entities(body):
# 객체 묶음: 보통은 0 줄 하나에서 다음 0 줄 전까지.
# POLYLINE 은 SEQEND 까지. 코드 66 이 1 인 INSERT 는 ATTRIB…SEQEND 까지.
groups = []
i = 0
while i < len(body):
start = i
i += 1
while i < len(body) and code_of(body[i]) != "0":
i += 1
head_type = value_of(body[start])
if head_type == "POLYLINE" or (head_type == "INSERT" and has_attrib_flag(body[start:i])):
while i < len(body) and not (code_of(body[i]) == "0" and value_of(body[i]) == "SEQEND"):
i += 1
if i < len(body):
i += 1 # SEQEND 자체도 묶음에 넣는다
# 다음 객체 머리(0 줄)까지 같은 묶음
while i < len(body) and code_of(body[i]) != "0":
i += 1
groups.append(body[start:i])
return groups
def has_attrib_flag(head_pairs):
# INSERT 머리(첫 0 줄 다음) 안의 코드 66 값이 1 이면 속성이 뒤따른다
for p in head_pairs[1:]:
if code_of(p) == "66":
return value_of(p) == "1"
return False
def head_flag(group, code):
# 묶음 머리 객체의 코드 값 (ATTRIB 등 뒤 객체 값은 보지 않는다)
for p in group[1:]:
if code_of(p) == "0":
break
if code_of(p) == code:
return value_of(p)
return None
def coords(group):
# 좌표: 코드 10·11 의 X 와 그 뒤 20·21 의 Y. POLYLINE 은 머리의 기준점을 빼고 VERTEX 부터.
start = 0
if value_of(group[0]) == "POLYLINE":
for k, p in enumerate(group):
if code_of(p) == "0" and value_of(p) == "VERTEX":
start = k
break
else:
return []
pts = []
body = group[start:]
for k, p in enumerate(body):
if code_of(p) in COORD_X and k + 1 < len(body) and code_of(body[k + 1]) in COORD_Y:
try:
pts.append((float(value_of(p)), float(value_of(body[k + 1]))))
except ValueError:
pass
# CIRCLE·ARC: 중심 ± 반지름(코드 40)
if value_of(group[0]) in ("CIRCLE", "ARC") and pts:
r = head_flag(group, "40")
try:
r = float(r)
except (TypeError, ValueError):
r = 0.0
cx, cy = pts[0]
pts += [(cx - r, cy - r), (cx + r, cy + r)]
return pts
def dropped(group, cfg):
head = group[0]
if head_flag(group, "67") == "1":
return True # 배치 공간
if head_flag(group, "8") in cfg["drop_layers"]:
return True
if value_of(head) == "INSERT" and cfg["drop_block_word"] in (head_flag(group, "2") or ""):
return True
return False
def inside(pts, box):
x0, y0, x1, y1 = box
return all(x0 <= x <= x1 and y0 <= y <= y1 for x, y in pts)
def main(argv):
if len(argv) != 4:
print("사용법: python3 tools/select_views.py <전체.dxf> <views.json> <출력.dxf>")
return 2
with open(argv[2], encoding="utf-8") as f:
cfg = json.load(f)
boxes = [b["range"] for b in cfg["boxes"]]
pairs = read_pairs(argv[1])
s, e = find_entities(pairs)
groups = group_entities(pairs[s + 2:e])
kept, allpts = [], []
for g in groups:
if dropped(g, cfg):
continue
pts = coords(g)
if not pts:
continue
if any(inside(pts, b) for b in boxes):
kept.append(g)
allpts += pts
# 헤더의 $EXTMIN·$EXTMAX 를 남긴 좌표의 최소·최대로 바꾼다 (헤더 안 10/20 줄만)
head = pairs[:s]
if allpts:
xs = [p[0] for p in allpts]
ys = [p[1] for p in allpts]
new_min, new_max = (min(xs), min(ys)), (max(xs), max(ys))
for k in range(len(head) - 1):
name = value_of(head[k]) if code_of(head[k]) == "9" else None
if name not in ("$EXTMIN", "$EXTMAX"):
continue
target = new_min if name == "$EXTMIN" else new_max
j = k + 1
while j + 1 < len(head) and code_of(head[j]) != "9":
if code_of(head[j]) == "10":
head[j] = (head[j][0], repr(target[0]))
elif code_of(head[j]) == "20":
head[j] = (head[j][0], repr(target[1]))
j += 1
out = head + pairs[s:s + 2] + [p for g in kept for p in g] + pairs[e:]
with open(argv[3], "w", encoding="utf-8", errors="surrogateescape", newline="") as f:
for c, v in out:
f.write(c + "\r\n" + v + "\r\n")
print(f"완료: {argv[3]} (객체 {len(kept)}개 / 전체 {len(groups)}개)")
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv))
+80
View File
@@ -0,0 +1,80 @@
{
"_": "도면 범위 지정 (사람 입력, SPEC 28장). DWG 한 파일에 수직구 두 개와 옛 개정판이 함께 있어, 이 과제가 쓰는 그림 상자를 사람이 정한다. 좌표는 DWG 모형 공간(m). place = 그 상자의 점 (X, Y) 를 SVG (X − place[0], place[1] − Y) 에 놓는다(축척 없이 옮김, 1 = 1 m). viewBox·fit 은 왼쪽 도면 칸의 크기·처음 화면.",
"boxes": [
{
"name": "전체 단면 A-A·B-B",
"range": [
11307.483,
8061.096,
11407.483,
8151.096
],
"place": [
11200.381,
8314.952
]
},
{
"name": "평면도 1·2",
"range": [
11527.483,
8103.096,
11555.483,
8149.096
],
"place": [
11313.624,
8313.914
]
},
{
"name": "단면 A-A·B-B (4B~15B 생략)",
"range": [
11570.483,
8099.096,
11619.483,
8149.096
],
"place": [
11322.610,
8313.912
]
},
{
"name": "슬래브 평면 5개",
"range": [
11653.483,
8103.096,
11725.483,
8147.096
],
"place": [
11437.082,
8360.590
]
}
],
"drop_layers": [
"T-타이틀",
"0-도곽",
"PDF_문자",
"Ptpoint",
"Grid",
"CP",
"CP-NO",
"CP-EL"
],
"drop_block_word": "도각",
"viewBox": [
0,
0,
443,
443
],
"fit": [
118,
160,
245,
250
]
}
+922
View File
@@ -0,0 +1,922 @@
const canvas = document.getElementById('view');
const ctx = canvas.getContext('2d');
const viewPane = document.getElementById('viewPane');
const drawPane = document.getElementById('drawPane');
const drawMsg = document.getElementById('drawMsg');
const splitter = document.getElementById('splitter');
const drawStage = document.getElementById('drawStage');
const drawImg = document.getElementById('drawing');
const hotspots = document.getElementById('hotspots');
const partLabel = document.getElementById('partLabel');
// 도면 클릭으로 고른 부재 id 들 (없으면 빈 배열)
let SELECTED = [];
// 모델 중심(벽 중심, 높이 가운데)과 중심에서 가장 먼 점까지 거리(mm, 대략). 처음 화면이 모델 전체를 담게 한다
const MODEL_CENTER = [0, 0, -35150];
const MODEL_R = 40000;
// 카메라 처음 값: 등각 (좌우 45도, 기울기 35도)
const CAM_HOME = { yaw: 45 * Math.PI / 180, pitch: 35 * Math.PI / 180 };
// 카메라: target 은 보는 중심(mm), 처음에는 모델 중심
const CAM = { ...CAM_HOME, target: [...MODEL_CENTER] };
// 빛 방향 (화면 기준: 오른쪽, 위, 보는 사람 쪽)
const LIGHT = [-0.4, 0.7, 0.6];
let SCALE = 1;
// 휠 배율: 1 = 창에 맞춘 크기, 0.5 ~ 40 사이에서만 바뀐다
let ZOOM = 1;
const ZOOM_MIN = 0.5, ZOOM_MAX = 40;
function resize() {
canvas.width = viewPane.clientWidth;
canvas.height = viewPane.clientHeight;
draw();
}
// 세계 좌표(mm) → 화면 기준 좌표 [오른쪽, 위, 멀수록 큰 값]
function rotatePoint(p) {
const c = Math.cos(CAM.yaw), s = Math.sin(CAM.yaw);
const x1 = p[0] * c - p[1] * s;
const y1 = p[0] * s + p[1] * c;
const z1 = p[2];
const cp = Math.cos(CAM.pitch), sp = Math.sin(CAM.pitch);
return [x1, y1 * sp + z1 * cp, y1 * cp - z1 * sp];
}
// 화면 기준 벡터 → 세계 좌표 벡터 (rotatePoint 의 역. 화면 이동량을 세계 좌표로 바꿀 때 쓴다)
function unrotateVector(v) {
const cp = Math.cos(CAM.pitch), sp = Math.sin(CAM.pitch);
const y1 = v[1] * sp + v[2] * cp;
const z1 = v[1] * cp - v[2] * sp;
const c = Math.cos(CAM.yaw), s = Math.sin(CAM.yaw);
return [v[0] * c + y1 * s, -v[0] * s + y1 * c, z1];
}
// 화면 기준 좌표 → 캔버스 픽셀 (보는 중심 CAM.target 을 화면 가운데에 둔다)
function project(v) {
const c = rotatePoint(CAM.target);
return [canvas.width / 2 + (v[0] - c[0]) * SCALE, canvas.height / 2 - (v[1] - c[1]) * SCALE];
}
// 배율 z 일 때 mm 당 픽셀
function scaleFor(z) {
return Math.min(canvas.width, canvas.height) / (2 * MODEL_R) * z;
}
// 직육면체 6면. 각 면은 점 4개, 바깥 방향 법선, 색, alpha
function facesBox(x0, y0, z0, x1, y1, z1, color, alpha) {
const c = [
[x0, y0, z0], [x1, y0, z0], [x1, y1, z0], [x0, y1, z0],
[x0, y0, z1], [x1, y0, z1], [x1, y1, z1], [x0, y1, z1],
];
const quads = [
[[0, 1, 2, 3], [0, 0, -1]],
[[4, 5, 6, 7], [0, 0, 1]],
[[0, 1, 5, 4], [0, -1, 0]],
[[2, 3, 7, 6], [0, 1, 0]],
[[1, 2, 6, 5], [1, 0, 0]],
[[3, 0, 4, 7], [-1, 0, 0]],
];
return quads.map(([idx, n]) => ({ pts: idx.map(i => c[i]), n, color, alpha }));
}
// 세운 원판 (32각형): 윗면·아랫면 뚜껑 2장 + 옆면 32장
function facesDisc(p) {
const N = 32;
const alpha = p.alpha ?? 1;
const ring = z => Array.from({ length: N }, (_, i) => {
const a = 2 * Math.PI * i / N;
return [p.cx + p.r * Math.cos(a), p.cy + p.r * Math.sin(a), z];
});
const top = ring(p.z1), bot = ring(p.z0);
const faces = [
{ pts: top, n: [0, 0, 1], color: p.color, alpha },
{ pts: bot, n: [0, 0, -1], color: p.color, alpha },
];
for (let i = 0; i < N; i++) {
const j = (i + 1) % N;
const a = 2 * Math.PI * (i + 0.5) / N;
faces.push({ pts: [bot[i], bot[j], top[j], top[i]], n: [Math.cos(a), Math.sin(a), 0], color: p.color, alpha });
}
return faces;
}
// 구멍 모양 하나를 점 목록으로 (SPEC 17.1): 점 목록이면 그대로, {circle: [x, y, r]} 면 16각형
function holePolygon(h) {
if (!h.circle) return h;
const [x, y, r] = h.circle;
return Array.from({ length: 16 }, (_, i) => {
const a = 2 * Math.PI * i / 16;
return [x + r * Math.cos(a), y + r * Math.sin(a)];
});
}
// 구멍 난 뚜껑·옆면 (SPEC 15.1, 17.1): 윗면·아랫면에 구멍 고리를 넣고, 구멍 둘레마다 세로 옆면을 더한다. holes 는 절대 좌표 점 목록
function addHoles(faces, holes, z0, z1, color, alpha) {
faces[0].holes = holes.map(poly => poly.map(q => [q[0], q[1], z1]));
faces[1].holes = holes.map(poly => poly.map(q => [q[0], q[1], z0]));
for (const poly of holes) {
// 넓이가 양수(반시계)면 왼쪽 법선이 구멍 안을 가리킨다. 아니면 뒤집는다
const area = poly.reduce((s, q, i) => {
const r = poly[(i + 1) % poly.length];
return s + q[0] * r[1] - r[0] * q[1];
}, 0);
const sign = area > 0 ? 1 : -1;
const top = poly.map(q => [q[0], q[1], z1]), bot = poly.map(q => [q[0], q[1], z0]);
for (let i = 0; i < poly.length; i++) {
const j = (i + 1) % poly.length;
const dx = poly[j][0] - poly[i][0], dy = poly[j][1] - poly[i][1];
const len = Math.hypot(dx, dy);
faces.push({ pts: [bot[i], bot[j], top[j], top[i]], n: [sign * -dy / len, sign * dx / len, 0], color, alpha });
}
}
}
// 구멍 난 세운 원판 (SPEC 15.1): facesDisc 에 구멍을 얹는다. 구멍 점을 부재 위치(cx, cy)로 옮긴 뒤 addHoles
function facesDiscHoles(p) {
const faces = facesDisc(p);
const holes = (p.holes || []).map(h => holePolygon(h).map(q => [p.cx + q[0], p.cy + q[1]]));
if (holes.length) addHoles(faces, holes, p.z0, p.z1, p.color, p.alpha ?? 1);
return faces;
}
// 세운 속 빈 원통 (32각형): 바깥면·안쪽면·윗고리·아랫고리, 각 32장씩
function facesRing(p) {
const N = 32;
const alpha = p.alpha ?? 1;
const ring = (r, z) => Array.from({ length: N }, (_, i) => {
const a = 2 * Math.PI * i / N;
return [p.cx + r * Math.cos(a), p.cy + r * Math.sin(a), z];
});
const outTop = ring(p.r_out, p.z1), outBot = ring(p.r_out, p.z0);
const inTop = ring(p.r_in, p.z1), inBot = ring(p.r_in, p.z0);
const faces = [
{ pts: outTop, n: [0, 0, 1], color: p.color, alpha },
{ pts: outBot, n: [0, 0, -1], color: p.color, alpha },
];
for (let i = 0; i < N; i++) {
const j = (i + 1) % N;
const a = 2 * Math.PI * (i + 0.5) / N;
const c = Math.cos(a), s = Math.sin(a);
faces.push({ pts: [outBot[i], outBot[j], outTop[j], outTop[i]], n: [c, s, 0], color: p.color, alpha });
faces.push({ pts: [inBot[j], inBot[i], inTop[i], inTop[j]], n: [-c, -s, 0], color: p.color, alpha });
faces.push({ pts: [outTop[i], outTop[j], inTop[j], inTop[i]], n: [0, 0, 1], color: p.color, alpha });
faces.push({ pts: [outBot[i], inBot[i], inBot[j], outBot[j]], n: [0, 0, -1], color: p.color, alpha });
}
return faces;
}
// 누운 속 빈 원통 (32각형): 축 시작점(x, y, z)에서 angle_deg 방향으로 length 만큼
function facesPipe(p) {
const N = 32;
const alpha = p.alpha ?? 1;
const a = p.angle_deg * Math.PI / 180;
const d = [Math.cos(a), Math.sin(a), 0];
const u = [-Math.sin(a), Math.cos(a), 0];
// 축 위치 t 에서, 반지름 r, 각도 th 인 점 (u 는 수평 직각 방향, 위는 z)
const pt = (t, r, th) => [
p.x + d[0] * t + r * Math.cos(th) * u[0],
p.y + d[1] * t + r * Math.cos(th) * u[1],
p.z + r * Math.sin(th),
];
const ring = (t, r) => Array.from({ length: N }, (_, i) => pt(t, r, 2 * Math.PI * i / N));
const outA = ring(0, p.r_out), outB = ring(p.length, p.r_out);
const inA = ring(0, p.r_in), inB = ring(p.length, p.r_in);
const faces = [];
for (let i = 0; i < N; i++) {
const j = (i + 1) % N;
const th = 2 * Math.PI * (i + 0.5) / N;
const n = [Math.cos(th) * u[0], Math.cos(th) * u[1], Math.sin(th)];
faces.push({ pts: [outA[i], outA[j], outB[j], outB[i]], n, color: p.color, alpha });
faces.push({ pts: [inB[i], inB[j], inA[j], inA[i]], n: n.map(x => -x), color: p.color, alpha });
faces.push({ pts: [outA[i], outA[j], inA[j], inA[i]], n: [-d[0], -d[1], 0], color: p.color, alpha });
faces.push({ pts: [outB[i], inB[i], inB[j], outB[j]], n: [d[0], d[1], 0], color: p.color, alpha });
}
return faces;
}
// 터널 안 채움: 원을 수평선 v0·v1 로 자른 단면을 축 방향으로 늘인다. half_width 가 0 이 아니면 그 폭 안으로만
function facesFill(p) {
const N = 8;
const alpha = p.alpha ?? 1;
const a = p.angle_deg * Math.PI / 180;
const d = [Math.cos(a), Math.sin(a), 0];
const u = [-Math.sin(a), Math.cos(a), 0];
// 높이 v 에서의 반폭 (원 안쪽, half_width 가 있으면 그 안)
const half = v => {
const w = Math.sqrt(Math.max(0, p.r * p.r - v * v));
return p.half_width > 0 ? Math.min(w, p.half_width) : w;
};
// 단면 윤곽 (u, v), 반시계: 오른쪽 옆을 위로, 왼쪽 옆을 아래로. 아랫선·윗선은 두 점을 잇는다
const vs = Array.from({ length: N + 1 }, (_, k) => p.v0 + (p.v1 - p.v0) * k / N);
const prof = [
...vs.map(v => [half(v), v]),
...vs.slice().reverse().map(v => [-half(v), v]),
];
// 겹치는 점 (아랫선 폭 0 등) 은 뺀다. 길이 0 인 면은 법선을 못 구한다
const poly = prof.filter((q, i) => {
const r = prof[(i + prof.length - 1) % prof.length];
return Math.hypot(q[0] - r[0], q[1] - r[1]) > 1e-9;
});
// 축 위치 t 에서 단면 한 바퀴의 점들
const ring = t => poly.map(([uu, vv]) => [
p.x + d[0] * t + uu * u[0],
p.y + d[1] * t + uu * u[1],
p.z + vv,
]);
const A = ring(0), B = ring(p.length);
const faces = [
{ pts: A, n: [-d[0], -d[1], 0], color: p.color, alpha },
{ pts: B, n: [d[0], d[1], 0], color: p.color, alpha },
];
for (let i = 0; i < poly.length; i++) {
const j = (i + 1) % poly.length;
const du = poly[j][0] - poly[i][0], dv = poly[j][1] - poly[i][1];
const len = Math.hypot(du, dv);
// 단면 윤곽의 바깥 법선 (반시계 기준), 수평 성분은 u 방향으로 펼친다
const nu = dv / len, nv = -du / len;
faces.push({ pts: [A[i], A[j], B[j], B[i]], n: [nu * u[0], nu * u[1], nv], color: p.color, alpha });
}
return faces;
}
// 다각형을 z0~z1 로 세운 기둥 (SPEC 16.1 prism): 윗면·아랫면 뚜껑 2장 + 변마다 옆면. 면의 바깥 법선은 다각형 방향(넓이 부호)으로 정한다
function facesPrism(p) {
const alpha = p.alpha ?? 1;
const poly = p.polygon;
const area = poly.reduce((s, q, i) => {
const r = poly[(i + 1) % poly.length];
return s + q[0] * r[1] - r[0] * q[1];
}, 0);
const sign = area > 0 ? 1 : -1;
const top = poly.map(q => [q[0], q[1], p.z1]);
const bot = poly.map(q => [q[0], q[1], p.z0]);
const faces = [
{ pts: top, n: [0, 0, 1], color: p.color, alpha },
{ pts: bot, n: [0, 0, -1], color: p.color, alpha },
];
for (let i = 0; i < poly.length; i++) {
const j = (i + 1) % poly.length;
const dx = poly[j][0] - poly[i][0], dy = poly[j][1] - poly[i][1];
const len = Math.hypot(dx, dy);
faces.push({ pts: [bot[i], bot[j], top[j], top[i]], n: [sign * -dy / len, sign * dx / len, 0], color: p.color, alpha });
}
if (p.holes) addHoles(faces, p.holes.map(holePolygon), p.z0, p.z1, p.color, alpha);
return faces;
}
// 원통(반지름 r) 안에서 x_min ≤ X ≤ x_max 인 덩어리 (SPEC 16.1 zone): 위아래 호를 따라 점을 찍어 prism 으로 세운다 (활꼴 기둥)
function facesZone(p) {
const N = 24;
const xs = Array.from({ length: N + 1 }, (_, k) => p.x_min + (p.x_max - p.x_min) * k / N);
const up = xs.map(x => [x, Math.sqrt(Math.max(0, p.r * p.r - x * x))]);
const low = xs.slice().reverse().map(x => [x, -Math.sqrt(Math.max(0, p.r * p.r - x * x))]);
return facesPrism({ ...p, polygon: [...up, ...low] });
}
// 사다리 (SPEC 16.1 ladder): 기둥 2개(X x0~x1, Y 는 yc ± width/2 를 가운데로 두께 40), 가로대(rung 간격, 두께 25, Y 는 width 폭)
function facesLadder(p) {
const faces = [];
const half = p.width / 2;
for (const c of [p.yc - half, p.yc + half]) {
faces.push(...facesBox(p.x0, c - 20, p.z0, p.x1, c + 20, p.z1, p.color, p.alpha ?? 1));
}
for (let z = p.z0 + p.rung; z + 12.5 <= p.z1; z += p.rung) {
faces.push(...facesBox(p.x0, p.yc - half, z - 12.5, p.x1, p.yc + half, z + 12.5, p.color, p.alpha ?? 1));
}
// angle_deg 가 있으면 원점 (0, 0) 기준으로 돌린다 (SPEC 26장). 없으면 0 이라 그대로. 법선도 같이 돈다
const a = (p.angle_deg ?? 0) * Math.PI / 180;
const cs = Math.cos(a), sn = Math.sin(a);
const turn = v => [v[0] * cs - v[1] * sn, v[0] * sn + v[1] * cs, v[2]];
return faces.map(f => ({ ...f, pts: f.pts.map(turn), n: turn(f.n) }));
}
// 돌린 직육면체 (SPEC 17.2 obox): 상자를 (s, t, z) 로 만든 뒤 angle_deg 만큼 X축 반시계로 돌려 (cx, cy) 에 놓는다. 법선도 같이 돈다
function facesObox(p) {
const a = p.angle_deg * Math.PI / 180;
const cs = Math.cos(a), sn = Math.sin(a);
const turn = v => [v[0] * cs - v[1] * sn, v[0] * sn + v[1] * cs, v[2]];
return facesBox(p.s0, p.t0, p.z0, p.s1, p.t1, p.z1, p.color, p.alpha ?? 1).map(f => ({
...f,
pts: f.pts.map(q => { const r = turn(q); return [r[0] + p.cx, r[1] + p.cy, r[2]]; }),
n: turn(f.n),
}));
}
// 방호 난간 (SPEC 17.3 railing): 꺾은선 paths 의 각 구간을 두께 40 의 얇은 판으로 z 부터 z+h 까지 세운다. 판마다 옆 2·끝 2·윗면·아랫면 6장
function facesRailing(p) {
const T = 20; // 판 두께의 절반 (mm)
const alpha = p.alpha ?? 1;
const zb = p.z, zt = p.z + p.h;
const faces = [];
for (const path of p.paths) {
for (let i = 0; i + 1 < path.length; i++) {
const [ax, ay] = path[i], [bx, by] = path[i + 1];
const len = Math.hypot(bx - ax, by - ay);
if (len === 0) continue;
const dx = (bx - ax) / len, dy = (by - ay) / len;
// 판 두께 방향 단위 벡터
const ux = -dy, uy = dx;
// 구간 양 끝에서 두께 방향으로 ±T 간 네 모서리 (0: A+u, 1: B+u, 2: B-u, 3: A-u)
const q = [[ax + ux * T, ay + uy * T], [bx + ux * T, by + uy * T], [bx - ux * T, by - uy * T], [ax - ux * T, ay - uy * T]];
const up = k => [q[k][0], q[k][1], zt];
const dn = k => [q[k][0], q[k][1], zb];
faces.push({ pts: [dn(0), dn(1), up(1), up(0)], n: [ux, uy, 0], color: p.color, alpha });
faces.push({ pts: [dn(2), dn(3), up(3), up(2)], n: [-ux, -uy, 0], color: p.color, alpha });
faces.push({ pts: [dn(1), dn(2), up(2), up(1)], n: [dx, dy, 0], color: p.color, alpha });
faces.push({ pts: [dn(3), dn(0), up(0), up(3)], n: [-dx, -dy, 0], color: p.color, alpha });
faces.push({ pts: [up(0), up(1), up(2), up(3)], n: [0, 0, 1], color: p.color, alpha });
faces.push({ pts: [dn(0), dn(3), dn(2), dn(1)], n: [0, 0, -1], color: p.color, alpha });
}
}
return faces;
}
// 콘크리트 계단 한 런의 단면 (SPEC 18.2): [s, z] 점 목록. s 는 x_top 에서 내려가는 방향 거리
// 마지막 점은 첫 점으로 되돌아오지 않는다 (다각형은 저절로 닫힌다)
function stairProfile(p) {
const n = p.risers, zh = p.z_high, zl = p.z_low;
const pts = [[0, zh]];
for (let j = 0; j <= n - 2; j++) {
const z = zh - p.riser * (j + 1);
pts.push([j * p.tread, z], [(j + 1) * p.tread, z]);
}
pts.push([(n - 1) * p.tread, zl]);
const sBottom = (zh - zl - 400) * 1.5;
pts.push([sBottom, zl], [0, zh - 400]);
return pts;
}
// 계단 한 런: 단면을 Y 띠(y0~y1) 폭만큼 늘인다. 옆면 2개 + 둘레 변마다 사각형
function facesStair(p) {
const alpha = p.alpha ?? 1;
const X = s => p.x_top + p.dir * s;
const prof = stairProfile(p).map(([s, z]) => [X(s), z]);
const area = prof.reduce((acc, q, i) => {
const r = prof[(i + 1) % prof.length];
return acc + q[0] * r[1] - r[0] * q[1];
}, 0);
const sign = area > 0 ? 1 : -1;
const yLo = Math.min(p.y0, p.y1), yHi = Math.max(p.y0, p.y1);
const faces = [
{ pts: prof.map(([x, z]) => [x, yLo, z]), n: [0, -1, 0], color: p.color, alpha },
{ pts: prof.map(([x, z]) => [x, yHi, z]), n: [0, 1, 0], color: p.color, alpha },
];
for (let i = 0; i < prof.length; i++) {
const j = (i + 1) % prof.length;
const dx = prof[j][0] - prof[i][0], dz = prof[j][1] - prof[i][1];
const len = Math.hypot(dx, dz);
const n = [sign * dz / len, 0, sign * -dx / len];
faces.push({
pts: [[prof[i][0], yLo, prof[i][1]], [prof[j][0], yLo, prof[j][1]],
[prof[j][0], yHi, prof[j][1]], [prof[i][0], yHi, prof[i][1]]],
n, color: p.color, alpha,
});
}
return faces;
}
// 모든 부재의 면을 한 줄로 모은다 (disc, ring, pipe, stair, fill, prism, zone, ladder, obox, railing)
function buildFaces(parts) {
const faces = [];
for (const p of parts) {
if (p.type === 'disc') faces.push(...facesDiscHoles(p));
else if (p.type === 'ring') faces.push(...facesRing(p));
else if (p.type === 'pipe') faces.push(...facesPipe(p));
else if (p.type === 'stair') faces.push(...facesStair(p));
else if (p.type === 'fill') faces.push(...facesFill(p));
else if (p.type === 'prism') faces.push(...facesPrism(p));
else if (p.type === 'zone') faces.push(...facesZone(p));
else if (p.type === 'ladder') faces.push(...facesLadder(p));
else if (p.type === 'obox') faces.push(...facesObox(p));
else if (p.type === 'railing') faces.push(...facesRailing(p));
}
return faces;
}
// 면 밝기 계수: 법선이 빛 쪽을 향할수록 밝다 (0.4 ~ 1.0)
function faceBrightness(n) {
const v = rotatePoint(n);
const nv = [v[0], v[1], -v[2]];
const len = Math.hypot(LIGHT[0], LIGHT[1], LIGHT[2]);
const d = (nv[0] * LIGHT[0] + nv[1] * LIGHT[1] + nv[2] * LIGHT[2]) / len;
return 0.4 + 0.6 * Math.max(0, d);
}
// '#rrggbb' 색을 계수 k 만큼 밝기 조절
function shadeColor(hex, k) {
const n = parseInt(hex.slice(1), 16);
const ch = [(n >> 16) & 255, (n >> 8) & 255, n & 255]
.map(x => Math.min(255, Math.round(x * k)));
return `rgb(${ch[0]}, ${ch[1]}, ${ch[2]})`;
}
// 먼 면부터 (화가 알고리즘): 면 가운데 점의 깊이가 큰 것부터
function sortFaces(faces) {
for (const f of faces) {
const depths = f.pts.map(p => rotatePoint(p)[2]);
f.depth = depths.reduce((a, b) => a + b, 0) / depths.length;
}
return faces.sort((a, b) => b.depth - a.depth);
}
function draw() {
ctx.fillStyle = '#1e1e1e';
ctx.fillRect(0, 0, canvas.width, canvas.height);
SCALE = scaleFor(ZOOM);
// 선택된 부재는 노란색·불투명으로 그린다
const parts = MODEL.parts.map(p => SELECTED.includes(p.id) ? { ...p, color: '#ffd400', alpha: 1 } : p);
// 사람 두 명은 멈춰 있어도 마지막 위치에 그린다 (계단이 없으면 경로가 없어 사람을 그리지 않는다)
const team = HAS_WALK ? teamAt(WALK_T) : [];
const people = team.flatMap((pose, i) => facesPerson(pose.pos, pose.h, WALK_COLORS[i]));
const faces = sortFaces([...buildFaces(parts), ...people]);
for (const f of faces) {
ctx.beginPath();
// 구멍 고리는 바깥 고리와 같은 경로에 넣고 evenodd 로 채워 구멍을 뚫는다
for (const ring of [f.pts, ...(f.holes || [])]) {
ring.forEach((p, i) => {
const [x, y] = project(rotatePoint(p));
if (i === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
});
ctx.closePath();
}
ctx.globalAlpha = f.alpha;
ctx.fillStyle = shadeColor(f.color, faceBrightness(f.n));
ctx.fill(f.holes ? 'evenodd' : 'nonzero');
}
ctx.globalAlpha = 1;
}
// 드래그 궤도 회전: 좌우 = Z축 둘레(yaw), 위아래 = 기울기(pitch)
const ROT_RATE = 0.01; // 픽셀당 라디안
const PITCH_LIMIT = 89 * Math.PI / 180;
let dragging = false, panning = false, lastX = 0, lastY = 0;
// 왼쪽 드래그 = 회전. 오른쪽 버튼 또는 Shift + 왼쪽 = 화면 이동 (SPEC 15.3)
function onMouseDown(e) {
panning = e.button === 2 || e.shiftKey;
dragging = !panning;
lastX = e.clientX;
lastY = e.clientY;
}
function onMouseMove(e) {
if (!dragging && !panning) return;
if (panning) {
panView(e.clientX - lastX, e.clientY - lastY);
} else {
CAM.yaw += (e.clientX - lastX) * ROT_RATE;
CAM.pitch = Math.max(-PITCH_LIMIT, Math.min(PITCH_LIMIT, CAM.pitch + (e.clientY - lastY) * ROT_RATE));
}
lastX = e.clientX;
lastY = e.clientY;
draw();
}
function onMouseUp() {
dragging = false;
panning = false;
}
// 화면 이동: 드래그한 만큼 그림이 따라오게 보는 중심(target)을 화면 평면에서 옮긴다 (픽셀 → mm)
function panView(dx, dy) {
const d = unrotateVector([-dx / SCALE, dy / SCALE, 0]);
CAM.target = CAM.target.map((x, i) => x + d[i]);
}
// 마우스 아래의 점이 그대로 머물게 배율을 step 배 바꾼다 (배율 0.5~40). 보는 중심을 옮겨서 맞춘다
function zoomAt(clientX, clientY, step) {
const rect = canvas.getBoundingClientRect();
const mx = clientX - rect.left, my = clientY - rect.top;
const old = scaleFor(ZOOM);
ZOOM = Math.max(ZOOM_MIN, Math.min(ZOOM_MAX, ZOOM * step));
// 화면 가운데에서 마우스까지의 거리(픽셀)가 배율에 따라 달라지는 만큼 보는 중심을 옮긴다
const k = 1 / old - 1 / scaleFor(ZOOM);
const d = unrotateVector([(mx - canvas.width / 2) * k, (canvas.height / 2 - my) * k, 0]);
CAM.target = CAM.target.map((x, i) => x + d[i]);
}
// 처음 화면: 등각, 배율 1, 보는 중심 = 모델 중심 (더블클릭 또는 「처음 화면」 버튼)
function resetView() {
Object.assign(CAM, CAM_HOME, { target: [...MODEL_CENTER] });
ZOOM = 1;
draw();
}
// 휠: 위로 굴리면 확대, 아래로 굴리면 축소 (한 칸당 10%, 마우스 아래 점 고정)
function onWheel(e) {
e.preventDefault();
zoomAt(e.clientX, e.clientY, e.deltaY < 0 ? 1.1 : 1 / 1.1);
draw();
}
// 가운데 막대 끌기: 왼쪽 도면 칸 폭을 창 폭의 15%~85% 로. 바뀔 때마다 3D 캔버스를 다시 맞춘다
let splitDragging = false;
function onSplitDown(e) {
splitDragging = true;
e.preventDefault();
}
function onSplitMove(e) {
if (!splitDragging) return;
const frac = Math.max(0.15, Math.min(0.85, e.clientX / window.innerWidth));
drawPane.style.width = (frac * 100) + '%';
resize();
}
function onSplitUp() {
splitDragging = false;
}
// 도면 확대·이동: 1 = 도면 SVG 원래 크기(443px), 0.5 ~ 40 사이에서만 바뀐다
const DRAW_SIZE = 443;
const DRAW_ZOOM_MIN = 0.5, DRAW_ZOOM_MAX = 40;
// 도면 칸 안에서의 배율(s)과 이동(tx, ty, 칸 픽셀)
let DRAW = { s: 1, tx: 0, ty: 0 };
let drawDragging = false, drawLastX = 0, drawLastY = 0;
// 이번 드래그에서 실제로 움직였는지 (움직였으면 클릭으로 치지 않는다)
let drawMoved = false;
// 배율은 크기(width/height)에 넣어 브라우저가 벡터를 다시 그리게 한다. 위치만 translate
function applyDrawView() {
const px = DRAW_SIZE * DRAW.s + 'px';
for (const el of [drawImg, hotspots]) {
el.style.width = px;
el.style.height = px;
}
drawStage.style.transform = `translate(${DRAW.tx}px, ${DRAW.ty}px)`;
}
// 처음 화면: 도면 fit 상자(MODEL.drawing.fit)가 왼쪽 칸에 꽉 차게. fit 이 아직 없으면 도면 전체
function fitDrawing() {
const [u0, v0, u1, v1] = (MODEL.drawing && MODEL.drawing.fit) || [0, 0, DRAW_SIZE, DRAW_SIZE];
const W = drawPane.clientWidth, H = drawPane.clientHeight;
const s = Math.min(W / (u1 - u0), H / (v1 - v0));
DRAW = { s, tx: W / 2 - s * (u0 + u1) / 2, ty: H / 2 - s * (v0 + v1) / 2 };
applyDrawView();
}
// 휠: 마우스 아래의 도면 점이 그대로 머물게 확대/축소
function onDrawWheel(e) {
e.preventDefault();
const step = e.deltaY < 0 ? 1.1 : 1 / 1.1;
const s = Math.max(DRAW_ZOOM_MIN, Math.min(DRAW_ZOOM_MAX, DRAW.s * step));
const rect = drawPane.getBoundingClientRect();
const mx = e.clientX - rect.left, my = e.clientY - rect.top;
const k = s / DRAW.s;
DRAW = { s, tx: mx - (mx - DRAW.tx) * k, ty: my - (my - DRAW.ty) * k };
applyDrawView();
}
// 드래그: 도면 이동
function onDrawDown(e) {
drawDragging = true;
drawMoved = false;
drawLastX = e.clientX;
drawLastY = e.clientY;
e.preventDefault();
}
function onDrawMove(e) {
if (!drawDragging) return;
drawMoved = true;
DRAW.tx += e.clientX - drawLastX;
DRAW.ty += e.clientY - drawLastY;
drawLastX = e.clientX;
drawLastY = e.clientY;
applyDrawView();
}
function onDrawUp() {
drawDragging = false;
}
// 도면 클릭 영역 (부재 종류별 순서: 계단 → 벽 → 터널 → 판. 겹치면 뒤에 그린 것이 위)
const HOT_ORDER = { stair: 0, wall: 1, tunnel: 2, slab: 3, pad: 3, bottom: 3, lean: 3 };
// 영역 하나: 부재 id 들과 그 영역의 다각형들. 다각형이 없으면 영역도 없다
const region = (ids, polys) => (ids.length && polys.length ? [{ ids, polys }] : []);
// 다각형 점 배열 → SVG path 의 d (닫힌 선)
const pathD = poly => 'M' + poly.map(q => q.join(',')).join('L') + 'Z';
// 상자(obox) 의 바닥 윤곽 네 꼭짓점 (mm). s, t 를 angle_deg 만큼 돌리고 cx, cy 에 놓는다. facesObox 와 같은 회전
function obox4(p) {
const a = p.angle_deg * Math.PI / 180;
const cs = Math.cos(a), sn = Math.sin(a);
return [[p.s0, p.t0], [p.s1, p.t0], [p.s1, p.t1], [p.s0, p.t1]].map(([s, t]) => [p.cx + s * cs - t * sn, p.cy + s * sn + t * cs]);
}
// 부재가 덮는 X 범위 (mm). 계단은 ±4850, 집수조·침사조는 x_min~x_max, 사다리는 x0~x1, 월류벽·맨홀 목은 다각형의 X 범위, 상자는 꼭짓점의 X 범위
function xSpan(p) {
if (p.kind === 'stair') return [-4850, 4850];
if (p.kind === 'pit') return [p.x_min, p.x_max];
if (p.type === 'ladder') return [p.x0, p.x1];
if (p.kind === 'weir' || p.kind === 'neck') {
const xs = p.polygon.map(q => q[0]);
return [Math.min(...xs), Math.max(...xs)];
}
if (p.type === 'obox') {
const xs = obox4(p).map(q => q[0]);
return [Math.min(...xs), Math.max(...xs)];
}
return [p.cx - p.r, p.cx + p.r];
}
// 단면 그림에서 부재 하나의 영역 (도면 u = x0 + X/1000, v = y0 − z/1000, mm 입력). 다각형 배열을 돌려준다
// section_break 는 생략 구간(z_upper_min ~ z_lower_max)을 뺀다: 벽은 위·아래 조각 두 개, 나머지는 전부 위거나 전부 아래일 때만 그린다
function sectionPoly(p, view) {
const k = MODEL.drawing.section.mm_per_unit;
// 가로 u0~u1(mm), 세로 z0~z1(mm) 사각형, 기준선 y0
const rect = (y0, u0, u1, z0, z1) => [
[view.x0 + u0 / k, y0 - z0 / k], [view.x0 + u1 / k, y0 - z0 / k],
[view.x0 + u1 / k, y0 - z1 / k], [view.x0 + u0 / k, y0 - z1 / k],
];
if (p.kind === 'wall') {
// 벽은 두 띠 (안쪽 r_in ~ 바깥 r_out)
const bands = (y0, z0, z1) => [
rect(y0, p.cx - p.r_out, p.cx - p.r_in, z0, z1),
rect(y0, p.cx + p.r_in, p.cx + p.r_out, z0, z1),
];
if (view.type !== 'section_break') return bands(view.y0, p.z0, p.z1);
return [...bands(view.y0_upper, view.z_upper_min, p.z1), ...bands(view.y0_lower, p.z0, view.z_lower_max)];
}
// 계단은 윗단에서 한 단 낮은 곳(z_high − 300)까지만
const [uA, uB] = xSpan(p);
const [zA, zB] = p.kind === 'stair' ? [p.z_low, p.z_high - 300] : [p.z0, p.z1];
if (view.type !== 'section_break') return [rect(view.y0, uA, uB, zA, zB)];
if (zA >= view.z_upper_min) return [rect(view.y0_upper, uA, uB, zA, zB)];
if (zB <= view.z_lower_max) return [rect(view.y0_lower, uA, uB, zA, zB)];
return [];
}
// 평면 그림의 원 (32각형). 중심은 원점, r 은 mm
function circlePoly(view, r) {
const k = MODEL.drawing.plan.mm_per_unit;
return Array.from({ length: 32 }, (_, i) => {
const a = 2 * Math.PI * i / 32;
return [view.cx + r * Math.cos(a) / k, view.cy - r * Math.sin(a) / k];
});
}
// 평면 그림에서 부재 하나의 영역 (u = cx + x/1000, v = cy − y/1000, mm 입력). 벽은 고리(두 원, evenodd 로 가운데가 빈다), 터널은 사각형
function planPoly(p, view) {
if (p.kind === 'wall') return [circlePoly(view, p.r_out), circlePoly(view, p.r_in)];
// 축 시작점에서 angle_deg 방향으로 length, 폭 ±r_out 인 사각형
const a = p.angle_deg * Math.PI / 180;
const d = [Math.cos(a), Math.sin(a)], u = [-Math.sin(a), Math.cos(a)];
const at = (t, s) => planXY(view, p.x + d[0] * t + u[0] * s, p.y + d[1] * t + u[1] * s);
return [[at(0, -p.r_out), at(p.length, -p.r_out), at(p.length, p.r_out), at(0, p.r_out)]];
}
// 그림 한 장의 클릭 영역 목록 (영역 = { ids, polys }). 뒤에 나온 영역이 위에 온다
function viewHotspots(view) {
const ordered = MODEL.parts
.filter(p => p.kind in HOT_ORDER)
.sort((a, b) => HOT_ORDER[a.kind] - HOT_ORDER[b.kind]);
if (view.type === 'section' || view.type === 'section_break') {
return ordered.filter(p => p.kind !== 'tunnel')
.flatMap(p => region([p.id], sectionPoly(p, view)));
}
if (view.type === 'plan') {
return ordered.filter(p => p.kind === 'wall' || (p.kind === 'tunnel' && view.tunnel))
.flatMap(p => region([p.id], planPoly(p, view)));
}
if (view.type === 'slabplan') {
// 안쪽 원판: z1 상면이 같은 kind 부재 전부, 그 상면 위에 놓인 패드 (19B 패드는 19B 슬래브 상면에 놓인다)
const onTop = MODEL.parts.filter(p => (p.kind === view.kind && view.z1.some(z => Math.abs(p.z1 - z) < 1))
|| (p.kind === 'pad' && view.z1.some(z => Math.abs(p.z0 - z) < 1)));
const wall = MODEL.parts.find(p => p.kind === 'wall');
const tunnel = MODEL.parts.find(p => p.kind === 'tunnel');
return [
...region(onTop.map(p => p.id), [circlePoly(view, wall.r_in)]),
...region([wall.id], planPoly(wall, view)),
...(view.tunnel ? region([tunnel.id], planPoly(tunnel, view)) : []),
];
}
return [];
}
// 평면 그림의 점 하나 (X, Y 는 mm, 중심 기준). planPoly 와 같은 규칙
// 그림의 rot_deg 만큼 먼저 돌린다 (평면도 1·2 는 −30.3°, 없으면 0)
const planXY = (view, X, Y) => {
const a = (view.rot_deg || 0) * Math.PI / 180;
const Xr = X * Math.cos(a) - Y * Math.sin(a), Yr = X * Math.sin(a) + Y * Math.cos(a);
return [view.cx + Xr / MODEL.drawing.plan.mm_per_unit, view.cy - Yr / MODEL.drawing.plan.mm_per_unit];
};
// 하부 슬래브 평면에서 바닥부 부재 하나의 영역. 집수조·침사조는 원을 X 범위로 자른 활꼴(위 호 → 아래 호 되돌아오기)
function bottomPlanPoly(p, view) {
if (p.kind === 'weir') return [p.polygon.map(([X, Y]) => planXY(view, X, Y))];
if (p.kind === 'ladder') {
const h = p.width / 2;
return [[[p.x0, p.yc - h], [p.x1, p.yc - h], [p.x1, p.yc + h], [p.x0, p.yc + h]].map(([X, Y]) => planXY(view, X, Y))];
}
const xs = Array.from({ length: 17 }, (_, i) => p.x_min + (p.x_max - p.x_min) * i / 16);
const arc = sign => xs.map(X => planXY(view, X, sign * Math.sqrt(p.r * p.r - X * X)));
return [[...arc(1), ...arc(-1).reverse()]];
}
// 바닥부 클릭 영역 (SPEC 16.2). 단면 A-A 두 그림은 X·z 사각형, 하부 슬래브 평면(slab20)은 평면 규칙, 19B 평면(slab19)은 침사구 자리를 사다리로
// 순서: 집수조 → 침사조 → 월류벽 → 사다리 (뒤에 나온 것이 위에 온다)
function bottomHotspots(view) {
const ofKind = k => MODEL.parts.filter(p => p.kind === k);
const bottom = [...ofKind('pit'), ...ofKind('weir'), ...ofKind('ladder')];
if (view.id === 'secA' || view.id === 'secA2') return bottom.flatMap(p => region([p.id], sectionPoly(p, view)));
if (view.id === 'slab20') return bottom.flatMap(p => region([p.id], bottomPlanPoly(p, view)));
if (view.id === 'slab19') {
// 19B 슬래브 구멍 두 개 중 침사구(X 3000 너머)
const pit = MODEL.parts.find(p => p.id === 'slab_19B').holes.find(h => h[0][0] > 3000);
return region(['ladder_19B'], [pit.map(([X, Y]) => planXY(view, X, Y))]);
}
return [];
}
// 상부 부재(SPEC 17.4)의 클릭 영역. 단면 A-A 두 그림은 X·z 사각형(상부 슬래브는 원판 규칙), 평면도 1·2 는 원판(r 4850) → 맨홀 목 → 분기구·환기구 바닥 윤곽 순서
// 벽 고리·터널(viewHotspots)은 이 뒤에 와서 위에 온다
// 분기구·환기구는 같은 kind 전체를 한 덩어리로 선택한다 (어느 조각을 눌러도 같은 id 목록). 목·사다리는 하나씩
const KIND_LABEL = { branch: '분기구', vent: '환기구(강제)' };
function upperHotspots(view) {
const ofKind = k => MODEL.parts.filter(p => p.kind === k);
const group = p => (p.kind in KIND_LABEL ? ofKind(p.kind).map(q => q.id) : [p.id]);
if (view.id === 'secA' || view.id === 'secA2') {
const upper = [...ofKind('topslab'), ...ofKind('neck'), ...ofKind('branch'), ...ofKind('vent'), ...ofKind('ladder_up')];
return upper.flatMap(p => region(group(p), sectionPoly(p, view)));
}
if (view.id === 'plan1' || view.id === 'plan2') {
return [
...region(ofKind('topslab').map(p => p.id), [circlePoly(view, 4850)]),
...ofKind('neck').flatMap(p => region([p.id], [p.polygon.map(([X, Y]) => planXY(view, X, Y))])),
...[...ofKind('branch'), ...ofKind('vent')].flatMap(p => region(group(p), [obox4(p).map(([X, Y]) => planXY(view, X, Y))])),
];
}
return [];
}
// 모든 그림의 클릭 영역을 svg#hotspots 에 path 로 그린다 (fill 은 투명, 구멍은 evenodd)
// 순서: 상부 → 벽·터널 → 바닥부 (뒤에 나온 것이 위에 온다)
function buildHotspots() {
hotspots.innerHTML = MODEL.drawing.views.flatMap(view => [...upperHotspots(view), ...viewHotspots(view), ...bottomHotspots(view)].map(r =>
`<path data-ids="${r.ids.join(' ')}" data-view="${view.label}" fill-rule="evenodd" d="${r.polys.map(pathD).join('')}"/>`)).join('');
}
// 부재 여러 개 선택 (id 배열): 도면 영역 칠하기, 3D 노란색, 「선택: 그림 이름 — 부재 이름」. 빈 배열이면 해제
function selectPart(ids, viewLabel) {
SELECTED = ids;
for (const el of hotspots.querySelectorAll('path')) {
const mine = el.getAttribute('data-ids').split(' ');
el.classList.toggle('on', mine.some(id => ids.includes(id)));
}
const names = ids.map(id => MODEL.parts.find(p => p.id === id).name);
// 여럿이면 처음 둘과 마지막만 (예: 1B 슬래브, 3B 슬래브, … 17B 슬래브)
const list = names.length > 3 ? `${names[0]}, ${names[1]}, … ${names[names.length - 1]}` : names.join(', ');
// 분기구·환기구 덩어리는 kind 이름으로 (예: 「선택: 평면도 — 분기구」)
const first = ids.length ? MODEL.parts.find(p => p.id === ids[0]) : null;
const whole = first && first.kind in KIND_LABEL && ids.length === MODEL.parts.filter(p => p.kind === first.kind).length;
partLabel.textContent = names.length ? `선택: ${viewLabel} — ${whole ? KIND_LABEL[first.kind] : list}` : '';
draw();
}
// 도면 파일이 있으면 안내 글자를 지우고 도면을 처음 화면에 맞춘다 (없으면 안내 글자 그대로)
drawImg.onload = () => {
drawMsg.style.display = 'none';
drawStage.style.display = 'block';
fitDrawing();
};
// 도면 파일 경로는 모델의 drawing.src 가 정한다 (없으면 private/drawing.svg)
drawImg.onerror = () => {
drawMsg.textContent = `도면 파일 없음 — ${drawImg.getAttribute('src')} 를 넣으세요`;
};
if (MODEL.drawing) drawImg.src = MODEL.drawing.src || 'private/drawing.svg';
// 클릭: 영역 위면 그 영역의 부재들 선택, 영역 밖이면 해제 (드래그 뒤의 클릭은 무시)
hotspots.addEventListener('click', e => {
if (drawMoved) return;
const ids = e.target.getAttribute('data-ids');
selectPart(ids ? ids.split(' ') : [], e.target.getAttribute('data-view'));
});
drawPane.addEventListener('wheel', onDrawWheel, { passive: false });
drawPane.addEventListener('mousedown', onDrawDown);
window.addEventListener('mousemove', onDrawMove);
window.addEventListener('mouseup', onDrawUp);
canvas.addEventListener('mousedown', onMouseDown);
window.addEventListener('mousemove', onMouseMove);
window.addEventListener('mouseup', onMouseUp);
canvas.addEventListener('wheel', onWheel, { passive: false });
canvas.addEventListener('contextmenu', e => e.preventDefault());
canvas.addEventListener('dblclick', resetView);
document.getElementById('resetView').addEventListener('click', resetView);
splitter.addEventListener('mousedown', onSplitDown);
window.addEventListener('mousemove', onSplitMove);
window.addEventListener('mouseup', onSplitUp);
window.addEventListener('resize', resize);
// 사람 애니메이션 (SPEC 15.4): 계단을 내려갔다 올라오기를 쉬지 않고 반복한다
const WALK_SPEED = 1000; // mm/s
const WALK_COLORS = ['#ff8c1a', '#e07010']; // 첫 사람, 두 번째 사람
const TEAM_LAG = 2000; // 두 번째 사람은 첫 사람보다 2초 뒤에 같은 길을 간다 (SPEC 18.1)
// 움직인 시간(ms). 멈추면 늘지 않는다
let WALK_T = 0;
let walking = true;
let lastNow = 0, lastDraw = 0;
const walkToggle = document.getElementById('walkToggle');
// 계단 18런으로 경로 한 바퀴를 만든다: 내려가는 길 다음에 같은 길을 되짚어 올라오는 길. 구간마다 시작·끝 점과 진행 방향(h)을 가진다
function buildWalkPath() {
const runs = MODEL.parts.filter(p => p.type === 'stair').sort((a, b) => b.z_high - a.z_high);
const down = [];
for (const p of runs) {
const n = p.risers, yc = (p.y0 + p.y1) / 2;
// 입구 앞 300 지점, 디딤판마다 가운데 점 (위 단부터), 아래 슬래브에 내려선 점
down.push([p.x_top - p.dir * 300, yc, p.z_high]);
for (let k = n - 1; k >= 1; k--) {
down.push([p.x_top + p.dir * 300 * (n - 1 - k + 0.5), yc, p.z_low + p.first_riser + p.riser * (k - 1)]);
}
down.push([p.x_top + p.dir * 300 * n, yc, p.z_low]);
}
// 올라올 때는 내려간 점을 거꾸로 (양 끝 점은 한 번만)
const pts = [...down, ...down.slice(1, -1).reverse()];
const segs = [];
let total = 0;
pts.forEach((a, i) => {
const b = pts[(i + 1) % pts.length];
const len = Math.hypot(b[0] - a[0], b[1] - a[1], b[2] - a[2]);
segs.push({ a, b, start: total, len, h: Math.atan2(b[1] - a[1], b[0] - a[0]) });
total += len;
});
return { segs, total };
}
const WALK = buildWalkPath();
// 계단이 하나도 없으면 (추출 모델) 걸을 경로가 없다 → 사람 그리기와 버튼을 모두 건너뛴다
const HAS_WALK = WALK.segs.length > 0;
// 시각 t(ms) 에서 발 위치 pos [x, y, z] 와 진행 방향 h (라디안). 경로 끝에 오면 처음으로 돌아간다
function walkerAt(t) {
// 음수 시각(두 번째 사람의 처음 2초)도 경로 안으로 접는다
const s = (((t * WALK_SPEED / 1000) % WALK.total) + WALK.total) % WALK.total;
const seg = WALK.segs.find(g => s < g.start + g.len) || WALK.segs[WALK.segs.length - 1];
const f = seg.len ? (s - seg.start) / seg.len : 0;
return { pos: seg.a.map((v, i) => v + (seg.b[i] - v) * f), h: seg.h };
}
// 두 사람 (SPEC 18.1): [첫 사람, 두 번째 사람] 의 발 위치·진행 방향. 두 번째는 TEAM_LAG 뒤 시각
function teamAt(t) {
return [walkerAt(t), walkerAt(t - TEAM_LAG)];
}
// 사람 한 명 (SPEC 15.4): 다리 2·몸통·머리 상자 4개를 진행 방향(h)에 맞춰 돌린 면들. 색은 불투명 color (SPEC 18.1)
function facesPerson(pos, h, color) {
const c = Math.cos(h), s = Math.sin(h);
// 몸 기준 (a 앞쪽, b 왼쪽, z 위) → 세계 좌표
const W = (a, b, z) => [pos[0] + a * c - b * s, pos[1] + a * s + b * c, pos[2] + z];
// 상자 하나: 몸 기준 범위 a0~a1, b0~b1, z0~z1 (법선도 같이 돌린다)
const box = (a0, a1, b0, b1, z0, z1) => [
{ pts: [W(a0, b0, z1), W(a1, b0, z1), W(a1, b1, z1), W(a0, b1, z1)], n: [0, 0, 1] },
{ pts: [W(a0, b0, z0), W(a1, b0, z0), W(a1, b1, z0), W(a0, b1, z0)], n: [0, 0, -1] },
{ pts: [W(a1, b0, z0), W(a1, b1, z0), W(a1, b1, z1), W(a1, b0, z1)], n: [c, s, 0] },
{ pts: [W(a0, b0, z0), W(a0, b1, z0), W(a0, b1, z1), W(a0, b0, z1)], n: [-c, -s, 0] },
{ pts: [W(a0, b1, z0), W(a1, b1, z0), W(a1, b1, z1), W(a0, b1, z1)], n: [-s, c, 0] },
{ pts: [W(a0, b0, z0), W(a1, b0, z0), W(a1, b0, z1), W(a0, b0, z1)], n: [s, -c, 0] },
].map(f => ({ ...f, color, alpha: 1 }));
return [
...box(-75, 75, 50, 200, 0, 800), // 다리 (왼쪽, 두 다리 사이 100)
...box(-75, 75, -200, -50, 0, 800), // 다리 (오른쪽)
...box(-125, 125, -200, 200, 800, 1400), // 몸통 (앞뒤 250, 좌우 400)
...box(-110, 110, -110, 110, 1450, 1670), // 머리 220
];
}
// 움직이는 동안 requestAnimationFrame 으로 시간을 더하고, 초당 30번 이하로 다시 그린다
function animate(now) {
if (!walking) return;
WALK_T += now - lastNow;
lastNow = now;
if (now - lastDraw >= 1000 / 30) {
lastDraw = now;
draw();
}
requestAnimationFrame(animate);
}
// 움직임 켜기·끄기. 버튼 글자도 바꾼다 (움직일 때 「❚❚」, 멈췄을 때 「▶」)
function setWalking(on) {
walking = on;
walkToggle.textContent = on ? '사람 ❚❚' : '사람 ▶';
if (on) {
lastNow = performance.now();
requestAnimationFrame(animate);
}
}
walkToggle.addEventListener('click', () => setWalking(!walking));
// 도면 연결 데이터(MODEL.drawing)가 없는 모델(model_extracted.js)이면 왼쪽 칸은 안내 글자만 둔다
if (MODEL.drawing) buildHotspots();
if (!HAS_WALK) walkToggle.hidden = true;
setWalking(HAS_WALK);
resize();