Compare commits
112
Commits
7182b6cbb4
...
f51865c57b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f51865c57b | ||
|
|
c9e1cc6eaa | ||
|
|
bff1502ecf | ||
|
|
1fda705b75 | ||
|
|
c7acfccd51 | ||
|
|
7c5f129712 | ||
|
|
3bd2340609 | ||
|
|
54c855ab1a | ||
|
|
2668f17bf2 | ||
|
|
9d357de4d1 | ||
|
|
364b329c54 | ||
|
|
87b445965b | ||
|
|
a0b0929e13 | ||
|
|
5fec2743cb | ||
|
|
3d0d484524 | ||
|
|
2b26b7c095 | ||
|
|
fb7b4b021b | ||
|
|
370bd3d7cd | ||
|
|
42316104df | ||
|
|
da7b13a568 | ||
|
|
2e0bfb6fc8 | ||
|
|
3922b330d5 | ||
|
|
0c23173a1a | ||
|
|
91c4a4ad63 | ||
|
|
bc91a8166f | ||
|
|
a68e4a0366 | ||
|
|
5856cfb423 | ||
|
|
a2d0d6057f | ||
|
|
b75f3d4855 | ||
|
|
a282618d90 | ||
|
|
e9d79957c8 | ||
|
|
9ae6cf0945 | ||
|
|
fa7d0032c3 | ||
|
|
bb2af4bdf5 | ||
|
|
7b9de649e5 | ||
|
|
89ebf852a9 | ||
|
|
81fe1f1549 | ||
|
|
e3c9ad0953 | ||
|
|
bac087ce63 | ||
|
|
d15b237100 | ||
|
|
e6869d17be | ||
|
|
06220fa312 | ||
|
|
3800a75b78 | ||
|
|
5e64a4f2ed | ||
|
|
51a36669a8 | ||
|
|
9f152fef90 | ||
|
|
914e3dbda8 | ||
|
|
ba71911edc | ||
|
|
25a4a84138 | ||
|
|
74de932eb9 | ||
|
|
34959f5b4e | ||
|
|
a4c7bcabcf | ||
|
|
a17c9ebbb4 | ||
|
|
079748e5e7 | ||
|
|
66e7d4d90d | ||
|
|
1da8dd74e2 | ||
|
|
8361487b6b | ||
|
|
d7714f7ef2 | ||
|
|
f4311d7f95 | ||
|
|
603066f370 | ||
|
|
b06bf368b6 | ||
|
|
e00e43ca61 | ||
|
|
59c2607418 | ||
|
|
9835fb42b3 | ||
|
|
4b8b80ae97 | ||
|
|
fc0370883f | ||
|
|
41b3165543 | ||
|
|
886347471e | ||
|
|
4ef3650454 | ||
|
|
80e5302192 | ||
|
|
a025a61209 | ||
|
|
4eb92d381d | ||
|
|
372d759eb7 | ||
|
|
55b679932e | ||
|
|
796a267205 | ||
|
|
6b969f4a7c | ||
|
|
4b02dde153 | ||
|
|
4e3950eb67 | ||
|
|
7e3ba306f3 | ||
|
|
5bdd360235 | ||
|
|
e1f85e5d86 | ||
|
|
842d372312 | ||
|
|
a14497a771 | ||
|
|
08b1138c96 | ||
|
|
a756fd11ce | ||
|
|
d3440e9158 | ||
|
|
686d87bad9 | ||
|
|
804bd5c8f8 | ||
|
|
d2d679a8e6 | ||
|
|
aaa9f57908 | ||
|
|
61ef001034 | ||
|
|
4b51ef186f | ||
|
|
786c4a73dd | ||
|
|
e5134cdb68 | ||
|
|
ea36df58cc | ||
|
|
3aa7cc06b6 | ||
|
|
7fab9b6685 | ||
|
|
b425a6010b | ||
|
|
43edcdc3c5 | ||
|
|
986418024a | ||
|
|
67bebe5ec2 | ||
|
|
fbc315fdba | ||
|
|
8f75c6dc9f | ||
|
|
5c2b23211e | ||
|
|
789f62adf0 | ||
|
|
18524460e8 | ||
|
|
929f8eb4a2 | ||
|
|
5af88a511e | ||
|
|
1d31d2f3a9 | ||
|
|
b1b2ad1ddb | ||
|
|
6cbda0e126 | ||
|
|
36b26209df |
No files matched your search
@@ -12,5 +12,6 @@ __pycache__/
|
||||
*.zip
|
||||
pierzainer_ground/data/cache/
|
||||
pierzainer_ground/data/output/**/cache/
|
||||
backup/
|
||||
verify/
|
||||
도면추출/output/
|
||||
@@ -1,6 +1,14 @@
|
||||
# PIERZAINER · PIER & ABUT
|
||||
|
||||
교각 Excel을 읽어 형상을 생성하고 원도면 기반 지반면·위성사진·2D 지도를 함께 표시하는 프로젝트입니다.
|
||||
교각·교대 Excel을 읽어 형상과 철근배근을 생성하고 원도면 기반 지반면·위성사진·2D 지도를 함께 표시하는 프로젝트입니다.
|
||||
|
||||
## 현재 작업 상태 (2026-11-05 확인)
|
||||
|
||||
- 현재 로컬 작업 브랜치는 `master`이며 제품 코드 기준 커밋은 `91c4a4a`입니다. 아래 11월 2일 복구 기록은 당시 이력입니다.
|
||||
- 교대 ② 형상계획 카드 6개와 ③ 철근배근 6개 부재(받침블럭·벽체·흉벽/브라켓·확대기초·날개벽·접속슬래브)가 구현되어 있습니다.
|
||||
- A1 표본으로 ③ 입력 재계산·삽도/3D 표시·철근 선택·파일 다운로드·새 페이지 저장 복원을 검증했습니다. 설계 미결 사항과 다른 입력의 동작은 별도 확인 대상입니다.
|
||||
- 최신 진행 기록은 `../../context-migration/컨텍스트_2026-11-03_교각_교대_진행상황.md`, 남은 항목은 `../../context-migration/OPEN_미해결목록.md`에서 관리합니다.
|
||||
- 로컬 `origin/master` 추적 정보 대비 89커밋 앞선 상태였습니다. 실제 원격은 조회하지 않았습니다. 브랜치 전환이나 이력 변경 전에 현재 상태를 확인하세요.
|
||||
|
||||
## 실행
|
||||
|
||||
@@ -23,10 +31,33 @@ npm run dev -- --host 127.0.0.1 --port 5173
|
||||
- `랜딩페이지/`: 랜딩 화면과 지도 포함 통합 샘플 12개
|
||||
- `pierzainer_ground/`: 지반 생성 프로그램, 원본 자료, 최종 지반 JSON 및 재현에 필요한 데이터
|
||||
- `도면추출/`: 도면 추출 프로그램과 교각 샘플 Excel (기본 P1 샘플 포함)
|
||||
- `tests/`: 현재 교각·Excel·지도 기능의 자동 검사
|
||||
- `tests/`, `scripts/`, `verify/`: 현재 교각·Excel·지도 기능의 자동 검사와 유지보수 도구
|
||||
|
||||
`node_modules/`, `dist/`, `.verify/`, 지반 `cache/`는 실행·검증 중 재생성되는 파일이며 Git에 추가하지 않습니다.
|
||||
|
||||
## 검증
|
||||
|
||||
```bash
|
||||
npm test -- --maxWorkers=2
|
||||
npm run build
|
||||
```
|
||||
|
||||
A1 화면 검증 스크립트는 `scripts/verify-abut-workflow.cjs`입니다. 개발 서버와 별도 프로필 Chrome의 로컬 CDP 포트(기본 9444)가 필요합니다.
|
||||
|
||||
현장타설 실배근의 현행 UI 검증은 `node scripts/verify-cast-rebar.cjs`를 사용합니다. Windows Chrome 사용 시 Windows Node로 실행합니다. 샘플 Excel에서 9개 교각·기초 조합을 생성하고 실제 주철근/횡철근/커플러 행·삽도·입력 재생성·불일치 경고·재료표·파일 저장/새 페이지 복원·헤더 3개 폭을 검사합니다. 결과는 `.verify/cast-rebar-20261106/`에 저장합니다. `verify-unified-input.cjs`는 실배근 연결 전 통합 입력 단계의 검증이고, `verify-cast-input.cjs`와 `verify-cast-detail.cjs`는 통합 전 분리 카드 선택자를 사용한 과거 검증 스크립트입니다.
|
||||
|
||||
```text
|
||||
node scripts/verify-abut-workflow.cjs <input.xlsx> <output-directory> <download-directory>
|
||||
```
|
||||
|
||||
Windows Chrome을 쓰면 Windows Node로 실행하고 입력·출력 경로도 Windows 경로 또는 WSL UNC 경로로 전달합니다. 결과 JSON·화면 캡처·SVG·프로젝트 표본은 `.verify/abut-workflow-20261105/`에 보관했습니다. `report.json`의 실행 결과와 범위를 확인하세요. 단순 재열기 검사는 같은 인자 뒤에 `--resume`을 붙일 수 있습니다.
|
||||
|
||||
## 2026-11-02 혼합 파일 복구 (당시 기록)
|
||||
|
||||
혼합 전 Pierzainer 커밋 `d6735a6`에서 로컬 브랜치 `recovery/pierzainer-20261102`를 만들었습니다. π형 Excel 인식, 전·후열 받침 수 불일치 허용, 랜딩페이지 샘플 연결을 포함합니다. 기존 ITAM 브랜치와 원격 기록은 변경하지 않았습니다.
|
||||
|
||||
당시 복구 기록에는 분리 파일을 상위 `_recovery/20261102_mixed_projects/`에 보관하고, 당시 `master`가 ITAM 코드였다고 적혀 있습니다. 2026-11-05 현재 로컬 `master`는 Pierzainer 코드(`91c4a4a`)이므로 과거 안내에 따라 복구 브랜치로 전환하지 마세요. 당시 복구 보관 경로의 현존 여부는 이번 작업에서 재확인하지 않았습니다. 이번 검증·기록 정리에서도 원격 push와 브랜치 전환은 수행하지 않았습니다.
|
||||
|
||||
## 정리 기준
|
||||
|
||||
실행에 필요한 코드·원본 도면·샘플 Excel·최종 지반 JSON은 유지합니다. 과거 개발용 도구와 옛 좌표 기준의 검증 파일은 정리했으며 백업 목록은 상위 `_recovery/20261103_cleanup/`에 있습니다.
|
||||
+80
-26
@@ -11,22 +11,26 @@
|
||||
<header class="top">
|
||||
<div class="brand">
|
||||
<strong>Pierzainer</strong>
|
||||
<span>교각 설계</span>
|
||||
<span id="project-line" class="proj">프로젝트 미지정</span>
|
||||
</div>
|
||||
<nav class="steps" aria-label="단계">
|
||||
<button id="nav-shape" class="step current" type="button" aria-current="step">① 형상 생성 <em id="step1-status">미생성</em></button>
|
||||
<button id="nav-plan" class="step" type="button">② 형상계획 <em id="step2-status">예정</em></button>
|
||||
<button id="nav-rebar" class="step" type="button">③ 철근배근 <em id="step3-status">예정</em></button>
|
||||
<button id="nav-drawing" class="step" type="button">④ 도면작성 <em id="step-drawing-status">형상 필요</em></button>
|
||||
<button id="nav-quantity" class="step" type="button">⑤ 재료표 <em id="step4-status">예정</em></button>
|
||||
<button id="nav-shape" class="step current" data-label="① 형상 생성" aria-label="① 형상 생성" type="button" aria-current="step">① 형상 <em id="step1-status"></em></button>
|
||||
<button id="nav-plan" class="step" data-label="② 형상계획" aria-label="② 형상계획" type="button">② 계획 <em id="step2-status"></em></button>
|
||||
<button id="nav-rebar" class="step" data-label="③ 철근배근" aria-label="③ 철근배근" type="button">③ 배근 <em id="step3-status"></em></button>
|
||||
<button id="nav-drawing" class="step" data-label="④ 도면작성" aria-label="④ 도면작성" type="button">④ 도면 <em id="step-drawing-status"></em></button>
|
||||
</nav>
|
||||
<div class="actions">
|
||||
<span id="save-status" class="savest">저장 전</span>
|
||||
<button id="home-from-app" class="btn-s" type="button" hidden>홈으로</button>
|
||||
<button id="home-from-app" class="btn-s" type="button" hidden>홈</button>
|
||||
<button id="save-project" class="btn-s" type="button" disabled title="프로젝트 파일 저장 (Ctrl+S)">저장</button>
|
||||
<button id="open-project" class="btn-s" type="button">프로젝트 열기</button>
|
||||
<button id="regen" class="btn-p" type="button" disabled>선택 교각 형상 생성</button>
|
||||
<details id="file-menu" class="file-menu">
|
||||
<summary class="btn-s">불러오기</summary>
|
||||
<div class="file-menu-items">
|
||||
<button id="open-import" class="btn small" type="button">엑셀 불러오기</button>
|
||||
<button id="open-project" class="btn small" type="button">프로젝트 열기</button>
|
||||
</div>
|
||||
</details>
|
||||
<button id="regen" class="btn-p" type="button" disabled>생성</button>
|
||||
<input id="project-file" type="file" accept=".json" hidden />
|
||||
</div>
|
||||
</header>
|
||||
@@ -43,7 +47,7 @@
|
||||
|
||||
<main class="grid" id="view-shape">
|
||||
<aside class="panel piers" aria-labelledby="pier-title">
|
||||
<p id="pier-title" class="lbl">교각 목록</p>
|
||||
<p id="pier-title" class="lbl">교각·교대 목록</p>
|
||||
<div class="pier-tools">
|
||||
<label>형식
|
||||
<select id="type-filter"></select>
|
||||
@@ -54,7 +58,7 @@
|
||||
<p id="pier-empty" class="muted">불러온 교각이 없습니다.</p>
|
||||
<p class="pnote">시트 1장 = 교각 1기입니다. 오류가 난 교각만 건너뛰고 나머지는 생성합니다.</p>
|
||||
|
||||
<section class="pier-input" aria-labelledby="input-title">
|
||||
<section id="pier-info" class="pier-input" aria-labelledby="input-title">
|
||||
<p id="input-title" class="lbl">입력값</p>
|
||||
<div class="pier-table-wrap">
|
||||
<table id="inputs-all" class="grid-table compact">
|
||||
@@ -71,6 +75,27 @@
|
||||
<button id="edit-regen" class="btn primary" type="button" disabled>다시 생성 · 변경 0</button>
|
||||
</section>
|
||||
</section>
|
||||
<section id="abut-panel" class="pier-input" hidden aria-labelledby="abut-panel-title">
|
||||
<p id="abut-panel-title" class="lbl"><span id="abut-panel-name"></span> 입력값 <span class="badge">입력</span>
|
||||
<button id="abut-panel-edit-toggle" class="btn small" type="button" aria-expanded="false">편집</button></p>
|
||||
<dl id="abut-panel-input" class="kv wide"></dl>
|
||||
<div id="abut-panel-editor" hidden>
|
||||
<div id="abut-panel-basic" class="edit-row"></div>
|
||||
<details class="abut-group">
|
||||
<summary>받침 값</summary>
|
||||
<div id="abut-panel-bearings" class="edit-row"></div>
|
||||
</details>
|
||||
<details class="abut-group">
|
||||
<summary>형상 (벽체·흉벽, 저판, 말뚝)</summary>
|
||||
<div id="abut-panel-shape" class="edit-row"></div>
|
||||
</details>
|
||||
<p id="abut-panel-msg" class="muted" aria-live="polite"></p>
|
||||
</div>
|
||||
<details class="abut-group">
|
||||
<summary>상세 (계산값)</summary>
|
||||
<dl id="abut-panel-summary" class="kv wide"></dl>
|
||||
</details>
|
||||
</section>
|
||||
</aside>
|
||||
|
||||
<section class="panel viewer" id="viewer-block" aria-labelledby="viewer-title">
|
||||
@@ -159,15 +184,30 @@
|
||||
|
||||
<main class="grid plan" id="view-plan" hidden>
|
||||
<aside class="panel piers" aria-labelledby="plan-pier-title">
|
||||
<p id="plan-pier-title" class="lbl">교각 목록</p>
|
||||
<p id="plan-pier-title" class="lbl">교각·교대 목록</p>
|
||||
<div class="phead" aria-hidden="true"><span>교각</span><span>변경</span></div>
|
||||
<ul id="plan-pier-items" class="pier-items"></ul>
|
||||
<p class="lbl abut-members-title">부재 · 교대</p>
|
||||
<ul id="abut-members" class="members abut-members" aria-label="교대 부재"></ul>
|
||||
<p class="lbl members-title">부재 · 5개</p>
|
||||
<ul id="members" class="members"></ul>
|
||||
</aside>
|
||||
|
||||
<div class="center-col">
|
||||
<div class="viewer-slot" id="slot-plan"></div>
|
||||
<!-- ② 교대 부재 카드 (시안, 2026-11-03): the card of the row picked in 부재 · 교대, under the 3D view. -->
|
||||
<section id="abut-panel-plan" class="panel viewer abut-card" hidden aria-labelledby="abut-panel-plan-title">
|
||||
<div class="viewer-head">
|
||||
<h2 id="abut-panel-plan-title"><span id="abut-panel-plan-member">교량받침 · 받침블럭 · 솔플레이트</span> <span class="muted small" id="abut-panel-plan-name"></span></h2>
|
||||
</div>
|
||||
<div id="abut-panel-plan-bearings" class="edit-row"></div>
|
||||
<div id="abut-panel-plan-wing" class="edit-row"></div>
|
||||
<div id="abut-panel-plan-slab" class="edit-row"></div>
|
||||
<div id="abut-panel-plan-ground" class="edit-row"></div>
|
||||
<div id="abut-panel-plan-shape" class="edit-row abut-shape-view"></div>
|
||||
<div id="abut-panel-plan-footing" class="edit-row abut-shape-view"></div>
|
||||
<p id="abut-panel-plan-msg" class="muted" aria-live="polite"></p>
|
||||
</section>
|
||||
<section class="panel viewer" aria-labelledby="member-title">
|
||||
<div class="viewer-head">
|
||||
<h2 id="member-title">코핑</h2>
|
||||
@@ -238,6 +278,7 @@
|
||||
</aside>
|
||||
<section class="panel viewer" aria-labelledby="quantity-title">
|
||||
<div class="viewer-head">
|
||||
<button id="quantity-back" class="btn-o" type="button">← ③ 철근배근</button>
|
||||
<h2 id="quantity-title">철근재료표</h2>
|
||||
<button id="quantity-export" class="btn-o" type="button">엑셀로 내보내기</button>
|
||||
<button id="quantity-print" class="btn-o" type="button">인쇄</button>
|
||||
@@ -254,7 +295,16 @@
|
||||
<ul id="rebar-tabs" class="members"></ul>
|
||||
<p class="pnote">형상은 ② 형상계획 기준입니다. 형상이 바뀌면 "형상 바뀜"으로 표시합니다.</p>
|
||||
</aside>
|
||||
<div class="center-col">
|
||||
<div class="center-col review-workspace" id="review-workspace">
|
||||
<div class="workspace-tools" aria-label="검토 공간 조절">
|
||||
<label class="model-height-control" for="review-model-height">모델 높이
|
||||
<input id="review-model-height" type="range" min="180" max="560" step="20" value="240" />
|
||||
<output id="review-model-height-value" for="review-model-height">240 px</output>
|
||||
</label>
|
||||
<button id="review-model-toggle" class="btn small" type="button" aria-expanded="true" aria-controls="slot-rebar">모델 접기</button>
|
||||
<button id="review-inputs" class="btn small" type="button">입력 보기</button>
|
||||
<button id="review-table" class="btn small" type="button">철근표 보기</button>
|
||||
</div>
|
||||
<div class="viewer-slot" id="slot-rebar"></div>
|
||||
<section class="panel viewer" aria-labelledby="rebar-title">
|
||||
<div class="viewer-head"><h2 id="rebar-title">철근표</h2></div>
|
||||
@@ -265,24 +315,26 @@
|
||||
<aside class="panel results" aria-labelledby="rebar-issues-title">
|
||||
<h2 id="rebar-issues-title">알림</h2>
|
||||
<ul id="rebar-issues" class="issues"></ul>
|
||||
<h2>검토 필요 (결정 대기)</h2>
|
||||
<ul class="issues">
|
||||
<li>Q-B5 B1·B3·B5·B7 다리 길이 (제안값 사용)</li>
|
||||
<li>Q-B6 B2·B6 이음 위치와 길이</li>
|
||||
<li>Q-C9 P4–B11 간섭: 무시로 결정, 문서 반영 필요</li>
|
||||
</ul>
|
||||
<h2>부재 검토</h2>
|
||||
<p id="review-summary" class="muted small" aria-live="polite"></p>
|
||||
<ul id="review-tasks" class="issues review-tasks"></ul>
|
||||
<section class="common" aria-labelledby="common-title">
|
||||
<p id="common-title" class="lbl">공통 설정</p>
|
||||
<h3 id="common-title">공통 설정</h3>
|
||||
<fieldset class="material-fields">
|
||||
<legend>재료</legend>
|
||||
<label class="mat">fck (MPa) <input id="mat-fck" type="number" step="any" /></label>
|
||||
<label class="mat">D 규격 강도 <select id="mat-grade-d"><option value="300" selected>SD300</option><option value="400">SD400</option><option value="500">SD500</option></select></label>
|
||||
<label class="mat">H 규격 강도 <select id="mat-grade-h"><option value="300">SD300</option><option value="400" selected>SD400</option><option value="500">SD500</option></select></label>
|
||||
<label class="mat">S 규격 강도 <select id="mat-grade-s"><option value="300">SD300</option><option value="400">SD400</option><option value="500" selected>SD500</option></select></label>
|
||||
<label class="mat">재료표 할증 (%) · 기본 0 가정 <input id="mat-waste" type="number" step="0.5" min="0" /></label>
|
||||
<p class="lbl">굽힘 내면반경 (× 철근 지름)</p>
|
||||
</fieldset>
|
||||
<fieldset class="material-fields">
|
||||
<legend>굽힘 내면반경 (× 철근 지름)</legend>
|
||||
<label class="mat">D16 이하 <input id="bend-small16" type="number" step="0.5" min="0.5" /></label>
|
||||
<label class="mat">D19–D25 <input id="bend-main25" type="number" step="0.5" min="0.5" /></label>
|
||||
<label class="mat">D29–D35 <input id="bend-main35" type="number" step="0.5" min="0.5" /></label>
|
||||
<label class="mat">D38 이상 <input id="bend-main51" type="number" step="0.5" min="0.5" /></label>
|
||||
</fieldset>
|
||||
<p id="mat-note" class="muted small"></p>
|
||||
</section>
|
||||
</aside>
|
||||
@@ -355,20 +407,22 @@
|
||||
<div class="dlg-col">
|
||||
<section>
|
||||
<h3>미리보기</h3>
|
||||
<p id="preview-title" class="muted"></p>
|
||||
<p id="preview-title" class="muted" aria-live="polite"></p>
|
||||
<h4>교각 정보</h4>
|
||||
<div class="pv-scroll"><table id="pv-pier" class="grid-table"></table></div>
|
||||
<h4>교대 정보</h4>
|
||||
<div class="pv-scroll"><table id="pv-abuts" class="grid-table"></table></div>
|
||||
<h4>받침</h4>
|
||||
<div class="pv-scroll">
|
||||
<table id="pv-bearings" class="grid-table">
|
||||
<thead><tr><th>교각</th><th>번호</th><th>x (m)</th><th>y (m)</th><th>z (m)</th><th>방위각</th><th>위치</th></tr></thead>
|
||||
<thead><tr><th>구조물</th><th>번호</th><th>x (m)</th><th>y (m)</th><th>z (m)</th><th>방위각</th><th>위치</th></tr></thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<h4>IP</h4>
|
||||
<div class="pv-scroll">
|
||||
<table id="pv-ips" class="grid-table">
|
||||
<thead><tr><th>교각</th><th>점</th><th>x (m)</th><th>y (m)</th><th>꺾임각</th></tr></thead>
|
||||
<thead><tr><th>구조물</th><th>점</th><th>x (m)</th><th>y (m)</th><th>꺾임각</th></tr></thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
</div>
|
||||
@@ -377,9 +431,9 @@
|
||||
</div>
|
||||
|
||||
<footer class="dlg-foot">
|
||||
<span id="dlg-selected" class="muted"></span>
|
||||
<span id="dlg-selected" class="muted" aria-live="polite"></span>
|
||||
<button id="dlg-cancel" class="btn" type="button">취소</button>
|
||||
<button id="dlg-apply" class="btn primary" type="button" disabled>선택한 교각 0기 형상 생성</button>
|
||||
<button id="dlg-apply" class="btn primary" type="button" disabled>불러올 구조물을 선택하세요</button>
|
||||
</footer>
|
||||
</dialog>
|
||||
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
// Read-only diagnosis: concrete scene vs rebar paths for every pier/foundation combination.
|
||||
import { createServer } from 'vite';
|
||||
import { mkdir, writeFile } from 'node:fs/promises';
|
||||
const server = await createServer({ configFile: false, server: { middlewareMode: true, hmr: false, watch: null }, appType: 'custom' });
|
||||
try {
|
||||
const { loadP1 } = await server.ssrLoadModule('/tests/helpers.ts');
|
||||
const { buildPier } = await server.ssrLoadModule('/src/core/geometry.ts');
|
||||
const { buildPierScene } = await server.ssrLoadModule('/src/view/scene.ts');
|
||||
const { buildRebar3D } = await server.ssrLoadModule('/src/view/rebar3d.ts');
|
||||
const { rebarSummary } = await server.ssrLoadModule('/src/core/rebar/summary.ts');
|
||||
const result = [];
|
||||
for (const [type, columnCount] of [['T형', 1], ['2주형', 2], ['3주형', 3]]) {
|
||||
for (const foundationType of ['직접기초', '말뚝기초', '현장타설말뚝']) {
|
||||
const model = buildPier({ ...loadP1(), type, columnCount, foundationType });
|
||||
const record = { type, foundationType, columns: model.columns.length, footings: model.footings.length, piles: model.piles.length };
|
||||
for (const [name, run] of Object.entries({ concrete: () => buildPierScene(model), rebar3d: () => buildRebar3D(model), summary: () => rebarSummary(model) })) {
|
||||
try { run(); record[name] = { ok: true }; }
|
||||
catch (error) { record[name] = { ok: false, message: error.message, stack: error.stack.split('\n').slice(0, 5) }; }
|
||||
}
|
||||
result.push(record);
|
||||
}
|
||||
}
|
||||
await mkdir('.verify/cast-in-place-20261105', { recursive: true });
|
||||
await writeFile('.verify/cast-in-place-20261105/diagnosis.json', JSON.stringify(result, null, 2));
|
||||
console.table(result.map(({ type, foundationType, concrete, rebar3d, summary }) => ({ type, foundationType, concrete: concrete.ok, rebar3d: rebar3d.ok, summary: summary.ok })));
|
||||
} finally { await server.close(); }
|
||||
@@ -0,0 +1,22 @@
|
||||
import { createServer } from 'vite';
|
||||
import { readFileSync } from 'node:fs';
|
||||
const server = await createServer({ server: { middlewareMode: true, hmr: false } });
|
||||
try {
|
||||
const { buildPier } = await server.ssrLoadModule('/src/core/geometry.ts');
|
||||
const { generateDrawing, DEFAULT_SETTINGS } = await server.ssrLoadModule('/도면작성/drawing.ts');
|
||||
const {presentationEntities} = await server.ssrLoadModule('/도면작성/presentation.ts');
|
||||
const {entityBox,viewCollisions,occupiedBounds} = await server.ssrLoadModule('/도면작성/occupancy.ts');
|
||||
const {bearingDetail} = await server.ssrLoadModule('/도면작성/bearing-detail.ts');
|
||||
const sample = JSON.parse(readFileSync('랜딩페이지/assets/samples/'+(process.argv[2]||'sample01.pzr.json'), 'utf8'));
|
||||
const input = sample.piers[0].input;
|
||||
const doc = generateDrawing(buildPier(input), input, sample.project, DEFAULT_SETTINGS);
|
||||
console.log(JSON.stringify(doc.quality.issues.map(q => ({q, a:doc.views.find(v=>v.id===q.view)?.entities[q.first], b:doc.views.find(v=>v.id===q.view)?.entities[q.second]})), null, 2));
|
||||
console.log(JSON.stringify(doc.views.map(v=>({id:v.id,scale:v.scale,slot:v.slot}))));
|
||||
for(const q of doc.quality.issues.filter(q=>q.kind==='text-text'))console.log(JSON.stringify(presentationEntities(doc.views.find(v=>v.id===q.view)).filter(p=>p.entity.kind==='text'&&[q.first,q.second].includes(p.index)).map(p=>({entity:p.entity,box:entityBox(p.entity)}))));
|
||||
for(const scale of [50,75,100,125,150,200,250,300]){
|
||||
const {entities}=bearingDetail(buildPier(input),scale);
|
||||
for(const e of entities){if(e.kind==='dimension')e.height=3*scale;if(e.kind==='text')e.height=Math.max(e.height,2.5*scale);}
|
||||
const v={id:'detail',entities,scale},b=occupiedBounds(v);
|
||||
console.log(JSON.stringify({scale,width:(b.maxX-b.minX)/scale,issues:viewCollisions(v).map(q=>({a:entities[q.first],b:entities[q.second]}))}));
|
||||
}
|
||||
} finally { await server.close(); }
|
||||
@@ -0,0 +1,52 @@
|
||||
// Read the user's linked artifact in an isolated browser profile, without changing its content.
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const out = path.resolve(__dirname, '../.verify/design-system-artifact-20261105');
|
||||
fs.mkdirSync(out, { recursive: true });
|
||||
async function inspect(target, name, screenshot) {
|
||||
const ws = new WebSocket(target.webSocketDebuggerUrl);
|
||||
await new Promise((resolve, reject) => { ws.onopen = resolve; ws.onerror = reject; });
|
||||
let seq = 0;
|
||||
const pending = new Map();
|
||||
ws.onmessage = ({ data }) => {
|
||||
const message = JSON.parse(data);
|
||||
const wait = pending.get(message.id);
|
||||
if (!wait) return;
|
||||
clearTimeout(wait.timer); pending.delete(message.id);
|
||||
if (message.error) wait.reject(new Error(JSON.stringify(message.error))); else wait.resolve(message.result);
|
||||
};
|
||||
const send = (method, params = {}) => new Promise((resolve, reject) => {
|
||||
const id = ++seq;
|
||||
const timer = setTimeout(() => { pending.delete(id); reject(new Error(`Timeout ${method}`)); }, 15000);
|
||||
pending.set(id, { resolve, reject, timer });
|
||||
ws.send(JSON.stringify({ id, method, params }));
|
||||
});
|
||||
try {
|
||||
await send('Runtime.enable');
|
||||
if (screenshot) {
|
||||
await send('Page.enable');
|
||||
await send('Emulation.setDeviceMetricsOverride', { width: 1600, height: 1000, deviceScaleFactor: 1, mobile: false });
|
||||
await new Promise((resolve) => setTimeout(resolve, 6000));
|
||||
}
|
||||
const text = await send('Runtime.evaluate', { expression: 'document.body.innerText', returnByValue: true });
|
||||
const html = await send('Runtime.evaluate', { expression: 'document.documentElement.outerHTML', returnByValue: true });
|
||||
const frames = await send('Runtime.evaluate', { expression: '[...document.querySelectorAll("iframe")].map(f=>({src:f.src,title:f.title,srcdocLength:f.srcdoc.length}))', returnByValue: true });
|
||||
fs.writeFileSync(path.join(out, `${name}.txt`), text.result?.value || '');
|
||||
fs.writeFileSync(path.join(out, `${name}.html`), html.result?.value || '');
|
||||
fs.writeFileSync(path.join(out, `${name}-frames.json`), JSON.stringify(frames.result?.value, null, 2));
|
||||
console.log(JSON.stringify({ name, title: target.title, url: target.url, text: (text.result?.value || '').slice(0, 10000), frames: frames.result?.value }));
|
||||
if (screenshot) {
|
||||
const shot = await send('Page.captureScreenshot', { format: 'png' });
|
||||
fs.writeFileSync(path.join(out, 'overview.png'), Buffer.from(shot.data, 'base64'));
|
||||
}
|
||||
} finally { ws.close(); }
|
||||
}
|
||||
(async () => {
|
||||
let targets = await (await fetch('http://127.0.0.1:9444/json')).json();
|
||||
const page = targets.find((target) => target.type === 'page' && target.url.includes('9LPwY9MMugHfvF5auhpVW2'));
|
||||
if (!page) throw new Error('Linked artifact page not found');
|
||||
await inspect(page, 'page', true);
|
||||
targets = await (await fetch('http://127.0.0.1:9444/json')).json();
|
||||
let index = 0;
|
||||
for (const target of targets.filter((target) => target.type === 'iframe')) await inspect(target, `frame-${++index}`, false);
|
||||
})().catch((error) => { console.error(error); process.exitCode = 1; });
|
||||
@@ -0,0 +1,21 @@
|
||||
// Read focused-control and native-menu behaviour in the last verification tab.
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
(async () => {
|
||||
const pages = await (await fetch('http://127.0.0.1:9444/json/list')).json();
|
||||
const page = pages.find(p=>p.url==='http://127.0.0.1:5173/');
|
||||
const ws = new WebSocket(page.webSocketDebuggerUrl);
|
||||
await new Promise(r=>ws.onopen=r);
|
||||
let seq=0; const pending=new Map();
|
||||
ws.onmessage=({data})=>{const m=JSON.parse(data);if(pending.has(m.id)){pending.get(m.id)(m.result);pending.delete(m.id);}};
|
||||
const send=(method,params={})=>new Promise(r=>{const id=++seq;pending.set(id,r);ws.send(JSON.stringify({id,method,params}));});
|
||||
const ev=async expression=>(await send('Runtime.evaluate',{expression,returnByValue:true})).result.value;
|
||||
const inspect=`({open:document.getElementById('file-menu').open,active:document.activeElement.outerHTML,summaryTab:document.querySelector('#file-menu summary').tabIndex})`;
|
||||
const result={before:await ev(inspect)};
|
||||
await ev(`document.querySelector('#file-menu summary').focus()`);
|
||||
await send('Input.dispatchKeyEvent',{type:'keyDown',key:'Enter',code:'Enter',text:'\r',windowsVirtualKeyCode:13});
|
||||
await send('Input.dispatchKeyEvent',{type:'keyUp',key:'Enter',code:'Enter',windowsVirtualKeyCode:13});
|
||||
result.after=await ev(inspect);
|
||||
fs.writeFileSync(path.resolve(__dirname,'../.verify/unified-input-20261106/keyboard-diagnosis.json'),JSON.stringify(result,null,2));
|
||||
console.log(result);ws.close();
|
||||
})().catch(e=>{console.error(e);process.exitCode=1;});
|
||||
@@ -0,0 +1,49 @@
|
||||
// Read-only design sample capture, fresh isolated CDP target. Run via Windows Node.
|
||||
const fs=require('node:fs'), path=require('node:path');
|
||||
const root=path.resolve(__dirname,'..'),out=path.join(root,'.verify/impeccable-20261105/a-after');
|
||||
fs.mkdirSync(out,{recursive:true});
|
||||
// Preserve the first animation-timed sample before the one allowed corrective batch.
|
||||
if(fs.existsSync(path.join(out,'raw.json'))){const archive=path.join(out,'initial');fs.mkdirSync(archive,{recursive:true});for(const f of fs.readdirSync(out))if(/\.(png|json)$/.test(f))fs.copyFileSync(path.join(out,f),path.join(archive,f));}
|
||||
const report={started:new Date().toISOString(),views:[],errors:[]};
|
||||
const delay=ms=>new Promise(r=>setTimeout(r,ms));let ws;
|
||||
(async()=>{
|
||||
const target=await(await fetch('http://127.0.0.1:9444/json/new?'+encodeURIComponent('http://127.0.0.1:5173/'),{method:'PUT'})).json();report.target=target.id;
|
||||
ws=new WebSocket(target.webSocketDebuggerUrl);await new Promise((r,j)=>{ws.onopen=r;ws.onerror=j});
|
||||
let id=0;const pending=new Map();ws.onmessage=({data})=>{const m=JSON.parse(data);if(m.id){const p=pending.get(m.id);if(p){pending.delete(m.id);clearTimeout(p.t);m.error?p.j(Error(JSON.stringify(m.error))):p.r(m.result)}}else if(m.method==='Runtime.exceptionThrown')report.errors.push(m.params)};
|
||||
const send=(method,params={})=>new Promise((r,j)=>{const n=++id,t=setTimeout(()=>j(Error('Timeout '+method)),20000);pending.set(n,{r,j,t});ws.send(JSON.stringify({id:n,method,params}))});
|
||||
const ev=async expression=>{const r=await send('Runtime.evaluate',{expression,awaitPromise:true,returnByValue:true});if(r.exceptionDetails)throw Error(JSON.stringify(r.exceptionDetails));return r.result.value};
|
||||
const until=async(expression)=>{for(let n=0;n<100;n++){if(await ev(expression))return;await delay(200)}throw Error('Wait failed '+expression)};
|
||||
const click=selector=>ev(`(()=>{const e=document.querySelector(${JSON.stringify(selector)});if(!e)throw Error('Missing ${selector}');e.click()})()`);
|
||||
const cap=async name=>{await delay(600);fs.writeFileSync(path.join(out,name+'.png'),Buffer.from((await send('Page.captureScreenshot',{format:'png'})).data,'base64'));const facts=await ev(`({text:document.body.innerText,viewport:{width:innerWidth,height:innerHeight,scrollWidth:document.documentElement.scrollWidth,scrollHeight:document.documentElement.scrollHeight},controls:[...document.querySelectorAll('button,input,select,summary')].filter(e=>e.getBoundingClientRect().width&&e.getBoundingClientRect().height).map(e=>({tag:e.tagName,id:e.id,text:e.innerText||e.getAttribute('aria-label')||e.getAttribute('placeholder'),r:(()=>{const r=e.getBoundingClientRect();return{x:r.x,y:r.y,w:r.width,h:r.height}})(),font:getComputedStyle(e).fontSize}))})`);report.views.push({name,...facts});fs.writeFileSync(path.join(out,'raw.json'),JSON.stringify(report,null,2))};
|
||||
await send('Page.enable');await send('Runtime.enable');await send('DOM.enable');
|
||||
await send('Emulation.setDeviceMetricsOverride',{width:1600,height:1000,deviceScaleFactor:1,mobile:false});
|
||||
await until(`!!document.querySelector('[data-card="excel"] button')`);await delay(2500);
|
||||
await click('[data-card="excel"] button');
|
||||
await ev(`document.getElementById('project-name').value='디자인 검토 A';document.getElementById('project-name').dispatchEvent(new Event('input',{bubbles:true}))`);
|
||||
const doc=await send('DOM.getDocument'),node=await send('DOM.querySelector',{nodeId:doc.root.nodeId,selector:'#file'});
|
||||
await send('DOM.setFileInputFiles',{nodeId:node.nodeId,files:[path.resolve(root,'../../RAG/운곡교P1입력.xlsx')]});
|
||||
await until(`!document.getElementById('dlg-apply').disabled`);await cap('01-import-1600');
|
||||
report.importBefore=await ev(`({scope:document.querySelector('#dlg-selected').textContent,preview:document.querySelector('#pv-abut')?.innerText,button:document.querySelector('#dlg-apply').textContent})`);
|
||||
await click('#import-abut-A1');report.importDeselected=await ev(`({scope:document.querySelector('#dlg-selected').textContent,button:document.querySelector('#dlg-apply').textContent,preview:document.querySelector('#pv-abut')?.innerText})`);await click('#import-abut-A1');
|
||||
await send('Emulation.setDeviceMetricsOverride',{width:1280,height:800,deviceScaleFactor:1,mobile:false});await cap('02-import-1280');
|
||||
await click('#dlg-apply');await until(`!document.getElementById('import-dialog').open`);
|
||||
await click('#nav-plan');await until(`[...document.querySelectorAll('#plan-pier-items button')].some(b=>b.textContent.includes('A1')&&b.textContent.includes('교대'))`);
|
||||
await ev(`[...document.querySelectorAll('#plan-pier-items button')].find(b=>b.textContent.includes('A1')&&b.textContent.includes('교대')).click()`);await delay(4500);
|
||||
await click('#nav-rebar');await until(`document.querySelectorAll('#rebar-tabs button').length===6`);
|
||||
await send('Emulation.setDeviceMetricsOverride',{width:1600,height:1000,deviceScaleFactor:1,mobile:false});
|
||||
if(await ev(`document.querySelector('#review-model-toggle').getAttribute('aria-expanded')==='false'`))await click('#review-model-toggle');
|
||||
await ev(`document.querySelector('#review-model-height').value='240';document.querySelector('#review-model-height').dispatchEvent(new Event('input',{bubbles:true}))`);await cap('03-rebar-1600');
|
||||
report.footTasks=await ev(`document.querySelector('#review-summary').textContent+' '+document.querySelector('#review-tasks').innerText`);
|
||||
await send('Emulation.setDeviceMetricsOverride',{width:1280,height:800,deviceScaleFactor:1,mobile:false});await cap('04-rebar-1280');
|
||||
await ev(`[...document.querySelectorAll('#rebar-tabs button')].find(b=>b.textContent.includes('날개벽')).click()`);
|
||||
report.wingTasks=await ev(`document.querySelector('#review-summary').textContent+' '+document.querySelector('#review-tasks').innerText`);
|
||||
await click('#review-tasks button');report.taskJump=await ev(`({focused:document.activeElement?.innerText,selected:document.querySelector('#rebar-table').innerText.includes('강조 중')})`);
|
||||
await ev(`[...document.querySelectorAll('#rebar-tabs button')].find(b=>b.textContent.includes('확대기초')).click()`);
|
||||
await ev(`document.querySelector('#review-model-height').value='180';document.querySelector('#review-model-height').dispatchEvent(new Event('input',{bubbles:true}))`);
|
||||
await click('#review-inputs');report.inputJump=await ev(`({focused:document.activeElement?.id,scroll:document.querySelector('#review-workspace > .viewer').scrollTop})`);
|
||||
await click('#review-model-toggle');await click('#review-table');
|
||||
await ev(`document.querySelector('#view-rebar > .results').scrollTop=250`);await cap('05-collapsed-table-material-1280');
|
||||
report.end=await ev(`({modelHidden:getComputedStyle(document.querySelector('#slot-rebar')).display,expanded:document.querySelector('#review-model-toggle').getAttribute('aria-expanded'),focused:document.activeElement.tagName,frames:[...document.querySelectorAll('#view-rebar > .panel,#review-workspace,#review-workspace > .viewer')].map(e=>({id:e.id,class:e.className,overflow:getComputedStyle(e).overflow,clientHeight:e.clientHeight,scrollHeight:e.scrollHeight,scrollTop:e.scrollTop}))})`);
|
||||
report.finished=new Date().toISOString();fs.writeFileSync(path.join(out,'raw.json'),JSON.stringify(report,null,2));console.log(JSON.stringify(report));
|
||||
await send('Page.close');ws.close();
|
||||
})().catch(e=>{report.failure=String(e.stack||e);fs.writeFileSync(path.join(out,'raw.json'),JSON.stringify(report,null,2));console.error(e);if(ws)ws.close();process.exitCode=1});
|
||||
@@ -0,0 +1,52 @@
|
||||
// Independent design evidence: only the newly created Chrome tab is modified.
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const [input, out] = process.argv.slice(2);
|
||||
const report = { views: [], console: [], exceptions: [], started: new Date().toISOString() };
|
||||
const sleep = ms => new Promise(r => setTimeout(r, ms));
|
||||
let ws;
|
||||
async function main() {
|
||||
const target = await (await fetch('http://127.0.0.1:9444/json/new?http://127.0.0.1:5173/', { method: 'PUT' })).json();
|
||||
report.targetId = target.id;
|
||||
ws = new WebSocket(target.webSocketDebuggerUrl);
|
||||
await new Promise((r,j) => {ws.onopen=r; ws.onerror=j;});
|
||||
let next=0; const pending=new Map();
|
||||
ws.onmessage=({data})=> {const m=JSON.parse(data); if(m.id){const p=pending.get(m.id);if(p){clearTimeout(p.t);pending.delete(m.id);m.error?p.j(m.error):p.r(m.result);}} else if(m.method==='Runtime.consoleAPICalled')report.console.push({type:m.params.type,args:m.params.args.map(x=>x.value??x.description)});else if(m.method==='Runtime.exceptionThrown')report.exceptions.push(m.params.exceptionDetails);};
|
||||
const send=(method,params={})=>new Promise((r,j)=>{const id=++next;const t=setTimeout(()=>j(new Error(method+' timeout')),20000);pending.set(id,{r,j,t});ws.send(JSON.stringify({id,method,params}));});
|
||||
const ev=async expression=>{const r=await send('Runtime.evaluate',{expression,returnByValue:true,awaitPromise:true});if(r.exceptionDetails)throw new Error(r.exceptionDetails.text);return r.result.value;};
|
||||
const until=async e=>{for(let n=0;n<100;n++){if(await ev(e))return;await sleep(150);}throw new Error('Missing state '+e);};
|
||||
const click=async e=>ev(`(()=>{const n=${e};if(!n||n.disabled)throw new Error('Missing/disabled click');n.click();return true;})()`);
|
||||
await send('Runtime.enable'); await send('Page.enable'); await send('DOM.enable');
|
||||
await send('Emulation.setDeviceMetricsOverride',{width:1600,height:1000,deviceScaleFactor:1,mobile:false});
|
||||
await until(`typeof window.loadBytesToApp==='function'`);
|
||||
report.injectionPreflight=await ev(`(()=>{document.title='[Human] Design evidence B';let s=document.createElement('script');s.textContent='window.__designBMutation=true';document.head.append(s);return window.__designBMutation===true;})()`);
|
||||
const measure=async name=>{
|
||||
if(process.env.OVERLAY_URL) report.overlayInjection=await ev(`new Promise(resolve=>{document.title='[Human] Design evidence B';scrollTo(0,0);const s=document.createElement('script');s.src=${JSON.stringify(process.env.OVERLAY_URL)};s.onload=()=>setTimeout(()=>resolve({loaded:true}),2600);s.onerror=()=>resolve({loaded:false});document.head.append(s);})`);
|
||||
await sleep(350);
|
||||
const facts=await ev(`(()=>{const visible=e=>{const r=e.getBoundingClientRect();return r.width>0&&r.height>0&&getComputedStyle(e).visibility!=='hidden'};const rect=e=>{const r=e.getBoundingClientRect();return {x:r.x,y:r.y,width:r.width,height:r.height,right:r.right,bottom:r.bottom}};const controls=[...document.querySelectorAll('button,input,select,textarea')].filter(visible);const fields=controls.filter(e=>/INPUT|SELECT|TEXTAREA/.test(e.tagName));const named=e=>!!(e.labels?.length||e.getAttribute('aria-label')||e.getAttribute('aria-labelledby')||e.title);return {viewport:{w:innerWidth,h:innerHeight},doc:{w:document.documentElement.scrollWidth,h:document.documentElement.scrollHeight},text:document.body.innerText,controls:controls.map(e=>({tag:e.tagName,id:e.id,text:(e.innerText||e.getAttribute('aria-label')||e.value||'').slice(0,90),...rect(e),font:getComputedStyle(e).fontSize,disabled:e.disabled})),unnamedFields:fields.filter(e=>!named(e)).map(e=>({id:e.id,type:e.type,...rect(e)})),overflow:[...document.querySelectorAll('body *')].filter(visible).filter(e=>e.scrollWidth>e.clientWidth+2&&e.clientWidth>0).map(e=>({tag:e.tagName,id:e.id,cls:e.className,client:e.clientWidth,scroll:e.scrollWidth,overflowX:getComputedStyle(e).overflowX,...rect(e)})).slice(0,40),active:{id:document.activeElement.id,tag:document.activeElement.tagName},dialogs:[...document.querySelectorAll('dialog[open]')].map(rect)};})()`);
|
||||
facts.name=name;report.views.push(facts);
|
||||
const shot=await send('Page.captureScreenshot',{format:'png'});fs.writeFileSync(path.join(out,name+'.png'),Buffer.from(shot.data,'base64'));
|
||||
};
|
||||
await measure('01-landing-desktop');
|
||||
await click(`document.querySelector('[data-card="excel"] button')`);
|
||||
await ev(`document.getElementById('project-name').value='디자인 독립 검증 B'`);
|
||||
const doc=await send('DOM.getDocument');const file=await send('DOM.querySelector',{nodeId:doc.root.nodeId,selector:'#file'});
|
||||
await send('DOM.setFileInputFiles',{nodeId:file.nodeId,files:[input]});
|
||||
await until(`!document.getElementById('dlg-apply').disabled`);
|
||||
await measure('02-import-desktop');
|
||||
await click(`document.getElementById('dlg-apply')`);
|
||||
await click(`document.getElementById('nav-plan')`);
|
||||
await click(`[...document.querySelectorAll('#plan-pier-items button')].find(b=>b.textContent.includes('교대')&&b.textContent.includes('A1'))`);
|
||||
await measure('03-A1-plan-desktop');
|
||||
await click(`document.getElementById('nav-rebar')`);
|
||||
await click(`[...document.querySelectorAll('#rebar-pier-items button')].find(b=>b.textContent.includes('교대')&&b.textContent.includes('A1'))`);
|
||||
await until(`document.querySelectorAll('#rebar-tabs button').length===6`);
|
||||
await click(`[...document.querySelectorAll('#rebar-tabs button')].find(b=>b.textContent.startsWith('벽체'))`);
|
||||
await measure('04-A1-rebar-desktop');
|
||||
await send('Emulation.setDeviceMetricsOverride',{width:1280,height:800,deviceScaleFactor:1,mobile:false});
|
||||
await measure('05-A1-rebar-laptop');
|
||||
await send('Input.dispatchKeyEvent',{type:'keyDown',key:'Tab',code:'Tab',windowsVirtualKeyCode:9});await send('Input.dispatchKeyEvent',{type:'keyUp',key:'Tab',code:'Tab',windowsVirtualKeyCode:9});
|
||||
report.keyboardFocus=await ev(`(()=>{const e=document.activeElement;const s=getComputedStyle(e);return {tag:e.tagName,id:e.id,text:e.innerText?.slice(0,80),outline:s.outline,focusVisible:e.matches(':focus-visible')}})()`);
|
||||
report.result='COMPLETE';
|
||||
}
|
||||
fs.mkdirSync(out,{recursive:true});main().catch(e=>{report.result='PARTIAL';report.failure=e.stack;process.exitCode=1;}).finally(()=>{report.finished=new Date().toISOString();fs.writeFileSync(path.join(out,'browser.json'),JSON.stringify(report,null,2));console.log(JSON.stringify({result:report.result,views:report.views.length,failure:report.failure}));ws?.close();});
|
||||
@@ -0,0 +1,166 @@
|
||||
// Build non-destructive fixtures and verify full-name identity and error disclosure via CDP.
|
||||
// Run with Windows Node while the local app and a dedicated Chrome CDP port 9444 are running.
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const assert = require('node:assert/strict');
|
||||
const XLSX = require('xlsx');
|
||||
const root = path.resolve(__dirname, '..');
|
||||
const out = path.join(root, '.verify', 'abut-names-20261105');
|
||||
const report = { checks: [], errors: [], started: new Date().toISOString() };
|
||||
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
fs.mkdirSync(out, { recursive: true });
|
||||
const base = XLSX.readFile(path.resolve(root, '../../RAG/운곡교P1입력.xlsx'));
|
||||
const blocks = { info: [], ips: [], bearings: [] };
|
||||
for (const sheet of base.SheetNames) {
|
||||
const rows = XLSX.utils.sheet_to_json(base.Sheets[sheet], { header: 1, defval: null });
|
||||
for (let h = 0; h < rows.length; h++) {
|
||||
if (rows[h][0] !== '교대명') continue;
|
||||
const kind = ({ 형식: 'info', IP점: 'ips', 받침번호: 'bearings' })[rows[h][1]];
|
||||
if (!kind) continue;
|
||||
let name;
|
||||
for (let i = h + 1; i < rows.length && rows[i][1] != null && rows[i][1] !== ''; i++) {
|
||||
name = rows[i][0] || name;
|
||||
if (name === 'A1') blocks[kind].push(['A2-완도', ...rows[i].slice(1)]);
|
||||
}
|
||||
}
|
||||
for (const row of rows) if (row[0] === 'A1') row[0] = 'A1-완도';
|
||||
base.Sheets[sheet] = XLSX.utils.aoa_to_sheet(rows);
|
||||
}
|
||||
assert.equal(blocks.info.length, 1);
|
||||
blocks.info[0][1] = 'A2';
|
||||
XLSX.utils.book_append_sheet(base, XLSX.utils.aoa_to_sheet([
|
||||
['교대명', '형식'], ...blocks.info, [],
|
||||
['교대명', 'IP점'], ...blocks.ips, [],
|
||||
['교대명', '받침번호'], ...blocks.bearings,
|
||||
]), '종점교대');
|
||||
const validFile = path.join(out, 'full-names.xlsx');
|
||||
XLSX.writeFile(base, validFile);
|
||||
const bad = XLSX.readFile(validFile);
|
||||
for (const sheet of bad.SheetNames) {
|
||||
const rows = XLSX.utils.sheet_to_json(bad.Sheets[sheet], { header: 1, defval: null });
|
||||
for (const row of rows) if (row[0] === 'A1-완도' && row[1] === 'A1') row[1] = 'A3';
|
||||
bad.Sheets[sheet] = XLSX.utils.aoa_to_sheet(rows);
|
||||
}
|
||||
const badFile = path.join(out, 'invalid-type.xlsx');
|
||||
XLSX.writeFile(bad, badFile);
|
||||
let ws;
|
||||
let send;
|
||||
async function main() {
|
||||
const target = await (await fetch('http://127.0.0.1:9444/json/new?about:blank', { method: 'PUT' })).json();
|
||||
ws = new WebSocket(target.webSocketDebuggerUrl);
|
||||
await new Promise((resolve, reject) => { ws.onopen = resolve; ws.onerror = reject; });
|
||||
let seq = 0;
|
||||
let loaded;
|
||||
let downloadGuid;
|
||||
const done = new Set();
|
||||
const pending = new Map();
|
||||
send = (method, params = {}) => new Promise((resolve, reject) => {
|
||||
const id = ++seq;
|
||||
const timer = setTimeout(() => { pending.delete(id); reject(new Error(`CDP timeout ${method}`)); }, 20000);
|
||||
pending.set(id, { resolve, reject, timer });
|
||||
ws.send(JSON.stringify({ id, method, params }));
|
||||
});
|
||||
ws.onmessage = ({ data }) => {
|
||||
const msg = JSON.parse(data);
|
||||
if (msg.id) {
|
||||
const wait = pending.get(msg.id); if (!wait) return;
|
||||
clearTimeout(wait.timer); pending.delete(msg.id);
|
||||
if (msg.error) wait.reject(new Error(JSON.stringify(msg.error))); else wait.resolve(msg.result);
|
||||
} else if (msg.method === 'Page.loadEventFired') loaded?.();
|
||||
else if (msg.method === 'Runtime.exceptionThrown') report.errors.push(msg.params.exceptionDetails.exception?.description || msg.params.exceptionDetails.text);
|
||||
else if (msg.method === 'Runtime.consoleAPICalled' && msg.params.type === 'error') report.errors.push(msg.params.args.map((a) => a.value ?? a.description).join(' '));
|
||||
else if (msg.method === 'Browser.downloadWillBegin') downloadGuid = msg.params.guid;
|
||||
else if (msg.method === 'Browser.downloadProgress' && msg.params.state === 'completed') done.add(msg.params.guid);
|
||||
};
|
||||
const ev = async (expression) => {
|
||||
const r = await send('Runtime.evaluate', { expression, awaitPromise: true, returnByValue: true });
|
||||
if (r.exceptionDetails) throw new Error(r.exceptionDetails.exception?.description || r.exceptionDetails.text);
|
||||
return r.result.value;
|
||||
};
|
||||
const until = async (expression) => {
|
||||
for (let i = 0; i < 100; i++) { if (await ev(expression)) return; await delay(150); }
|
||||
throw new Error(`Timeout: ${expression}`);
|
||||
};
|
||||
const click = (expression) => ev(`(() => { const e = ${expression}; if (!e || e.disabled) throw new Error('Missing/disabled target'); e.click(); })()`);
|
||||
const check = (name, condition) => { assert.ok(condition, name); report.checks.push(name); console.log('PASS', name); };
|
||||
const shot = async (name) => {
|
||||
await delay(250);
|
||||
const r = await send('Page.captureScreenshot', { format: 'png' });
|
||||
fs.writeFileSync(path.join(out, `${name}.png`), Buffer.from(r.data, 'base64'));
|
||||
};
|
||||
const file = async (selector, name) => {
|
||||
const doc = await send('DOM.getDocument');
|
||||
const node = await send('DOM.querySelector', { nodeId: doc.root.nodeId, selector });
|
||||
await send('DOM.setFileInputFiles', { nodeId: node.nodeId, files: [name] });
|
||||
};
|
||||
const navigate = async () => {
|
||||
let timer;
|
||||
const load = new Promise((resolve, reject) => { loaded = resolve; timer = setTimeout(() => reject(new Error('Load timeout')), 20000); });
|
||||
await send('Page.navigate', { url: 'http://127.0.0.1:5173/' });
|
||||
try { await load; } finally { clearTimeout(timer); loaded = undefined; }
|
||||
await until(`typeof window.loadBytesToApp === 'function' && !!document.querySelector('[data-card="excel"] button')`);
|
||||
};
|
||||
const select = (name) => click(`[...document.querySelectorAll('#rebar-pier-items button')].find(b => b.textContent.includes(${JSON.stringify(name)}) && b.textContent.includes('교대'))`);
|
||||
await send('Page.enable'); await send('Runtime.enable'); await send('DOM.enable');
|
||||
await send('Emulation.setDeviceMetricsOverride', { width: 1600, height: 1000, deviceScaleFactor: 1, mobile: false });
|
||||
const downloads = 'C:\\Users\\User\\AppData\\Local\\Temp\\pierzainer-abut-name-20261105\\downloads';
|
||||
await send('Browser.setDownloadBehavior', { behavior: 'allowAndName', downloadPath: downloads, eventsEnabled: true });
|
||||
await navigate();
|
||||
await click(`document.querySelector('[data-card="excel"] button')`);
|
||||
await ev(`document.getElementById('project-name').value = '복합 교대명 검증'; document.getElementById('project-name').dispatchEvent(new Event('input'));`);
|
||||
await file('#file', validFile);
|
||||
await until(`document.getElementById('dlg-apply').textContent.includes('교대 2기')`);
|
||||
check('both full names recognized without cascade errors', await ev(`document.getElementById('pv-abuts').textContent.includes('A1-완도') && document.getElementById('pv-abuts').textContent.includes('A2-완도') && !document.querySelector('#sheets .bad')`));
|
||||
check('full-name checkboxes stay distinct', await ev(`document.getElementById('import-abut-A1-완도').checked && document.getElementById('import-abut-A2-완도').checked`));
|
||||
await shot('01-full-names-import');
|
||||
await click(`document.getElementById('dlg-apply')`);
|
||||
await click(`document.getElementById('nav-rebar')`);
|
||||
await select('A1-완도');
|
||||
check('full name selects the start abutment', await ev(`document.getElementById('rebar-note').textContent.startsWith('A1-완도') && document.querySelectorAll('#rebar-tabs button').length === 6`));
|
||||
await select('A2-완도');
|
||||
check('full name selects the end abutment independently', await ev(`document.getElementById('rebar-note').textContent.startsWith('A2-완도')`));
|
||||
const order = await ev(`[...document.querySelectorAll('#rebar-pier-items button')].map(b => b.textContent)`);
|
||||
check('type-based order is preserved', order[0].includes('A1-완도') && order[1].includes('P1') && order[2].includes('A2-완도'));
|
||||
await shot('02-independent-abutments');
|
||||
await click(`document.getElementById('save-project')`);
|
||||
await until(`document.getElementById('save-dialog').open`);
|
||||
await click(`document.getElementById('sv-save')`);
|
||||
for (let i = 0; i < 100 && !(downloadGuid && done.has(downloadGuid)); i++) await delay(150);
|
||||
assert.ok(downloadGuid && done.has(downloadGuid), 'actual download completes');
|
||||
const text = fs.readFileSync(path.join(downloads, downloadGuid), 'utf8');
|
||||
const project = JSON.parse(text);
|
||||
const expectedNames = ['A1-완도', 'A2-완도'];
|
||||
assert.deepEqual(project.abutments.map((a) => a.name), expectedNames);
|
||||
assert.deepEqual(project.abutDefaults.map((a) => a.name), expectedNames);
|
||||
report.checks.push('actual download preserves both names and defaults');
|
||||
const saved = path.join(out, 'full-names.pzr.json'); fs.writeFileSync(saved, text);
|
||||
await navigate();
|
||||
await ev('window.openSampleProject()');
|
||||
await click(`document.getElementById('open-project')`);
|
||||
await file('#project-file', saved);
|
||||
await until(`document.querySelector('#toast-host').textContent.includes('열었습니다')`);
|
||||
await click(`document.getElementById('nav-rebar')`);
|
||||
await select('A1-완도'); await select('A2-완도');
|
||||
check('fresh reopen regenerates full-name abutments', await ev(`document.getElementById('save-status').textContent === '저장됨' && document.getElementById('rebar-note').textContent.startsWith('A2-완도')`));
|
||||
await shot('03-reopened');
|
||||
await click(`document.getElementById('open-import')`);
|
||||
await file('#file', badFile);
|
||||
await until(`document.getElementById('dlg-apply').textContent.includes('교대 1기') && !!document.querySelector('#sheets details')`);
|
||||
check('one bad definition does not disable another name', await ev(`document.getElementById('import-abut-A2-완도').checked && !document.getElementById('import-abut-A2-완도').disabled`));
|
||||
check('dependent rows are collapsed under the root', await ev(`document.querySelectorAll('#sheets details').length === 1 && !document.querySelector('#sheets details').open && document.querySelector('#sheets details summary').textContent.includes('8개 행') && document.querySelectorAll('#sheets details p').length === 8`));
|
||||
await shot('04-root-error-collapsed');
|
||||
await click(`document.querySelector('#sheets details summary')`);
|
||||
check('dependent rows remain available for diagnosis', await ev(`document.querySelector('#sheets details').open && document.querySelector('#sheets details').textContent.includes('IP') && document.querySelector('#sheets details').textContent.includes('받침')`));
|
||||
await shot('05-related-rows');
|
||||
check('no browser runtime errors', report.errors.length === 0);
|
||||
report.result = 'PASS';
|
||||
}
|
||||
main().catch((error) => { report.result = 'FAIL'; report.failure = error.stack; process.exitCode = 1; }).finally(async () => {
|
||||
if (report.result === 'FAIL' && send) {
|
||||
try { const shot = await send('Page.captureScreenshot', { format: 'png' }); fs.writeFileSync(path.join(out, 'failure.png'), Buffer.from(shot.data, 'base64')); } catch { /* Keep the original error. */ }
|
||||
}
|
||||
report.finished = new Date().toISOString(); fs.writeFileSync(path.join(out, 'report.json'), JSON.stringify(report, null, 2));
|
||||
console.log(JSON.stringify({ result: report.result, checks: report.checks.length, errors: report.errors, failure: report.failure }));
|
||||
if (send) { try { await send('Page.close'); } catch { /* The tab may already be closed. */ } }
|
||||
ws?.close();
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
// 교대만 가져오기 (2026-11-06): CDP 9444 Chrome + Vite 5173. Imports the sample workbook with P1 unchecked and A1 only,
|
||||
// then walks ① ② ③, and checks for runtime exceptions; screenshots go to .verify/abut-only-20261106.
|
||||
const fs = require('node:fs'), path = require('node:path'), assert = require('node:assert/strict');
|
||||
const out = path.resolve(__dirname, '../.verify/abut-only-20261106');
|
||||
fs.mkdirSync(out, { recursive: true });
|
||||
const report = { checks: [], errors: [], console: [] };
|
||||
const delay = ms => new Promise(r => setTimeout(r, ms));
|
||||
(async () => {
|
||||
const target = await (await fetch('http://127.0.0.1:9444/json/new?about:blank', { method: 'PUT' })).json();
|
||||
const ws = new WebSocket(target.webSocketDebuggerUrl);
|
||||
await new Promise((r, j) => { ws.onopen = r; ws.onerror = j; });
|
||||
let seq = 0; const pending = new Map();
|
||||
ws.onmessage = ({ data }) => {
|
||||
const m = JSON.parse(data), p = pending.get(m.id);
|
||||
if (p) { pending.delete(m.id); m.error ? p.reject(new Error(JSON.stringify(m.error))) : p.resolve(m.result); }
|
||||
if (m.method === 'Runtime.exceptionThrown') report.errors.push(m.params.exceptionDetails.exception?.description || m.params.exceptionDetails.text);
|
||||
if (m.method === 'Runtime.consoleAPICalled' && m.params.type === 'error') report.console.push(m.params.args.map(a => a.value ?? a.description).join(' '));
|
||||
};
|
||||
const send = (method, params = {}) => new Promise((resolve, reject) => { const id = ++seq; pending.set(id, { resolve, reject }); ws.send(JSON.stringify({ id, method, params })); });
|
||||
const ev = async expression => {
|
||||
const r = await send('Runtime.evaluate', { expression, awaitPromise: true, returnByValue: true });
|
||||
if (r.exceptionDetails) throw new Error(r.exceptionDetails.exception?.description || r.exceptionDetails.text);
|
||||
return r.result.value;
|
||||
};
|
||||
const until = async expr => { for (let i = 0; i < 100; i++) { if (await ev(expr)) return; await delay(150); } throw new Error(`Wait failed: ${expr}`); };
|
||||
const click = expr => ev(`(() => { const e = ${expr}; if (!e || e.disabled) throw new Error('Missing/disabled: ' + ${JSON.stringify(expr)}); e.click(); })()`);
|
||||
const check = (name, pass) => { assert.ok(pass, name); report.checks.push(name); console.log('PASS', name); };
|
||||
const shot = async name => { await delay(400); const r = await send('Page.captureScreenshot', { format: 'png' }); fs.writeFileSync(path.join(out, name + '.png'), Buffer.from(r.data, 'base64')); };
|
||||
try {
|
||||
await send('Page.enable'); await send('Runtime.enable');
|
||||
await send('Emulation.setDeviceMetricsOverride', { width: 1600, height: 1000, deviceScaleFactor: 1, mobile: false });
|
||||
await send('Page.navigate', { url: 'http://127.0.0.1:5173/' });
|
||||
await until(`typeof window.loadBytesToApp === 'function'`);
|
||||
await ev(`(async () => { const b = await (await fetch('/samples/' + encodeURIComponent('운곡교P1입력.xlsx'))).arrayBuffer(); window.loadBytesToApp(new Uint8Array(b), '운곡교P1입력.xlsx'); })()`);
|
||||
await until(`!!document.getElementById('import-abut-A1')`);
|
||||
// Uncheck every pier, keep A1.
|
||||
await ev(`(() => { for (const c of document.querySelectorAll('input[data-import-kind="pier"]')) if (c.checked) { c.checked = false; c.dispatchEvent(new Event('change', { bubbles: true })); } })()`);
|
||||
await ev(`(() => { const c = document.getElementById('import-abut-A1'); if (!c.checked) { c.checked = true; c.dispatchEvent(new Event('change', { bubbles: true })); } })()`);
|
||||
await ev(`(() => { const e = document.getElementById('project-name'); e.value = '교대만 검증'; e.dispatchEvent(new Event('input', { bubbles: true })); })()`);
|
||||
const label = await ev(`document.getElementById('dlg-apply').textContent`);
|
||||
check(`apply button allows A1 only: "${label}"`, !(await ev(`document.getElementById('dlg-apply').disabled`)) && label.includes('교각 0기 + 교대 1기'));
|
||||
check('dialog note says abutments only', (await ev(`document.getElementById('dlg-selected').textContent`)).includes('교대만 가져옵니다'));
|
||||
await click(`document.getElementById('dlg-apply')`);
|
||||
await until(`!document.querySelector('.app').hidden`);
|
||||
await shot('1-shape');
|
||||
check('① list shows A1', (await ev(`document.body.innerText`)).includes('A1 · A1 교대'));
|
||||
// WebGL canvases cannot be read back without preserveDrawingBuffer: the 3D view is judged on the screenshots.
|
||||
for (const [nav, name] of [['nav-plan', '2-plan'], ['nav-rebar', '3-rebar'], ['nav-shape', '4-shape-again']]) {
|
||||
await click(`document.getElementById('${nav}')`);
|
||||
await shot(name);
|
||||
// Pick the abutment in the visible list of this stage, when there is one.
|
||||
await ev(`(() => { const b = [...document.querySelectorAll('main:not([hidden]) button')].find(x => x.textContent.startsWith('A1 ·')); if (b) b.click(); })()`);
|
||||
await shot(name + '-A1');
|
||||
}
|
||||
check('no runtime exceptions (교대만)', report.errors.length === 0);
|
||||
// Regression: P1 + A1 together still import, and P1 front-row solplates pair with A1.
|
||||
await send('Page.navigate', { url: 'http://127.0.0.1:5173/' });
|
||||
await until(`typeof window.loadBytesToApp === 'function'`);
|
||||
await ev(`(async () => { const b = await (await fetch('/samples/' + encodeURIComponent('운곡교P1입력.xlsx'))).arrayBuffer(); window.loadBytesToApp(new Uint8Array(b), '운곡교P1입력.xlsx'); })()`);
|
||||
await until(`!!document.getElementById('import-abut-A1')`);
|
||||
await ev(`(() => { const e = document.getElementById('project-name'); e.value = '교각+교대 검증'; e.dispatchEvent(new Event('input', { bubbles: true })); })()`);
|
||||
check('P1 + A1 apply label', (await ev(`document.getElementById('dlg-apply').textContent`)).includes('교각 1기 + 교대 1기'));
|
||||
await click(`document.getElementById('dlg-apply')`);
|
||||
await until(`!document.querySelector('.app').hidden`);
|
||||
await click(`document.getElementById('nav-plan')`);
|
||||
await shot('5-pier-plan');
|
||||
check('no runtime exceptions (교각 + 교대)', report.errors.length === 0);
|
||||
} catch (err) {
|
||||
report.failure = String(err && err.stack || err);
|
||||
console.error(report.failure);
|
||||
} finally {
|
||||
fs.writeFileSync(path.join(out, 'report.json'), JSON.stringify(report, null, 2));
|
||||
console.log(JSON.stringify({ checks: report.checks.length, errors: report.errors, console: report.console.slice(0, 5), failure: report.failure }, null, 2));
|
||||
ws.close();
|
||||
}
|
||||
})();
|
||||
@@ -0,0 +1,90 @@
|
||||
// ③ 교대 벽체 카드 화면 확인 (2026-11-04): 헤드리스 Chrome(CDP)으로 입력 파일을 불러와 A1 → ③ → 벽체를 열고 스크린샷을 남긴다.
|
||||
// 사용 (WSL Node ≥ 22, 개발 서버 실행 중):
|
||||
// chrome-headless-shell --headless --remote-debugging-port=9444 & (~/.cache/ms-playwright/chromium_headless_shell-*/)
|
||||
// node scripts/verify-abut-wall.cjs <input.xlsx> <out dir>
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const APP_URL = process.env.APP_URL || 'http://127.0.0.1:5173/';
|
||||
const CDP = process.env.CDP || 'http://127.0.0.1:9444';
|
||||
const [INPUT_FILE, OUT_DIR] = process.argv.slice(2);
|
||||
if (!INPUT_FILE || !OUT_DIR) throw new Error('INPUT_FILE and OUT_DIR are required');
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
async function main() {
|
||||
const targets = await (await fetch(`${CDP}/json`)).json();
|
||||
const page = targets.find((t) => t.type === 'page');
|
||||
const ws = new WebSocket(page.webSocketDebuggerUrl);
|
||||
await new Promise((res, rej) => ((ws.onopen = res), (ws.onerror = rej)));
|
||||
let seq = 0;
|
||||
const waiting = new Map();
|
||||
const consoleEvents = [];
|
||||
ws.onmessage = (ev) => {
|
||||
const m = JSON.parse(ev.data);
|
||||
if (m.id && waiting.has(m.id)) {
|
||||
waiting.get(m.id)(m);
|
||||
waiting.delete(m.id);
|
||||
} else if (m.method === 'Runtime.exceptionThrown') consoleEvents.push(`exception: ${m.params.exceptionDetails.exception?.description || m.params.exceptionDetails.text}`);
|
||||
else if (m.method === 'Runtime.consoleAPICalled' && m.params.type === 'error') consoleEvents.push(`console.error: ${m.params.args.map((a) => a.value ?? a.description ?? '').join(' ')}`);
|
||||
};
|
||||
const send = (method, params = {}) => new Promise((res) => { const id = ++seq; waiting.set(id, res); ws.send(JSON.stringify({ id, method, params })); });
|
||||
const evalJs = async (expression) => {
|
||||
const r = await send('Runtime.evaluate', { expression, awaitPromise: true, returnByValue: true });
|
||||
if (r.result.exceptionDetails) throw new Error(JSON.stringify(r.result.exceptionDetails).slice(0, 400));
|
||||
return r.result.result.value;
|
||||
};
|
||||
const shot = async (name, full = false) => {
|
||||
const params = { format: 'png' };
|
||||
if (full) {
|
||||
const m = await send('Page.getLayoutMetrics');
|
||||
params.clip = { x: 0, y: 0, width: 1600, height: Math.min(m.result.cssContentSize.height, 6000), scale: 1 };
|
||||
params.captureBeyondViewport = true;
|
||||
}
|
||||
const r = await send('Page.captureScreenshot', params);
|
||||
fs.writeFileSync(path.join(OUT_DIR, name), Buffer.from(r.result.data, 'base64'));
|
||||
console.log(`SHOT ${name}`);
|
||||
};
|
||||
const click = (js) => evalJs(`(() => { const e = ${js}; if (!e) throw new Error('없음: ${js.replace(/'/g, '')}'); e.click(); return true; })()`);
|
||||
|
||||
await send('Page.enable');
|
||||
await send('Runtime.enable');
|
||||
await send('DOM.enable');
|
||||
await send('Emulation.setDeviceMetricsOverride', { width: 1600, height: 1000, deviceScaleFactor: 1, mobile: false });
|
||||
await send('Page.navigate', { url: APP_URL });
|
||||
await sleep(4000);
|
||||
await click(`document.getElementById('open-import')`);
|
||||
await sleep(500);
|
||||
const doc = await send('DOM.getDocument', { depth: -1 });
|
||||
const q = await send('DOM.querySelector', { nodeId: doc.result.root.nodeId, selector: '#file' });
|
||||
await send('DOM.setFileInputFiles', { nodeId: q.result.nodeId, files: [INPUT_FILE] });
|
||||
await sleep(3000);
|
||||
await click(`document.getElementById('dlg-apply')`);
|
||||
await sleep(2500);
|
||||
await click(`document.getElementById('nav-rebar')`);
|
||||
await sleep(1500);
|
||||
await click(`[...document.querySelectorAll('#rebar-pier-items button')].find((b) => b.textContent.includes('교대'))`);
|
||||
await sleep(2000);
|
||||
await click(`[...document.querySelectorAll('#rebar-tabs button')].find((b) => b.textContent.startsWith('벽체'))`);
|
||||
await sleep(2000);
|
||||
console.log('STATUS', await evalJs(`document.getElementById('step3-status').textContent`));
|
||||
console.log('TABS', await evalJs(`[...document.querySelectorAll('#rebar-tabs button')].map((b) => b.textContent).join(' | ')`));
|
||||
console.log('NOTE', await evalJs(`document.getElementById('rebar-note').textContent`));
|
||||
console.log('ROWS', await evalJs(`[...document.querySelectorAll('#rebar-table table.rebar-table tbody tr')].filter((r) => r.cells.length > 2).map((r) => [r.cells[2].textContent, r.cells[7].textContent, r.cells[8].textContent].join(' ')).join(' | ')`));
|
||||
console.log('ISSUES', await evalJs(`document.getElementById('rebar-issues').innerText.replace(/\\n+/g, ' / ')`));
|
||||
console.log('BARS3D', await evalJs(`document.querySelectorAll('canvas').length`));
|
||||
await shot('wall-1-section.png', true);
|
||||
await click(`[...document.querySelectorAll('.abut-optt')].find((b) => b.textContent.startsWith('교대축 단면'))`);
|
||||
await sleep(1200);
|
||||
await shot('wall-2-elevation.png', true);
|
||||
// W9 행 선택 → 선택만 보기.
|
||||
await click(`[...document.querySelectorAll('#rebar-table table.rebar-table tbody tr')].find((r) => r.cells[2] && r.cells[2].textContent === 'W9')`);
|
||||
await sleep(800);
|
||||
await click(`[...document.querySelectorAll('#rebar-table .rebar-select button')].find((b) => b.textContent === '선택만 보기')`);
|
||||
await sleep(1500);
|
||||
console.log('SELECTED', await evalJs(`document.querySelector('#rebar-table .rebar-select span').textContent`));
|
||||
await shot('wall-3-w9-only.png');
|
||||
console.log(`CONSOLE_EVENTS ${consoleEvents.length}`);
|
||||
for (const e of consoleEvents) console.log(` ${e}`);
|
||||
ws.close();
|
||||
}
|
||||
main().catch((e) => { console.error('FAILED', e); process.exit(1); });
|
||||
@@ -0,0 +1,241 @@
|
||||
// Verify A1's six rebar cards, rendered views, edits and project round trip via CDP.
|
||||
// Run with Windows Node when CDP is served by Windows Chrome:
|
||||
// node scripts/verify-abut-workflow.cjs <input.xlsx> <output-directory> <download-directory>
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const [input, out, downloads, mode] = process.argv.slice(2);
|
||||
if (!input || !out || !downloads) throw new Error('input, output and download directories are required');
|
||||
const base = process.env.CDP || 'http://127.0.0.1:9444';
|
||||
const url = process.env.APP_URL || 'http://127.0.0.1:5173/';
|
||||
const report = mode === '--resume'
|
||||
? JSON.parse(fs.readFileSync(path.join(out, 'report.json'), 'utf8'))
|
||||
: { input, url, started: new Date().toISOString(), cards: [], errors: [], checks: [] };
|
||||
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
fs.mkdirSync(out, { recursive: true });
|
||||
let ws;
|
||||
|
||||
async function main() {
|
||||
console.log('Connecting to Chrome', base);
|
||||
const targets = await (await fetch(`${base}/json`, { signal: AbortSignal.timeout(10000) })).json();
|
||||
const target = targets.find((t) => t.type === 'page');
|
||||
assert.ok(target, 'Chrome page target');
|
||||
ws = new WebSocket(target.webSocketDebuggerUrl);
|
||||
await new Promise((resolve, reject) => { ws.onopen = resolve; ws.onerror = reject; });
|
||||
let next = 0;
|
||||
const pending = new Map();
|
||||
const completed = new Set();
|
||||
let downloadGuid;
|
||||
let loaded;
|
||||
ws.onmessage = ({ data }) => {
|
||||
const message = JSON.parse(data);
|
||||
if (message.id) {
|
||||
const waiter = pending.get(message.id);
|
||||
if (!waiter) return;
|
||||
clearTimeout(waiter.timer);
|
||||
pending.delete(message.id);
|
||||
if (message.error) waiter.reject(new Error(JSON.stringify(message.error)));
|
||||
else waiter.resolve(message.result);
|
||||
} else if (message.method === 'Page.loadEventFired') loaded?.();
|
||||
else if (message.method === 'Runtime.exceptionThrown') {
|
||||
report.errors.push(message.params.exceptionDetails.exception?.description || message.params.exceptionDetails.text);
|
||||
} else if (message.method === 'Runtime.consoleAPICalled' && message.params.type === 'error') {
|
||||
report.errors.push(message.params.args.map((v) => v.value ?? v.description ?? '').join(' '));
|
||||
} else if (message.method === 'Browser.downloadWillBegin') downloadGuid = message.params.guid;
|
||||
else if (message.method === 'Browser.downloadProgress' && message.params.state === 'completed') completed.add(message.params.guid);
|
||||
};
|
||||
const send = (method, params = {}) => new Promise((resolve, reject) => {
|
||||
const id = ++next;
|
||||
const timer = setTimeout(() => { pending.delete(id); reject(new Error(`CDP timeout: ${method}`)); }, 20000);
|
||||
pending.set(id, { resolve, reject, timer });
|
||||
ws.send(JSON.stringify({ id, method, params }));
|
||||
});
|
||||
const evaluate = async (expression) => {
|
||||
const result = await send('Runtime.evaluate', { expression, awaitPromise: true, returnByValue: true });
|
||||
if (result.exceptionDetails) throw new Error(result.exceptionDetails.exception?.description || result.exceptionDetails.text);
|
||||
return result.result.value;
|
||||
};
|
||||
const navigate = async () => {
|
||||
let timer;
|
||||
const event = new Promise((resolve, reject) => {
|
||||
timer = setTimeout(() => reject(new Error('Page load timeout')), 20000);
|
||||
loaded = resolve;
|
||||
});
|
||||
await send('Page.navigate', { url });
|
||||
try { await event; } finally { clearTimeout(timer); loaded = undefined; }
|
||||
await until(() => evaluate(`typeof window.loadBytesToApp === 'function'`), 'app event handlers loaded');
|
||||
};
|
||||
const until = async (test, label) => {
|
||||
const end = Date.now() + 20000;
|
||||
while (Date.now() < end) {
|
||||
if (await test()) return;
|
||||
await delay(150);
|
||||
}
|
||||
report.timeoutPage = await evaluate('document.body.innerText');
|
||||
const shot = await send('Page.captureScreenshot', { format: 'png' });
|
||||
fs.writeFileSync(path.join(out, 'failure.png'), Buffer.from(shot.data, 'base64'));
|
||||
throw new Error(`Timeout: ${label}`);
|
||||
};
|
||||
const click = (expression) => evaluate(`(() => { const node = ${expression}; if (!node) throw new Error('Missing click target'); if (node.disabled) throw new Error('Disabled click target'); node.click(); return true; })()`);
|
||||
const selectCard = async (label) => {
|
||||
await click(`[...document.querySelectorAll('#rebar-tabs button')].find(b => b.textContent.startsWith(${JSON.stringify(label)}))`);
|
||||
await until(() => evaluate(`document.querySelector('#rebar-table svg') !== null`), label);
|
||||
await delay(250);
|
||||
};
|
||||
const screenshot = async (name) => {
|
||||
const shot = await send('Page.captureScreenshot', { format: 'png' });
|
||||
fs.writeFileSync(path.join(out, `${name}.png`), Buffer.from(shot.data, 'base64'));
|
||||
};
|
||||
const upload = async (selector, file) => {
|
||||
const doc = await send('DOM.getDocument');
|
||||
const node = await send('DOM.querySelector', { nodeId: doc.root.nodeId, selector });
|
||||
assert.ok(node.nodeId, selector);
|
||||
await send('DOM.setFileInputFiles', { nodeId: node.nodeId, files: [file] });
|
||||
};
|
||||
const editFirst = async (value) => evaluate(`(() => {
|
||||
const node = document.querySelector('#rebar-table .abut-rebar-inputs input[type=number]');
|
||||
if (!node) throw new Error('Missing rebar input');
|
||||
const old = { id: node.id, value: node.value };
|
||||
node.value = ${JSON.stringify(value)};
|
||||
node.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
return old;
|
||||
})()`);
|
||||
const rows = () => evaluate(`[...document.querySelectorAll('#rebar-table table.rebar-table tbody tr')].filter(r => r.cells.length > 2).map(r => r.innerText)`);
|
||||
const restoreView = async (card) => {
|
||||
for (const text of card.context) {
|
||||
await click(`[...document.querySelectorAll('#rebar-table .abut-optt')].find(b => b.textContent === ${JSON.stringify(text)})`);
|
||||
}
|
||||
};
|
||||
|
||||
await send('Page.enable');
|
||||
await send('Runtime.enable');
|
||||
await send('DOM.enable');
|
||||
await send('Emulation.setDeviceMetricsOverride', { width: 1600, height: 1000, deviceScaleFactor: 1, mobile: false });
|
||||
await send('Browser.setDownloadBehavior', { behavior: 'allowAndName', downloadPath: downloads, eventsEnabled: true });
|
||||
await send('Page.addScriptToEvaluateOnNewDocument', { source: `
|
||||
window.__drawCalls = 0;
|
||||
for (const type of [window.WebGLRenderingContext, window.WebGL2RenderingContext]) {
|
||||
if (!type) continue;
|
||||
for (const method of ['drawElements', 'drawArrays', 'drawElementsInstanced', 'drawArraysInstanced']) {
|
||||
const original = type.prototype[method];
|
||||
if (original) type.prototype[method] = function(...args) { window.__drawCalls++; return original.apply(this, args); };
|
||||
}
|
||||
}
|
||||
` });
|
||||
if (mode !== '--resume') {
|
||||
await navigate();
|
||||
await until(() => evaluate(`document.querySelector('[data-card="excel"] button') !== null && typeof window.loadBytesToApp === 'function'`), 'app loaded');
|
||||
await click(`document.querySelector('[data-card="excel"] button')`);
|
||||
await evaluate(`const name = document.getElementById('project-name'); name.value = 'A1 화면 검증'; name.dispatchEvent(new Event('input', { bubbles: true }));`);
|
||||
await upload('#file', input);
|
||||
await until(() => evaluate(`!document.getElementById('dlg-apply').disabled`), 'Excel import');
|
||||
report.import = await evaluate(`({ sheets: document.getElementById('sheets').innerText, selection: document.getElementById('dlg-selected').textContent })`);
|
||||
await screenshot('01-import');
|
||||
await click(`document.getElementById('dlg-apply')`);
|
||||
await click(`document.getElementById('nav-rebar')`);
|
||||
await until(() => evaluate(`[...document.querySelectorAll('#rebar-pier-items button')].some(b => b.textContent.includes('교대'))`), 'abutment list');
|
||||
await click(`[...document.querySelectorAll('#rebar-pier-items button')].find(b => b.textContent.includes('교대') && b.textContent.includes('A1'))`);
|
||||
await until(() => evaluate(`document.querySelectorAll('#rebar-tabs button').length === 6`), 'six member cards');
|
||||
|
||||
for (const [index, label] of ['받침블럭', '벽체', '흉벽', '확대기초', '날개벽', '접속슬래브'].entries()) {
|
||||
await selectCard(label);
|
||||
const before = await rows();
|
||||
assert.ok(before.length, `${label}: non-empty rebar table`);
|
||||
const card = { label, rows: before, views: [] };
|
||||
const views = await evaluate(`[...document.querySelectorAll('#rebar-table .abut-optt')].map(b => b.textContent)`);
|
||||
assert.ok(views.length >= 2, `${label}: view tabs`);
|
||||
for (const [viewIndex, view] of views.entries()) {
|
||||
const drawBefore = await evaluate('window.__drawCalls');
|
||||
await click(`[...document.querySelectorAll('#rebar-table .abut-optt')].find(b => b.textContent === ${JSON.stringify(view)})`);
|
||||
await delay(350);
|
||||
const facts = await evaluate(`({ svg: !!document.querySelector('#rebar-table svg'), badSvg: /(?:NaN|Infinity)/.test(document.querySelector('#rebar-table svg')?.outerHTML || ''), drawCalls: window.__drawCalls, canvases: [...document.querySelectorAll('canvas')].map(c => ({ width: c.width, height: c.height })) })`);
|
||||
assert.ok(facts.svg && !facts.badSvg, `${label}/${view}: finite SVG`);
|
||||
assert.ok(facts.drawCalls > drawBefore, `${label}/${view}: WebGL draw calls`);
|
||||
await screenshot(`${String(index + 2).padStart(2, '0')}-${index}-${viewIndex}`);
|
||||
fs.writeFileSync(path.join(out, `${index}-${viewIndex}.svg`), await evaluate(`document.querySelector('#rebar-table svg').outerHTML`));
|
||||
card.views.push({ view, ...facts });
|
||||
}
|
||||
const originalValue = await evaluate(`document.querySelector('#rebar-table .abut-rebar-inputs input[type=number]').value`);
|
||||
const changedValue = originalValue === '150' ? '200' : '150';
|
||||
const old = await editFirst(changedValue);
|
||||
const changed = await rows();
|
||||
assert.notDeepEqual(changed, before, `${label}: input recomputes rows`);
|
||||
assert.equal(await evaluate(`document.getElementById(${JSON.stringify(old.id)}).value`), changedValue);
|
||||
card.edit = { ...old, changedValue, changedRows: changed };
|
||||
{
|
||||
const marks = await evaluate(`[...document.querySelectorAll('#rebar-table table.rebar-table tbody tr')].filter(r => r.cells.length > 2).map(r => r.cells[2].textContent)`);
|
||||
assert.ok(marks[0], `${label}: selectable mark`);
|
||||
await click(`document.querySelector('#rebar-table table.rebar-table tbody tr')`);
|
||||
await delay(150);
|
||||
if (!await evaluate(`document.querySelector('#rebar-table .rebar-select button:last-child').getAttribute('aria-pressed') === 'true'`)) {
|
||||
await click(`[...document.querySelectorAll('#rebar-table .rebar-select button')].find(b => b.textContent === '선택만 보기')`);
|
||||
}
|
||||
await delay(250);
|
||||
card.selection = await evaluate(`document.querySelector('#rebar-table .rebar-select').innerText`);
|
||||
assert.ok(card.selection.includes('선택 1 /'), `${label}: one selected bar`);
|
||||
await screenshot(`${String(index + 2).padStart(2, '0')}-${index}-selected`);
|
||||
}
|
||||
card.context = await evaluate(`[...document.querySelectorAll('#rebar-table .abut-optt[aria-pressed="true"]')].map(b => b.textContent)`);
|
||||
report.cards.push(card);
|
||||
console.log('Verified card', label);
|
||||
fs.writeFileSync(path.join(out, 'report.json'), JSON.stringify(report, null, 2));
|
||||
}
|
||||
|
||||
// Snapshot all tables after every edit, including dependent members and the wing side.
|
||||
for (const card of report.cards) {
|
||||
await selectCard(card.label);
|
||||
await restoreView(card);
|
||||
card.edit.changedRows = await rows();
|
||||
}
|
||||
fs.writeFileSync(path.join(out, 'report.json'), JSON.stringify(report, null, 2));
|
||||
await click(`document.getElementById('save-project')`);
|
||||
await until(() => evaluate(`document.getElementById('save-dialog').open`), 'save dialog');
|
||||
await evaluate(`document.getElementById('sv-name').value = 'A1 화면 검증'; document.getElementById('sv-bridge').value = 'A1-verification'`);
|
||||
await click(`document.getElementById('sv-save')`);
|
||||
await until(async () => downloadGuid && completed.has(downloadGuid), 'download completes');
|
||||
const downloaded = fs.readFileSync(path.join(downloads, downloadGuid), 'utf8');
|
||||
const project = JSON.parse(downloaded);
|
||||
report.savedAbutments = project.abutments;
|
||||
assert.ok(project.abutments?.length, 'project contains abutments');
|
||||
const savedFile = path.join(out, 'A1-roundtrip.pzr.json');
|
||||
fs.writeFileSync(savedFile, downloaded);
|
||||
report.checks.push('actual project download');
|
||||
fs.writeFileSync(path.join(out, 'report.json'), JSON.stringify(report, null, 2));
|
||||
}
|
||||
await navigate();
|
||||
// Enter the app shell through its existing sample entry, then open the saved file.
|
||||
// This uses no original Excel file for reconstruction.
|
||||
await evaluate('window.openSampleProject()');
|
||||
await click(`document.getElementById('open-project')`);
|
||||
await upload('#project-file', path.join(out, 'A1-roundtrip.pzr.json'));
|
||||
await until(() => evaluate(`document.querySelector('#toast-host')?.textContent.includes('열었습니다') && document.getElementById('save-status').textContent === '저장됨'`), 'open saved project');
|
||||
await click(`document.getElementById('nav-rebar')`);
|
||||
await click(`[...document.querySelectorAll('#rebar-pier-items button')].find(b => b.textContent.includes('교대') && b.textContent.includes('A1'))`);
|
||||
for (const card of report.cards) {
|
||||
await selectCard(card.label);
|
||||
await restoreView(card);
|
||||
const restored = await evaluate(`document.getElementById(${JSON.stringify(card.edit.id)})?.value`);
|
||||
assert.equal(restored, card.edit.changedValue, `${card.label}: restored input`);
|
||||
assert.deepEqual(await rows(), card.edit.changedRows, `${card.label}: restored computed rows`);
|
||||
card.roundtrip = 'PASS';
|
||||
}
|
||||
await screenshot('09-restored');
|
||||
report.checks.push('6 member cards', 'all SVG view tabs', 'WebGL drawing', '6 input edits', '6 bar selections', 'actual project download', 'fresh page project reopen', '6 restored inputs and computed tables');
|
||||
assert.equal(report.errors.length, 0, 'browser runtime errors');
|
||||
delete report.failure;
|
||||
delete report.timeoutPage;
|
||||
report.checks = [...new Set(report.checks)];
|
||||
report.result = 'PASS';
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
report.result = 'FAIL';
|
||||
report.failure = error.stack;
|
||||
process.exitCode = 1;
|
||||
}).finally(() => {
|
||||
report.finished = new Date().toISOString();
|
||||
fs.writeFileSync(path.join(out, 'report.json'), JSON.stringify(report, null, 2));
|
||||
console.log(JSON.stringify({ result: report.result, cards: report.cards.length, errors: report.errors, failure: report.failure }));
|
||||
ws?.close();
|
||||
});
|
||||
@@ -0,0 +1,140 @@
|
||||
// Actual app verification in an isolated Windows Chrome, CDP port 9444.
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const assert = require('node:assert/strict');
|
||||
const out = path.resolve(__dirname, '../.verify/cast-detail-20261105');
|
||||
fs.mkdirSync(out, { recursive: true });
|
||||
const report = { checks: [], errors: [] };
|
||||
const delay = ms => new Promise(r => setTimeout(r, ms));
|
||||
let ws, send;
|
||||
(async () => {
|
||||
const target = await (await fetch('http://127.0.0.1:9444/json/new?about:blank', { method: 'PUT' })).json();
|
||||
ws = new WebSocket(target.webSocketDebuggerUrl);
|
||||
await new Promise((r, j) => { ws.onopen = r; ws.onerror = j; });
|
||||
let seq = 0, download;
|
||||
const completed = new Set(), pending = new Map();
|
||||
ws.onmessage = ({ data }) => {
|
||||
const m = JSON.parse(data), p = pending.get(m.id);
|
||||
if (p) { clearTimeout(p.timer); pending.delete(m.id); m.error ? p.reject(new Error(JSON.stringify(m.error))) : p.resolve(m.result); }
|
||||
if (m.method === 'Runtime.exceptionThrown') report.errors.push(m.params.exceptionDetails.exception?.description || m.params.exceptionDetails.text);
|
||||
if (m.method === 'Browser.downloadWillBegin') download = m.params.guid;
|
||||
if (m.method === 'Browser.downloadProgress' && m.params.state === 'completed') completed.add(m.params.guid);
|
||||
};
|
||||
send = (method, params = {}) => new Promise((resolve, reject) => {
|
||||
const id = ++seq, timer = setTimeout(() => { pending.delete(id); reject(new Error(`Timeout ${method}`)); }, 25000);
|
||||
pending.set(id, { resolve, reject, timer }); ws.send(JSON.stringify({ id, method, params }));
|
||||
});
|
||||
const ev = async expression => {
|
||||
const r = await send('Runtime.evaluate', { expression, awaitPromise: true, returnByValue: true });
|
||||
if (r.exceptionDetails) throw new Error(r.exceptionDetails.exception?.description || r.exceptionDetails.text);
|
||||
return r.result.value;
|
||||
};
|
||||
const until = async expression => { for (let i = 0; i < 120; i++) { if (await ev(expression)) return; await delay(150); } throw new Error(`Wait failed: ${expression}`); };
|
||||
const click = expression => ev(`(() => { const e = ${expression}; if (!e || e.disabled) throw new Error('Missing/disabled target: ' + ${JSON.stringify(expression)}); e.click(); })()`);
|
||||
const check = (name, pass) => { assert.ok(pass, name); report.checks.push(name); console.log('PASS', name); };
|
||||
const file = async name => {
|
||||
const doc = await send('DOM.getDocument');
|
||||
const { nodeId } = await send('DOM.querySelector', { nodeId: doc.root.nodeId, selector: '#project-file' });
|
||||
await send('DOM.setFileInputFiles', { nodeId, files: [name] });
|
||||
};
|
||||
const shot = async name => { await delay(250); const r = await send('Page.captureScreenshot', { format: 'png' }); fs.writeFileSync(path.join(out, name + '.png'), Buffer.from(r.data, 'base64')); };
|
||||
const member = text => click(`[...document.querySelectorAll('#rebar-tabs button')].find(b => b.textContent.startsWith(${JSON.stringify(text)}))`);
|
||||
const select = (id, name) => click(`[...document.querySelectorAll('#${id} button')].find(b => b.textContent.startsWith(${JSON.stringify(name + ' ·')}))`);
|
||||
const set = (id, value) => ev(`document.getElementById(${JSON.stringify(id)}).value = ${JSON.stringify(String(value))}`);
|
||||
await send('Page.enable'); await send('Runtime.enable'); await send('DOM.enable');
|
||||
await send('Emulation.setDeviceMetricsOverride', { width: 1600, height: 1000, deviceScaleFactor: 1, mobile: false });
|
||||
const downloads = 'C:\\Users\\User\\AppData\\Local\\Temp\\pierzainer-cast-detail-20261105\\downloads';
|
||||
await send('Browser.setDownloadBehavior', { behavior: 'allowAndName', downloadPath: downloads, eventsEnabled: true });
|
||||
await send('Page.navigate', { url: 'http://127.0.0.1:5173/' });
|
||||
await until(`typeof window.openSampleProject === 'function'`);
|
||||
const fixture = await ev(`(async () => {
|
||||
const XLSX = await import('/node_modules/xlsx/xlsx.mjs');
|
||||
const {parseWorkbook} = await import('/src/core/parseWorkbook.ts');
|
||||
const b = await (await fetch('/samples/' + encodeURIComponent('운곡교P1입력.xlsx'))).arrayBuffer();
|
||||
const base = parseWorkbook(XLSX.read(b)).piers.find(p=>p.name==='P1');
|
||||
const piers=[];
|
||||
for (const [type,columnCount] of [['T형',1],['2주형',2],['3주형',3]]) for (const foundationType of ['현장타설말뚝','직접기초','말뚝기초']) {
|
||||
const input={...base,name:'P'+(piers.length+1),type,columnCount,foundationType};
|
||||
piers.push({id:input.name,input,defaults:input,draft:input});
|
||||
}
|
||||
return {schemaVersion:1,project:{name:'현장타설 입력 검증',bridge:'검증',client:'',author:'',crs:'',memo:''},source:{fileName:'합성 표본',sheets:[]},piers,history:[]};
|
||||
})()`);
|
||||
const fixturePath = path.join(out, 'fixture.pzr.json'); fs.writeFileSync(fixturePath, JSON.stringify(fixture));
|
||||
await ev('window.openSampleProject()');
|
||||
await click(`document.getElementById('open-project')`); await file(fixturePath);
|
||||
await until(`document.querySelectorAll('#plan-pier-items button').length === 9`);
|
||||
for (let i = 1; i <= 9; i++) {
|
||||
const cast = [1,4,7].includes(i);
|
||||
await click(`document.getElementById('nav-plan')`); await select('plan-pier-items', 'P'+i);
|
||||
await click(`[...document.querySelectorAll('#members button')].find(b => b.textContent.startsWith(${JSON.stringify(cast ? '현장타설말뚝' : '확대기초')}))`);
|
||||
check(`P${i} plan: appropriate foundation controls`, await ev(cast
|
||||
? `!!document.querySelector('#members button[disabled]') && !!document.querySelector('#member-fields #edit-pileLengthMm') && !document.querySelector('#member-fields #edit-footingHeightMm')`
|
||||
: `!!document.querySelector('#member-fields #edit-footingHeightMm:not([disabled])')`));
|
||||
await click(`document.getElementById('nav-rebar')`); await member('코핑');
|
||||
check(`P${i} entering rebar builds coping drawings immediately`, await ev(`!!document.querySelector('#rebar-table figure svg')`));
|
||||
await member('기둥');
|
||||
check(`P${i} rebar: dedicated or legacy column`, await ev(cast
|
||||
? `!!document.querySelector('[data-cast-part="column"]') && [...document.querySelectorAll('#rebar-tabs button')].find(b=>b.textContent.startsWith('확대기초')).disabled`
|
||||
: `!!document.getElementById('column-nDiv') && !document.querySelector('[data-cast-part]')`));
|
||||
}
|
||||
await click(`document.getElementById('nav-plan')`); await select('plan-pier-items','P1');
|
||||
await click(`[...document.querySelectorAll('#members button')].find(b=>b.textContent.startsWith('현장타설말뚝'))`);
|
||||
const ground = fixture.piers[0].input.groundEl, tip = ground-fixture.piers[0].input.pileLengthMm/1000;
|
||||
check('clarified rock EL offsets and fixed beta default appear in the shape card', await ev(`Number(document.getElementById('cip-shape-betaLengthMm').value)===9000 && Number(document.getElementById('cip-shape-weatheredRockEl').value)===${tip+5} && Number(document.getElementById('cip-shape-bedrockEl').value)===${tip+1}`));
|
||||
check('independent clay EL is initially blank and casing fields inactive', await ev(`document.getElementById('cip-shape-clayBottomEl').value==='' && document.getElementById('cip-shape-casingTopEl').disabled`));
|
||||
await click(`document.getElementById('cip-casing-on')`);
|
||||
await set('cip-shape-clayBottomEl',ground-5);
|
||||
await ev(`document.getElementById('cip-shape-clayBottomEl').dispatchEvent(new Event('change'))`);
|
||||
check('casing defaults follow ground plus 3 m and bedrock; independent clay persists', await ev(`!document.getElementById('cip-shape-casingTopEl').disabled && Number(document.getElementById('cip-shape-casingTopEl').value)===${ground+3} && Number(document.getElementById('cip-shape-casingBottomEl').value)===${tip+1} && Number(document.getElementById('cip-shape-clayBottomEl').value)===${ground-5}`));
|
||||
await click(`document.getElementById('member-regen')`); await shot('00-shape-decisions');
|
||||
await click(`document.getElementById('nav-rebar')`); await select('rebar-pier-items','P1'); await member('기둥');
|
||||
check('round-column two-layer defaults and coupler mode are populated', await ev(`document.getElementById('cip-column-mainCount').value==='46' && document.getElementById('cip-column-innerMainCount').value==='46' && document.getElementById('cip-column-coverReference').value==='main-center' && document.getElementById('cip-column-tieSpacingMm').value==='300' && document.getElementById('cip-connection-mode').value==='coupler'`));
|
||||
await member('현장타설말뚝');
|
||||
check('pile bottom cover defaults to 250 mm', await ev(`document.getElementById('cip-pile-endCoverMm').value==='250'`));
|
||||
await member('기둥');
|
||||
await set('cip-column-mainDiameterMm',32); await set('cip-column-mainCount',46); await set('cip-column-coverMm',100);
|
||||
await set('cip-connection-mode','continuous'); await click(`document.getElementById('cip-column-apply')`);
|
||||
await until(`document.getElementById('cip-column-mainCount')?.value === '46'`);
|
||||
await shot('01-column-input');
|
||||
await member('현장타설말뚝');
|
||||
await set('cip-pile-cageLengthMm',999999); await click(`document.getElementById('cip-pile-apply')`);
|
||||
check('invalid cage length rejected with inline reason', await ev(`document.querySelector('[data-cast-part="pile"]').textContent.includes('철근망 길이가 말뚝 길이보다')`));
|
||||
await set('cip-pile-cageLengthMm',12000); await set('cip-pile-mainCount',46); await set('cip-pile-tieKind','spiral');
|
||||
await click(`document.getElementById('cip-pile-apply')`);
|
||||
await until(`document.getElementById('cip-pile-cageLengthMm')?.value === '12000'`);
|
||||
check('pile panel shares the column connection', await ev(`document.querySelector('[data-cast-part="pile"]').textContent.includes('공통 연결 방식: 연속')`));
|
||||
await shot('02-pile-input');
|
||||
// ④ is no longer a header step (header ①②③, 2026-11-06): open the 재료표 from ③.
|
||||
await click(`document.getElementById('nav-rebar')`);
|
||||
await click(`[...document.querySelectorAll('button')].find(b=>b.textContent==='철근재료표 보기')`);
|
||||
check('quantity clearly marks incomplete design', await ev(`document.getElementById('quantity-body').textContent.includes('전체 수량 미완료') && document.getElementById('quantity-body').textContent.includes('접합부')`));
|
||||
await shot('03-quantity-incomplete');
|
||||
await click(`document.getElementById('save-project')`);
|
||||
if (await ev(`document.getElementById('save-dialog').open`)) await click(`document.getElementById('sv-save')`);
|
||||
for (let i=0;i<120 && !(download && completed.has(download));i++) await delay(150);
|
||||
assert.ok(download && completed.has(download), 'project download completes');
|
||||
const savedText = fs.readFileSync(path.join(downloads,download),'utf8');
|
||||
const saved = JSON.parse(savedText), dedicated = saved.piers.find(p=>p.input.name==='P1').input.castInPlaceRebar;
|
||||
const shape = saved.piers.find(p=>p.input.name==='P1').input.castInPlaceShape;
|
||||
check('project preserves explicit shape settings without freezing automatic rock ELs',shape.casingOn===true && shape.clayBottomEl===ground-5 && shape.bedrockEl===undefined);
|
||||
check('download preserves independent column, pile and connection', dedicated.column.mainCount===46 && dedicated.pile.cageLengthMm===12000 && dedicated.connection.mode==='continuous');
|
||||
const savedPath = path.join(out,'saved.pzr.json'); fs.writeFileSync(savedPath,savedText);
|
||||
await send('Page.navigate',{url:'http://127.0.0.1:5173/'});
|
||||
await until(`typeof window.openSampleProject === 'function'`); await ev('window.openSampleProject()');
|
||||
await click(`document.getElementById('open-project')`); await file(savedPath);
|
||||
await until(`document.querySelectorAll('#rebar-pier-items button').length===9`);
|
||||
await click(`document.getElementById('nav-rebar')`); await select('rebar-pier-items','P1'); await member('현장타설말뚝');
|
||||
check('fresh reopen restores pile values and shared connection', await ev(`document.getElementById('cip-pile-cageLengthMm').value==='12000' && document.querySelector('[data-cast-part="pile"]').textContent.includes('공통 연결 방식: 연속')`));
|
||||
await member('기둥');
|
||||
check('fresh reopen restores independent column values', await ev(`document.getElementById('cip-column-mainCount').value==='46'`));
|
||||
await click(`document.getElementById('nav-plan')`); await select('plan-pier-items','P1');
|
||||
await click(`[...document.querySelectorAll('#members button')].find(b=>b.textContent.startsWith('현장타설말뚝'))`);
|
||||
check('fresh reopen restores casing toggle and independent clay EL',await ev(`document.getElementById('cip-casing-on').checked && Number(document.getElementById('cip-shape-clayBottomEl').value)===${ground-5}`));
|
||||
check('no runtime exceptions in actual app',report.errors.length===0);
|
||||
report.result='PASS';
|
||||
})().catch(e=>{ report.result='FAIL'; report.failure=e.stack; process.exitCode=1; }).finally(async()=>{
|
||||
if(report.result==='FAIL' && send) try { const r=await send('Page.captureScreenshot',{format:'png'});fs.writeFileSync(path.join(out,'failure.png'),Buffer.from(r.data,'base64')); } catch {}
|
||||
fs.writeFileSync(path.join(out,'report.json'),JSON.stringify(report,null,2));
|
||||
console.log(JSON.stringify({result:report.result,checks:report.checks.length,errors:report.errors,failure:report.failure}));
|
||||
ws?.close();
|
||||
});
|
||||
@@ -0,0 +1,121 @@
|
||||
// Actual app verification in an isolated Windows Chrome, CDP port 9444.
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const assert = require('node:assert/strict');
|
||||
const out = path.resolve(__dirname, '../.verify/cast-input-20261105');
|
||||
fs.mkdirSync(out, { recursive: true });
|
||||
const report = { checks: [], errors: [] };
|
||||
const delay = ms => new Promise(r => setTimeout(r, ms));
|
||||
let ws, send;
|
||||
(async () => {
|
||||
const target = await (await fetch('http://127.0.0.1:9444/json/new?about:blank', { method: 'PUT' })).json();
|
||||
ws = new WebSocket(target.webSocketDebuggerUrl);
|
||||
await new Promise((r, j) => { ws.onopen = r; ws.onerror = j; });
|
||||
let seq = 0, download;
|
||||
const completed = new Set(), pending = new Map();
|
||||
ws.onmessage = ({ data }) => {
|
||||
const m = JSON.parse(data), p = pending.get(m.id);
|
||||
if (p) { clearTimeout(p.timer); pending.delete(m.id); m.error ? p.reject(new Error(JSON.stringify(m.error))) : p.resolve(m.result); }
|
||||
if (m.method === 'Runtime.exceptionThrown') report.errors.push(m.params.exceptionDetails.exception?.description || m.params.exceptionDetails.text);
|
||||
if (m.method === 'Browser.downloadWillBegin') download = m.params.guid;
|
||||
if (m.method === 'Browser.downloadProgress' && m.params.state === 'completed') completed.add(m.params.guid);
|
||||
};
|
||||
send = (method, params = {}) => new Promise((resolve, reject) => {
|
||||
const id = ++seq, timer = setTimeout(() => { pending.delete(id); reject(new Error(`Timeout ${method}`)); }, 25000);
|
||||
pending.set(id, { resolve, reject, timer }); ws.send(JSON.stringify({ id, method, params }));
|
||||
});
|
||||
const ev = async expression => {
|
||||
const r = await send('Runtime.evaluate', { expression, awaitPromise: true, returnByValue: true });
|
||||
if (r.exceptionDetails) throw new Error(r.exceptionDetails.exception?.description || r.exceptionDetails.text);
|
||||
return r.result.value;
|
||||
};
|
||||
const until = async expression => { for (let i = 0; i < 120; i++) { if (await ev(expression)) return; await delay(150); } throw new Error(`Wait failed: ${expression}`); };
|
||||
const click = expression => ev(`(() => { const e = ${expression}; if (!e || e.disabled) throw new Error('Missing/disabled target: ' + ${JSON.stringify(expression)}); e.click(); })()`);
|
||||
const check = (name, pass) => { assert.ok(pass, name); report.checks.push(name); console.log('PASS', name); };
|
||||
const file = async name => {
|
||||
const doc = await send('DOM.getDocument');
|
||||
const { nodeId } = await send('DOM.querySelector', { nodeId: doc.root.nodeId, selector: '#project-file' });
|
||||
await send('DOM.setFileInputFiles', { nodeId, files: [name] });
|
||||
};
|
||||
const shot = async name => { await delay(250); const r = await send('Page.captureScreenshot', { format: 'png' }); fs.writeFileSync(path.join(out, name + '.png'), Buffer.from(r.data, 'base64')); };
|
||||
const member = text => click(`[...document.querySelectorAll('#rebar-tabs button')].find(b => b.textContent.startsWith(${JSON.stringify(text)}))`);
|
||||
const select = (id, name) => click(`[...document.querySelectorAll('#${id} button')].find(b => b.textContent.startsWith(${JSON.stringify(name + ' ·')}))`);
|
||||
const set = (id, value) => ev(`document.getElementById(${JSON.stringify(id)}).value = ${JSON.stringify(String(value))}`);
|
||||
await send('Page.enable'); await send('Runtime.enable'); await send('DOM.enable');
|
||||
await send('Emulation.setDeviceMetricsOverride', { width: 1600, height: 1000, deviceScaleFactor: 1, mobile: false });
|
||||
const downloads = 'C:\\Users\\User\\AppData\\Local\\Temp\\pierzainer-cast-input-20261105\\downloads';
|
||||
await send('Browser.setDownloadBehavior', { behavior: 'allowAndName', downloadPath: downloads, eventsEnabled: true });
|
||||
await send('Page.navigate', { url: 'http://127.0.0.1:5173/' });
|
||||
await until(`typeof window.openSampleProject === 'function'`);
|
||||
const fixture = await ev(`(async () => {
|
||||
const XLSX = await import('/node_modules/xlsx/xlsx.mjs');
|
||||
const {parseWorkbook} = await import('/src/core/parseWorkbook.ts');
|
||||
const b = await (await fetch('/samples/' + encodeURIComponent('운곡교P1입력.xlsx'))).arrayBuffer();
|
||||
const base = parseWorkbook(XLSX.read(b)).piers.find(p=>p.name==='P1');
|
||||
const piers=[];
|
||||
for (const [type,columnCount] of [['T형',1],['2주형',2],['3주형',3]]) for (const foundationType of ['현장타설말뚝','직접기초','말뚝기초']) {
|
||||
const input={...base,name:'P'+(piers.length+1),type,columnCount,foundationType};
|
||||
piers.push({id:input.name,input,defaults:input,draft:input});
|
||||
}
|
||||
return {schemaVersion:1,project:{name:'현장타설 입력 검증',bridge:'검증',client:'',author:'',crs:'',memo:''},source:{fileName:'합성 표본',sheets:[]},piers,history:[]};
|
||||
})()`);
|
||||
const fixturePath = path.join(out, 'fixture.pzr.json'); fs.writeFileSync(fixturePath, JSON.stringify(fixture));
|
||||
await ev('window.openSampleProject()');
|
||||
await click(`document.getElementById('open-project')`); await file(fixturePath);
|
||||
await until(`document.querySelectorAll('#plan-pier-items button').length === 9`);
|
||||
for (let i = 1; i <= 9; i++) {
|
||||
const cast = [1,4,7].includes(i);
|
||||
await click(`document.getElementById('nav-plan')`); await select('plan-pier-items', 'P'+i);
|
||||
await click(`[...document.querySelectorAll('#members button')].find(b => b.textContent.startsWith(${JSON.stringify(cast ? '현장타설말뚝' : '확대기초')}))`);
|
||||
check(`P${i} plan: appropriate foundation controls`, await ev(cast
|
||||
? `!!document.querySelector('#members button[disabled]') && !!document.querySelector('#member-fields #edit-pileLengthMm') && !document.querySelector('#member-fields #edit-footingHeightMm')`
|
||||
: `!!document.querySelector('#member-fields #edit-footingHeightMm:not([disabled])')`));
|
||||
await click(`document.getElementById('nav-rebar')`); await member('코핑');
|
||||
check(`P${i} entering rebar builds coping drawings immediately`, await ev(`!!document.querySelector('#rebar-table figure svg')`));
|
||||
await member('기둥');
|
||||
check(`P${i} rebar: dedicated or legacy column`, await ev(cast
|
||||
? `!!document.querySelector('[data-cast-part="column"]') && [...document.querySelectorAll('#rebar-tabs button')].find(b=>b.textContent.startsWith('확대기초')).disabled`
|
||||
: `!!document.getElementById('column-nDiv') && !document.querySelector('[data-cast-part]')`));
|
||||
}
|
||||
await select('rebar-pier-items','P1'); await member('기둥');
|
||||
await set('cip-column-mainDiameterMm',32); await set('cip-column-mainCount',46); await set('cip-column-coverMm',100);
|
||||
await set('cip-connection-mode','continuous'); await click(`document.getElementById('cip-column-apply')`);
|
||||
await until(`document.getElementById('cip-column-mainCount')?.value === '46'`);
|
||||
await shot('01-column-input');
|
||||
await member('현장타설말뚝');
|
||||
await set('cip-pile-cageLengthMm',999999); await click(`document.getElementById('cip-pile-apply')`);
|
||||
check('invalid cage length rejected with inline reason', await ev(`document.querySelector('[data-cast-part="pile"]').textContent.includes('철근망 길이가 말뚝 길이보다')`));
|
||||
await set('cip-pile-cageLengthMm',12000); await set('cip-pile-mainCount',46); await set('cip-pile-tieKind','spiral');
|
||||
await click(`document.getElementById('cip-pile-apply')`);
|
||||
await until(`document.getElementById('cip-pile-cageLengthMm')?.value === '12000'`);
|
||||
check('pile panel shares the column connection', await ev(`document.querySelector('[data-cast-part="pile"]').textContent.includes('공통 연결 방식: 연속')`));
|
||||
await shot('02-pile-input');
|
||||
// ④ is no longer a header step (header ①②③, 2026-11-06): open the 재료표 from ③.
|
||||
await click(`document.getElementById('nav-rebar')`);
|
||||
await click(`[...document.querySelectorAll('button')].find(b=>b.textContent==='철근재료표 보기')`);
|
||||
check('quantity clearly marks incomplete design', await ev(`document.getElementById('quantity-body').textContent.includes('전체 수량 미완료') && document.getElementById('quantity-body').textContent.includes('접합부')`));
|
||||
await shot('03-quantity-incomplete');
|
||||
await click(`document.getElementById('save-project')`);
|
||||
if (await ev(`document.getElementById('save-dialog').open`)) await click(`document.getElementById('sv-save')`);
|
||||
for (let i=0;i<120 && !(download && completed.has(download));i++) await delay(150);
|
||||
assert.ok(download && completed.has(download), 'project download completes');
|
||||
const savedText = fs.readFileSync(path.join(downloads,download),'utf8');
|
||||
const saved = JSON.parse(savedText), dedicated = saved.piers.find(p=>p.input.name==='P1').input.castInPlaceRebar;
|
||||
check('download preserves independent column, pile and connection', dedicated.column.mainCount===46 && dedicated.pile.cageLengthMm===12000 && dedicated.connection.mode==='continuous');
|
||||
const savedPath = path.join(out,'saved.pzr.json'); fs.writeFileSync(savedPath,savedText);
|
||||
await send('Page.navigate',{url:'http://127.0.0.1:5173/'});
|
||||
await until(`typeof window.openSampleProject === 'function'`); await ev('window.openSampleProject()');
|
||||
await click(`document.getElementById('open-project')`); await file(savedPath);
|
||||
await until(`document.querySelectorAll('#rebar-pier-items button').length===9`);
|
||||
await click(`document.getElementById('nav-rebar')`); await select('rebar-pier-items','P1'); await member('현장타설말뚝');
|
||||
check('fresh reopen restores pile values and shared connection', await ev(`document.getElementById('cip-pile-cageLengthMm').value==='12000' && document.querySelector('[data-cast-part="pile"]').textContent.includes('공통 연결 방식: 연속')`));
|
||||
await member('기둥');
|
||||
check('fresh reopen restores independent column values', await ev(`document.getElementById('cip-column-mainCount').value==='46'`));
|
||||
check('no runtime exceptions in actual app',report.errors.length===0);
|
||||
report.result='PASS';
|
||||
})().catch(e=>{ report.result='FAIL'; report.failure=e.stack; process.exitCode=1; }).finally(async()=>{
|
||||
if(report.result==='FAIL' && send) try { const r=await send('Page.captureScreenshot',{format:'png'});fs.writeFileSync(path.join(out,'failure.png'),Buffer.from(r.data,'base64')); } catch {}
|
||||
fs.writeFileSync(path.join(out,'report.json'),JSON.stringify(report,null,2));
|
||||
console.log(JSON.stringify({result:report.result,checks:report.checks.length,errors:report.errors,failure:report.failure}));
|
||||
ws?.close();
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
// Actual app: isolated Windows Chrome on CDP 9444, Vite on 5173. Run with Windows Node.
|
||||
const fs=require('node:fs'), path=require('node:path'), assert=require('node:assert/strict');
|
||||
const out=path.resolve(__dirname,process.argv[2]||'../.verify/cast-rebar-20261106'); fs.mkdirSync(out,{recursive:true});
|
||||
const report={checks:[],errors:[],started:new Date().toISOString()};
|
||||
const delay=ms=>new Promise(r=>setTimeout(r,ms)); let ws,send;
|
||||
(async()=>{
|
||||
const target=await(await fetch('http://127.0.0.1:9444/json/new?about:blank',{method:'PUT'})).json();
|
||||
ws=new WebSocket(target.webSocketDebuggerUrl); await new Promise((r,j)=>{ws.onopen=r;ws.onerror=j;});
|
||||
let seq=0,download; const pending=new Map(), completed=new Set();
|
||||
ws.onmessage=({data})=>{const m=JSON.parse(data),p=pending.get(m.id);if(p){clearTimeout(p.timer);pending.delete(m.id);m.error?p.reject(new Error(JSON.stringify(m.error))):p.resolve(m.result);}
|
||||
if(m.method==='Runtime.exceptionThrown')report.errors.push(m.params.exceptionDetails.exception?.description||m.params.exceptionDetails.text);
|
||||
if(m.method==='Browser.downloadWillBegin')download=m.params.guid;
|
||||
if(m.method==='Browser.downloadProgress'&&m.params.state==='completed')completed.add(m.params.guid);
|
||||
if(m.method==='Page.javascriptDialogOpening'&&m.params.type==='beforeunload')send('Page.handleJavaScriptDialog',{accept:true}).catch(e=>report.errors.push(e.message));
|
||||
};
|
||||
send=(method,params={})=>new Promise((resolve,reject)=>{const id=++seq,timer=setTimeout(()=>{pending.delete(id);reject(new Error('Timeout '+method));},30000);pending.set(id,{resolve,reject,timer});ws.send(JSON.stringify({id,method,params}));});
|
||||
const ev=async expression=>{const r=await send('Runtime.evaluate',{expression,awaitPromise:true,returnByValue:true});if(r.exceptionDetails)throw new Error(r.exceptionDetails.exception?.description||r.exceptionDetails.text);return r.result.value;};
|
||||
const until=async expression=>{for(let i=0;i<120;i++){if(await ev(expression))return;await delay(150);}throw new Error('Wait: '+expression);};
|
||||
const click=expression=>ev(`(()=>{const e=${expression};if(!e||e.disabled)throw new Error('Missing/disabled target');e.click();})()`);
|
||||
const set=(id,value)=>ev(`(()=>{const e=document.getElementById(${JSON.stringify(id)});e.value=${JSON.stringify(String(value))};e.dispatchEvent(new Event(e.tagName==='SELECT'?'change':'input',{bubbles:true}));})()`);
|
||||
const check=(name,value)=>{assert.ok(value,name);report.checks.push(name);console.log('PASS',name);};
|
||||
const shot=async name=>{await delay(250);const r=await send('Page.captureScreenshot',{format:'png'});fs.writeFileSync(path.join(out,name+'.png'),Buffer.from(r.data,'base64'));};
|
||||
const open=async file=>{await click(`document.querySelector('#file-menu summary')`);await click(`document.getElementById('open-project')`);const doc=await send('DOM.getDocument');const{nodeId}=await send('DOM.querySelector',{nodeId:doc.root.nodeId,selector:'#project-file'});await send('DOM.setFileInputFiles',{nodeId,files:[file]});};
|
||||
const pier=name=>click(`[...document.querySelectorAll('#rebar-pier-items button')].find(b=>b.textContent.startsWith(${JSON.stringify(name+' ·')}))`);
|
||||
const member=text=>click(`[...document.querySelectorAll('#rebar-tabs button')].find(b=>b.textContent.startsWith(${JSON.stringify(text)}))`);
|
||||
const table=`[...document.querySelectorAll('#rebar-table table.rebar-table tbody tr')].filter(r=>r.cells.length===14).map(r=>({name:r.cells[2].textContent,size:r.cells[3].textContent,grade:r.cells[4].textContent,count:Number(r.cells[11].textContent)}))`;
|
||||
const downloads=path.join(out,'downloads');fs.mkdirSync(downloads,{recursive:true});
|
||||
await send('Page.enable');await send('Runtime.enable');await send('DOM.enable');
|
||||
await send('Browser.setDownloadBehavior',{behavior:'allowAndName',downloadPath:downloads,eventsEnabled:true});
|
||||
await send('Emulation.setDeviceMetricsOverride',{width:1600,height:1000,deviceScaleFactor:1,mobile:false});
|
||||
await send('Page.navigate',{url:'http://127.0.0.1:5173/'});await until(`typeof window.openSampleProject==='function'`);
|
||||
const fixture=await ev(`(async()=>{
|
||||
const XLSX=await import('/node_modules/xlsx/xlsx.mjs'),{parseWorkbook}=await import('/src/core/parseWorkbook.ts');
|
||||
const bytes=await(await fetch('/samples/'+encodeURIComponent('운곡교P1입력.xlsx'))).arrayBuffer();
|
||||
const base=parseWorkbook(XLSX.read(bytes)).piers.find(p=>p.name==='P1'),piers=[];
|
||||
for(const[type,columnCount]of[['T형',1],['2주형',2],['3주형',3]])for(const foundationType of ['현장타설말뚝','직접기초','말뚝기초']){
|
||||
const input={...base,name:'P'+(piers.length+1),type,columnCount,foundationType,groundEl:30,pileLengthMm:24000,
|
||||
...(foundationType==='현장타설말뚝'?{castInPlaceShape:{clayBottomEl:16},castInPlaceRebar:{column:{endZoneLengthMm:2000}}}:{})};
|
||||
piers.push({id:input.name,input,defaults:input,draft:input});
|
||||
}return{schemaVersion:1,project:{name:'현장타설 실배근 검증',bridge:'검증',client:'',author:'',crs:'',memo:''},source:{fileName:'9개 합성 표본',sheets:[]},piers,history:[]};
|
||||
})()`);
|
||||
const file=path.join(out,'fixture.pzr.json');fs.writeFileSync(file,JSON.stringify(fixture));
|
||||
await ev('window.openSampleProject()');await open(file);await until(`document.querySelectorAll('#rebar-pier-items button').length===9`);
|
||||
await click(`document.getElementById('nav-rebar')`);
|
||||
for(let i=1;i<=9;i++){
|
||||
await pier('P'+i);await member('기둥');const cast=[1,4,7].includes(i);
|
||||
const rows=await ev(table);
|
||||
check(`P${i}: appropriate real main and tie rows`,cast
|
||||
? ['기둥-P1','기둥-P2','말뚝-P1','말뚝-P2','기둥-P3','말뚝-P3','기둥-P5','말뚝-P5','접합-P1 커플러'].every(n=>rows.some(r=>r.name===n&&r.count>0))
|
||||
: ['P1','P2','P3','P5'].every(n=>rows.some(r=>r.name===n&&r.count>0)));
|
||||
check(`P${i}: list reports actual support status`,await ev(`[...document.querySelectorAll('#rebar-pier-items button')].find(b=>b.textContent.startsWith('P${i} ·')).parentElement.textContent.includes('${cast||i>=4?'일부 계산':'계산됨'}')`));
|
||||
}
|
||||
for(const i of [4,5,6,7,8,9]){
|
||||
await pier('P'+i);await member('코핑');const rows=await ev(table);
|
||||
check(`P${i}: multi drawing diameters reach actual table`,[['C1','H25'],['C7','H29'],['C8','H25'],['C10','H22'],['C12','H16'],['C14','H16']].every(([name,size])=>rows.some(r=>r.name===name&&r.size===size&&r.count>0)));
|
||||
check(`P${i}: multi defaults and remaining detail notice are visible`,await ev(`document.getElementById('coping-spacingS').value==='300'&&document.getElementById('coping-sideSpacing').value==='200'&&document.getElementById('coping-c14UpperSpacing').value==='600'&&document.getElementById('rebar-issues').textContent.includes('cycle별 이음')`));
|
||||
}
|
||||
await shot('00-multi-coping');
|
||||
await pier('P1');await member('기둥');
|
||||
check('dedicated drawing uses generated front and section paths',await ev(`!!document.querySelector('[data-cast-part] figure svg polyline') && document.querySelectorAll('[data-cast-part] figure svg circle').length>50`));
|
||||
check('missing bottom P6 remains explicit rather than hiding all reinforcement',await ev(`document.getElementById('rebar-issues').textContent.includes('말뚝 저부 P6 검토안 미적용')`));
|
||||
await set('review-model-height',420);await shot('01-real-rebar');
|
||||
const baseline=await ev(table);
|
||||
await set('cip-common-mainCount',50);await set('cip-common-mainDiameterMm',35.8);await set('cip-common-mainGradeMpa',400);await click(`document.getElementById('cip-apply')`);
|
||||
const edited=await ev(table);
|
||||
check('shared edits regenerate physical piece counts, diameters and grades',edited.filter(r=>r.name.endsWith('-P1')).every(r=>r.count%50===0&&r.count>=50&&r.size==='Ø35.8'&&r.grade==='SD400'));
|
||||
check('regeneration changes actual quantities rather than just form values',JSON.stringify(edited)!==JSON.stringify(baseline));
|
||||
await set('cip-pile-tieKind','spiral');await click(`document.getElementById('cip-apply')`);
|
||||
check('spiral produces its own real table rows', (await ev(table)).some(r=>r.name==='말뚝-P3 나선'&&r.count>0));
|
||||
await set('cip-pile-mainCount',48);await click(`document.getElementById('cip-apply')`);
|
||||
check('mismatched joints remove interface couplers and report the mismatch',(await ev(table)).every(r=>!r.name.startsWith('접합-'))&&await ev(`document.getElementById('rebar-issues').textContent.includes('불일치')`));
|
||||
await set('cip-pile-mainCount',50);await click(`document.getElementById('cip-apply')`);
|
||||
await click(`[...document.querySelectorAll('#rebar-table table.rebar-table tbody tr')].find(r=>r.cells[2].textContent==='말뚝-P1')`);
|
||||
check('pile table selection highlights the corresponding generated mark',await ev(`[...document.querySelectorAll('#rebar-table tr.hl')].some(r=>r.cells[2].textContent==='말뚝-P1')`));
|
||||
await click(`[...document.querySelectorAll('#rebar-table button')].find(b=>b.textContent==='선택만 보기')`);
|
||||
await shot('02-pile-main-selected');
|
||||
await click(`[...document.querySelectorAll('#rebar-table table.rebar-table tbody tr')].find(r=>r.cells[2].textContent==='말뚝-P1')`);
|
||||
await click(`[...document.querySelectorAll('#rebar-table button')].find(b=>b.textContent==='선택만 보기')`);
|
||||
await ev(`(()=>{const d=[...document.querySelectorAll('[data-cast-part] details')].find(d=>d.querySelector('summary').textContent.includes('삽도'));d.open=true;d.scrollIntoView({block:'start'});})()`);await shot('03-drawing');
|
||||
for(const width of [1280,1600,1920]){
|
||||
await send('Emulation.setDeviceMetricsOverride',{width,height:1000,deviceScaleFactor:1,mobile:false});await delay(150);
|
||||
check(`header ${width} keeps one row and no horizontal overflow`,await ev(`document.querySelector('.top').getBoundingClientRect().height<=65&&document.documentElement.scrollWidth<=innerWidth`));
|
||||
}
|
||||
// ④ is no longer a header step (header ①②③, 2026-11-06): open the 재료표 from ③.
|
||||
await click(`document.getElementById('nav-rebar')`);
|
||||
await click(`[...document.querySelectorAll('button')].find(b=>b.textContent==='철근재료표 보기')`);
|
||||
check('quantity contains actual dedicated mains and reports remaining incomplete details',await ev(`document.getElementById('quantity-body').textContent.includes('말뚝-P1')&&document.getElementById('quantity-body').textContent.includes('기둥-P1')&&document.getElementById('quantity-body').textContent.includes('전체 수량 미완료')`));
|
||||
await shot('04-quantity');
|
||||
await click(`document.getElementById('nav-rebar')`);await member('기둥');const beforeSave=await ev(table);
|
||||
await click(`document.getElementById('save-project')`);if(await ev(`document.getElementById('save-dialog').open`))await click(`document.getElementById('sv-save')`);
|
||||
for(let i=0;i<120&&!(download&&completed.has(download));i++)await delay(150);assert.ok(download&&completed.has(download),'download completes');
|
||||
const text=fs.readFileSync(path.join(downloads,download),'utf8'),savedPath=path.join(out,'saved.pzr.json');fs.writeFileSync(savedPath,text);
|
||||
const saved=JSON.parse(text).piers.find(p=>p.input.name==='P1').input.castInPlaceRebar;
|
||||
check('download preserves generating inputs',saved.column.mainCount===50&&saved.pile.mainCount===50&&saved.pile.tieKind==='spiral'&&saved.column.mainGradeMpa===400);
|
||||
await send('Page.navigate',{url:'http://127.0.0.1:5173/'});await until(`typeof window.openSampleProject==='function'`);await ev('window.openSampleProject()');await open(savedPath);await until(`document.querySelectorAll('#rebar-pier-items button').length===9`);
|
||||
await click(`document.getElementById('nav-rebar')`);await pier('P1');await member('기둥');
|
||||
check('fresh-page restore regenerates identical physical bar rows',JSON.stringify(await ev(table))===JSON.stringify(beforeSave));
|
||||
check('no runtime exceptions',report.errors.length===0);report.result='PASS';
|
||||
})().catch(e=>{report.result='FAIL';report.failure=e.stack;process.exitCode=1;}).finally(async()=>{
|
||||
if(report.result==='FAIL'&&send)try{const r=await send('Page.captureScreenshot',{format:'png'});fs.writeFileSync(path.join(out,'failure.png'),Buffer.from(r.data,'base64'));}catch{}
|
||||
fs.writeFileSync(path.join(out,'report.json'),JSON.stringify(report,null,2));console.log(JSON.stringify({result:report.result,checks:report.checks.length,failure:report.failure}));ws?.close();
|
||||
});
|
||||
@@ -0,0 +1,130 @@
|
||||
// CDP regression checks for the four scoped design improvements. Run with Windows Node.
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const assert = require('node:assert/strict');
|
||||
const root = path.resolve(__dirname, '..');
|
||||
const out = path.join(root, '.verify', 'design-fixes-20261105');
|
||||
const report = { checks: [], errors: [], captures: [], started: new Date().toISOString() };
|
||||
fs.mkdirSync(out, { recursive: true });
|
||||
let ws;
|
||||
let send;
|
||||
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
async function main() {
|
||||
const target = await (await fetch('http://127.0.0.1:9444/json/new?http://127.0.0.1:5173/', { method: 'PUT' })).json();
|
||||
ws = new WebSocket(target.webSocketDebuggerUrl);
|
||||
await new Promise((resolve, reject) => { ws.onopen = resolve; ws.onerror = reject; });
|
||||
let seq = 0;
|
||||
const pending = new Map();
|
||||
send = (method, params = {}) => new Promise((resolve, reject) => {
|
||||
const id = ++seq;
|
||||
const timer = setTimeout(() => { pending.delete(id); reject(new Error(`CDP timeout ${method}`)); }, 20000);
|
||||
pending.set(id, { resolve, reject, timer });
|
||||
ws.send(JSON.stringify({ id, method, params }));
|
||||
});
|
||||
ws.onmessage = ({ data }) => {
|
||||
const msg = JSON.parse(data);
|
||||
if (msg.id) {
|
||||
const wait = pending.get(msg.id);
|
||||
if (!wait) return;
|
||||
clearTimeout(wait.timer); pending.delete(msg.id);
|
||||
if (msg.error) wait.reject(new Error(JSON.stringify(msg.error))); else wait.resolve(msg.result);
|
||||
} else if (msg.method === 'Runtime.exceptionThrown') report.errors.push(msg.params.exceptionDetails.exception?.description || msg.params.exceptionDetails.text);
|
||||
else if (msg.method === 'Runtime.consoleAPICalled' && msg.params.type === 'error') report.errors.push(msg.params.args.map((a) => a.value ?? a.description).join(' '));
|
||||
};
|
||||
const ev = async (expression) => {
|
||||
const r = await send('Runtime.evaluate', { expression, awaitPromise: true, returnByValue: true });
|
||||
if (r.exceptionDetails) throw new Error(r.exceptionDetails.exception?.description || r.exceptionDetails.text);
|
||||
return r.result.value;
|
||||
};
|
||||
const until = async (expression) => {
|
||||
for (let i = 0; i < 100; i++) { if (await ev(expression)) return; await delay(150); }
|
||||
throw new Error(`Timeout: ${expression}`);
|
||||
};
|
||||
const click = (expression) => ev(`(() => { const e = ${expression}; if (!e || e.disabled) throw new Error('Missing/disabled control'); e.click(); })()`);
|
||||
const shot = async (name) => {
|
||||
await delay(200);
|
||||
const r = await send('Page.captureScreenshot', { format: 'png' });
|
||||
fs.writeFileSync(path.join(out, `${name}.png`), Buffer.from(r.data, 'base64'));
|
||||
report.captures.push(name);
|
||||
};
|
||||
const check = (name, condition) => { assert.ok(condition, name); report.checks.push(name); console.log('PASS', name); };
|
||||
const upload = async () => {
|
||||
const doc = await send('DOM.getDocument');
|
||||
const node = await send('DOM.querySelector', { nodeId: doc.root.nodeId, selector: '#file' });
|
||||
await send('DOM.setFileInputFiles', { nodeId: node.nodeId, files: [] });
|
||||
await send('DOM.setFileInputFiles', { nodeId: node.nodeId, files: [path.resolve(root, '../../RAG/운곡교P1입력.xlsx')] });
|
||||
await until(`!document.getElementById('dlg-apply').disabled && document.getElementById('import-abut-A1')?.checked === true`);
|
||||
};
|
||||
const member = async (label) => { await click(`[...document.querySelectorAll('#rebar-tabs button')].find(b => b.textContent.startsWith(${JSON.stringify(label)}))`); await delay(100); };
|
||||
const lower = () => ev(`document.querySelector('#review-workspace > section').getBoundingClientRect().height`);
|
||||
await send('Page.enable'); await send('Runtime.enable'); await send('DOM.enable');
|
||||
await send('Emulation.setDeviceMetricsOverride', { width: 1600, height: 1000, deviceScaleFactor: 1, mobile: false });
|
||||
await until(`!!document.querySelector('[data-card="excel"] button') && typeof window.loadBytesToApp === 'function'`);
|
||||
await click(`document.querySelector('[data-card="excel"] button')`);
|
||||
await ev(`document.getElementById('project-name').value = '디자인 개선 검증'; document.getElementById('project-name').dispatchEvent(new Event('input'));`);
|
||||
await upload();
|
||||
check('combined import scope', await ev(`document.getElementById('dlg-apply').textContent === '교각 1기 + 교대 1기 가져오기'`));
|
||||
check('A1 source and dimensions preview', await ev(`document.getElementById('pv-abuts').textContent.includes('A1') && document.getElementById('pv-abuts').textContent.includes('12.197') && document.querySelector('#sheets > li:first-child').textContent.includes('교대 인식 결과')`));
|
||||
await shot('01-import-both');
|
||||
await click(`document.getElementById('import-abut-A1')`);
|
||||
check('uncheck updates preview and count', await ev(`document.getElementById('dlg-apply').textContent.includes('교대 0기') && document.getElementById('pv-abuts').textContent.includes('아니오')`));
|
||||
await click(`document.getElementById('dlg-apply')`);
|
||||
check('unchecked abutment is not applied', await ev(`![...document.querySelectorAll('#pier-items button')].some(b => b.textContent.includes('교대'))`));
|
||||
await click(`document.getElementById('open-import')`);
|
||||
await upload();
|
||||
await click(`document.getElementById('dlg-apply')`);
|
||||
await click(`document.getElementById('nav-rebar')`);
|
||||
await click(`[...document.querySelectorAll('#rebar-pier-items button')].find(b => b.textContent.includes('A1') && b.textContent.includes('교대'))`);
|
||||
await member('확대기초');
|
||||
check('A1 footing excludes pier review questions', await ev(`document.getElementById('review-summary').textContent.includes('A1') && document.getElementById('review-summary').textContent.includes('미결 0건') && !document.getElementById('review-tasks').textContent.includes('Q-C9')`));
|
||||
await delay(4500); await shot('02-workspace-desktop');
|
||||
await send('Emulation.setDeviceMetricsOverride', { width: 1280, height: 800, deviceScaleFactor: 1, mobile: false });
|
||||
await delay(300);
|
||||
if (await ev(`document.getElementById('review-model-toggle').getAttribute('aria-expanded') === 'false'`)) await click(`document.getElementById('review-model-toggle')`);
|
||||
await ev(`const size = document.getElementById('review-model-height'); size.value = size.max; size.dispatchEvent(new Event('input'));`);
|
||||
await delay(200);
|
||||
report.layoutBefore = await lower();
|
||||
await ev(`const range = document.getElementById('review-model-height'); range.value = '180'; range.dispatchEvent(new Event('input'));`);
|
||||
await delay(200);
|
||||
report.layoutResized = await lower();
|
||||
check('height control expands evidence area', report.layoutResized >= report.layoutBefore);
|
||||
check('compact viewer tools stay inside model', await ev(`document.querySelector('.viewer-stack-right').getBoundingClientRect().bottom <= document.getElementById('viewer-block').getBoundingClientRect().bottom + 1`));
|
||||
await click(`document.getElementById('review-model-toggle')`);
|
||||
await delay(200);
|
||||
report.layoutCollapsed = await lower();
|
||||
check('collapse adds model area to evidence', report.layoutCollapsed > report.layoutResized + 150);
|
||||
await click(`document.getElementById('review-table')`);
|
||||
check('table shortcut focuses and reveals table', await ev(`document.activeElement.matches('#rebar-table table.rebar-table') && document.activeElement.getBoundingClientRect().top >= document.querySelector('#review-workspace > section').getBoundingClientRect().top - 2`));
|
||||
await shot('03-table-laptop');
|
||||
await click(`document.getElementById('review-inputs')`);
|
||||
check('input shortcut preserves keyboard tab order', await ev(`document.activeElement.matches('#rebar-table input') && document.activeElement.tabIndex === 0`));
|
||||
await shot('04-inputs-laptop');
|
||||
check('layout preference persisted', await ev(`JSON.parse(localStorage.getItem('pierzainer.review-layout')).collapsed === true`));
|
||||
await click(`document.getElementById('review-model-toggle')`);
|
||||
await member('받침블럭');
|
||||
check('block review is scoped', await ev(`document.getElementById('review-tasks').textContent.includes('Q-S4') && !document.getElementById('review-tasks').textContent.includes('Q-C9')`));
|
||||
await click(`document.querySelector('#review-tasks button')`);
|
||||
check('review action selects correct bar and row', await ev(`document.querySelector('#rebar-table .rebar-select').textContent.includes('선택 1 /') && document.activeElement.matches('tr') && document.activeElement.textContent.includes('S1')`));
|
||||
await member('날개벽');
|
||||
check('wing review shows only relevant unresolved work', await ev(`document.getElementById('review-summary').textContent.includes('미결 2건') && document.getElementById('review-tasks').textContent.includes('E19') && document.getElementById('review-tasks').textContent.includes('V-A2')`));
|
||||
await shot('05-review-and-materials');
|
||||
report.fields = await ev(`[...document.querySelectorAll('.material-fields .mat')].map(label => { const r = label.getBoundingClientRect(); const c = label.querySelector('input,select').getBoundingClientRect(); return { label: label.textContent, left: r.left, top: r.top, width: r.width, controlLeft: c.left, controlRight: c.right, bottom: r.bottom }; })`);
|
||||
check('material fields align without overlap', report.fields.every((f, i, list) => f.controlRight <= f.left + f.width + 1 && (i === 0 || f.top >= list[i - 1].bottom)));
|
||||
check('document has no horizontal overflow at laptop width', await ev('document.documentElement.scrollWidth === innerWidth'));
|
||||
await send('Emulation.setDeviceMetricsOverride', { width: 1600, height: 1000, deviceScaleFactor: 1, mobile: false });
|
||||
await click(`[...document.querySelectorAll('#rebar-pier-items button')].find(b => b.textContent.includes('P1') && !b.textContent.includes('교대'))`);
|
||||
await member('기둥');
|
||||
check('existing decision is not presented as unresolved', await ev(`document.getElementById('review-tasks').textContent.includes('Q-C9') && document.getElementById('review-tasks').textContent.includes('결정됨') && document.getElementById('review-summary').textContent.includes('미결 0건')`));
|
||||
check('no runtime errors', report.errors.length === 0);
|
||||
report.result = 'PASS';
|
||||
}
|
||||
main().catch((error) => { report.result = 'FAIL'; report.failure = error.stack; process.exitCode = 1; }).finally(async () => {
|
||||
if (report.result === 'FAIL' && send) {
|
||||
try { const shot = await send('Page.captureScreenshot', { format: 'png' }); fs.writeFileSync(path.join(out, 'failure.png'), Buffer.from(shot.data, 'base64')); } catch { /* preserve original failure */ }
|
||||
}
|
||||
report.finished = new Date().toISOString();
|
||||
fs.writeFileSync(path.join(out, 'report.json'), JSON.stringify(report, null, 2));
|
||||
console.log(JSON.stringify({ result: report.result, checks: report.checks.length, errors: report.errors, failure: report.failure }));
|
||||
if (send) { try { await send('Page.close'); } catch { /* tab may already be closed */ } }
|
||||
ws?.close();
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
// Run with Windows Node and an isolated Chrome on CDP 9444; Vite must be on 5173.
|
||||
const fs = require('node:fs'), path = require('node:path'), assert = require('node:assert/strict');
|
||||
const out = path.resolve(__dirname, '../.verify/merge-20261106');
|
||||
fs.mkdirSync(out, { recursive: true });
|
||||
const report = { checks: [], errors: [] };
|
||||
let ws, send;
|
||||
const delay = ms => new Promise(r => setTimeout(r, ms));
|
||||
(async () => {
|
||||
const target = await (await fetch('http://127.0.0.1:9444/json/new?about:blank', { method: 'PUT' })).json();
|
||||
ws = new WebSocket(target.webSocketDebuggerUrl);
|
||||
await new Promise((r, j) => { ws.onopen = r; ws.onerror = j; });
|
||||
let seq = 0; const pending = new Map();
|
||||
ws.onmessage = ({ data }) => {
|
||||
const m = JSON.parse(data), p = pending.get(m.id);
|
||||
if (p) { clearTimeout(p.timer); pending.delete(m.id); m.error ? p.reject(new Error(JSON.stringify(m.error))) : p.resolve(m.result); }
|
||||
if (m.method === 'Runtime.exceptionThrown') report.errors.push(m.params.exceptionDetails.exception?.description || m.params.exceptionDetails.text);
|
||||
};
|
||||
send = (method, params = {}) => new Promise((resolve, reject) => {
|
||||
const id = ++seq, timer = setTimeout(() => reject(new Error('Timeout ' + method)), 30000);
|
||||
pending.set(id, { resolve, reject, timer }); ws.send(JSON.stringify({ id, method, params }));
|
||||
});
|
||||
const ev = async expression => {
|
||||
const r = await send('Runtime.evaluate', { expression, awaitPromise: true, returnByValue: true });
|
||||
if (r.exceptionDetails) throw new Error(r.exceptionDetails.exception?.description || r.exceptionDetails.text);
|
||||
return r.result.value;
|
||||
};
|
||||
const until = async expression => {
|
||||
for (let i = 0; i < 120; i++) { if (await ev(expression)) return; await delay(150); }
|
||||
throw new Error('Wait ' + expression);
|
||||
};
|
||||
const check = (name, value) => { assert.ok(value, name); report.checks.push(name); console.log('PASS', name); };
|
||||
const click = expression => ev(`(()=>{const e=${expression};if(!e||e.disabled)throw new Error('Missing target');e.click();})()`);
|
||||
const shot = async name => {
|
||||
const r = await send('Page.captureScreenshot', { format: 'png' });
|
||||
fs.writeFileSync(path.join(out, name + '.png'), Buffer.from(r.data, 'base64'));
|
||||
};
|
||||
await send('Page.enable'); await send('Runtime.enable'); await send('DOM.enable');
|
||||
await send('Emulation.setDeviceMetricsOverride', { width: 1600, height: 1000, deviceScaleFactor: 1, mobile: false });
|
||||
await send('Page.navigate', { url: 'http://127.0.0.1:5173/' });
|
||||
await until(`typeof window.openSampleProject==='function'`);
|
||||
const fixture = await ev(`(async()=>{
|
||||
const XLSX=await import('/node_modules/xlsx/xlsx.mjs'),{parseWorkbook}=await import('/src/core/parseWorkbook.ts');
|
||||
const bytes=await(await fetch('/'+encodeURIComponent('도면추출')+'/samples/'+encodeURIComponent('운곡교P1입력.xlsx'))).arrayBuffer();
|
||||
const base=parseWorkbook(XLSX.read(bytes)).piers.find(p=>p.name==='P1');
|
||||
if(!base)throw new Error('Missing sample pier');
|
||||
const piers=['직접기초','현장타설말뚝'].map((foundationType,i)=>{const input={...base,name:'P'+(i+1),foundationType,type:'T형',columnCount:1,groundEl:30,pileLengthMm:24000,castInPlaceShape:{clayBottomEl:16}};return{id:input.name,input,defaults:input,draft:input};});
|
||||
return{schemaVersion:1,project:{name:'병합 검증',bridge:'검증',client:'',author:'',crs:'',memo:''},source:{fileName:'합성 표본',sheets:[]},piers,history:[]};
|
||||
})()`);
|
||||
const file = path.join(out, 'fixture.pzr.json'); fs.writeFileSync(file, JSON.stringify(fixture));
|
||||
await ev('window.openSampleProject()');
|
||||
await click(`document.querySelector('#file-menu summary')`); await click(`document.getElementById('open-project')`);
|
||||
const doc = await send('DOM.getDocument');
|
||||
const { nodeId } = await send('DOM.querySelector', { nodeId: doc.root.nodeId, selector: '#project-file' });
|
||||
await send('DOM.setFileInputFiles', { nodeId, files: [file] });
|
||||
await until(`document.querySelectorAll('#rebar-pier-items button').length===2`);
|
||||
await click(`document.getElementById('nav-rebar')`);
|
||||
check('local rebar screen renders', await ev(`!document.getElementById('view-rebar').hidden&&!!document.querySelector('#rebar-table table')`));
|
||||
await click(`[...document.querySelectorAll('#rebar-pier-items button')].find(b=>b.textContent.startsWith('P2 ·'))`);
|
||||
await click(`[...document.querySelectorAll('#rebar-tabs button')].find(b=>b.textContent.startsWith('기둥'))`);
|
||||
check('unified cast input and physical rebar preserved', await ev(`!!document.getElementById('cip-apply')&&document.getElementById('rebar-table').textContent.includes('말뚝-P1')`));
|
||||
await shot('01-cast');
|
||||
await click(`[...document.querySelectorAll('button')].find(b=>b.textContent==='철근재료표 보기')`);
|
||||
check('quantity workspace from origin renders', await ev(`!document.getElementById('view-quantity').hidden&&!!document.querySelector('.takeoff-workspace .takeoff-toolbar')`));
|
||||
await click(`document.querySelector('[data-takeoff-mode="materials"]')`);
|
||||
check('local material table preserved', await ev(`document.getElementById('quantity-body').textContent.includes('말뚝-P1')`));
|
||||
await shot('02-materials');
|
||||
await click(`document.getElementById('nav-drawing')`);
|
||||
await until(`!!document.querySelector('#view-drawing svg')`);
|
||||
check('origin drawing screen renders finite geometry', await ev(`!document.getElementById('view-drawing').hidden&&!/NaN|Infinity/.test(document.querySelector('#view-drawing svg').outerHTML)`));
|
||||
await shot('03-drawing');
|
||||
await click(`document.getElementById('nav-plan')`);
|
||||
check('plan remains reachable after drawing', await ev(`!document.getElementById('view-plan').hidden`));
|
||||
for (const width of [1280, 1600, 1920]) {
|
||||
await send('Emulation.setDeviceMetricsOverride', { width, height: 1000, deviceScaleFactor: 1, mobile: false });
|
||||
await delay(150);
|
||||
check(`one-row header preserved at ${width}`, await ev(`document.querySelector('.top').getBoundingClientRect().height<=65&&document.documentElement.scrollWidth<=innerWidth`));
|
||||
}
|
||||
check('no runtime exceptions', report.errors.length === 0);
|
||||
report.result = 'PASS';
|
||||
})().catch(e => { report.result = 'FAIL'; report.failure = e.stack; process.exitCode = 1; }).finally(() => {
|
||||
fs.writeFileSync(path.join(out, 'report.json'), JSON.stringify(report, null, 2));
|
||||
console.log(JSON.stringify(report)); ws?.close();
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
// Run with Windows Node against the task's isolated Chrome CDP on port 9444.
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const out = path.resolve(__dirname, '../.verify/multicol-coping-20261105');
|
||||
fs.mkdirSync(out, { recursive: true });
|
||||
(async () => {
|
||||
const targets = await (await fetch('http://127.0.0.1:9444/json')).json();
|
||||
const page = targets.find(t => t.type === 'page' && t.url.startsWith('http://127.0.0.1:5173'));
|
||||
if (!page) throw new Error('Task browser page missing');
|
||||
const ws = new WebSocket(page.webSocketDebuggerUrl);
|
||||
await new Promise((resolve, reject) => { ws.onopen = resolve; ws.onerror = reject; });
|
||||
let seq = 0;
|
||||
const pending = new Map();
|
||||
ws.onmessage = ({ data }) => {
|
||||
const msg = JSON.parse(data);
|
||||
const p = pending.get(msg.id);
|
||||
if (p) { pending.delete(msg.id); clearTimeout(p.timer); msg.error ? p.reject(new Error(JSON.stringify(msg.error))) : p.resolve(msg.result); }
|
||||
};
|
||||
const send = (method, params = {}) => new Promise((resolve, reject) => {
|
||||
const id = ++seq;
|
||||
const timer = setTimeout(() => { pending.delete(id); reject(new Error(`Timeout ${method}`)); }, 20000);
|
||||
pending.set(id, { resolve, reject, timer });
|
||||
ws.send(JSON.stringify({ id, method, params }));
|
||||
});
|
||||
try {
|
||||
await send('Emulation.setDeviceMetricsOverride', { width: 1200, height: 750, deviceScaleFactor: 1, mobile: false });
|
||||
const results = [];
|
||||
for (const type of ['2주형', '3주형']) {
|
||||
const res = await send('Runtime.evaluate', { awaitPromise: true, returnByValue: true, expression: `
|
||||
(async () => {
|
||||
const THREE = await import('/node_modules/.vite/deps/three.js');
|
||||
const XLSX = await import('/node_modules/xlsx/xlsx.mjs');
|
||||
const { parseWorkbook } = await import('/src/core/parseWorkbook.ts');
|
||||
const { buildPier } = await import('/src/core/geometry.ts');
|
||||
const { buildPierScene } = await import('/src/view/scene.ts');
|
||||
const buf = await (await fetch('/samples/' + encodeURIComponent('운곡교P1입력.xlsx'))).arrayBuffer();
|
||||
const base = parseWorkbook(XLSX.read(buf)).piers.find(p => p.name === 'P1');
|
||||
if (!base) throw new Error('P1 sample missing');
|
||||
// 3주형 needs a 20 m coping for the 오목형 spans (P1 length leaves no flat part, RB-M01).
|
||||
const m = buildPier({ ...base, ...(${type === '3주형'} ? { copingLengthMm: 20000 } : {}), type: ${JSON.stringify(type)}, columnCount: ${type === '2주형' ? 2 : 3} });
|
||||
const built = buildPierScene(m);
|
||||
const scene = new THREE.Scene(); scene.background = new THREE.Color(0xe9eef4);
|
||||
const group = new THREE.Group();
|
||||
group.add(built.parts.coping, built.parts.columns); scene.add(group);
|
||||
const center = new THREE.Vector3();
|
||||
const bounds = new THREE.Box3().setFromObject(built.parts.coping);
|
||||
bounds.getCenter(center);
|
||||
// Align the camera along the cross-axis to make both interior haunches visible.
|
||||
const u = new THREE.Vector3(m.u.e, 0, -m.u.n);
|
||||
const cross = new THREE.Vector3(m.nrm.e, 0, -m.nrm.n);
|
||||
const camera = new THREE.PerspectiveCamera(36, 1200/750, 0.1, 1000);
|
||||
const target = center.clone().add(new THREE.Vector3(0, -0.9, 0));
|
||||
camera.position.copy(target).addScaledVector(cross, m.copingL * 1.6).addScaledVector(u, -m.copingL * 0.12).add(new THREE.Vector3(0,-2.5,0));
|
||||
camera.lookAt(target);
|
||||
scene.add(new THREE.HemisphereLight(0xffffff, 0x687784, 2.4));
|
||||
const light = new THREE.DirectionalLight(0xffffff, 2); light.position.copy(camera.position).add(new THREE.Vector3(0,10,0)); scene.add(light);
|
||||
const renderer = new THREE.WebGLRenderer({ antialias: true, preserveDrawingBuffer: true });
|
||||
renderer.setSize(1200,750); renderer.localClippingEnabled = true;
|
||||
document.body.replaceChildren(renderer.domElement); document.body.style.margin = '0';
|
||||
renderer.render(scene,camera);
|
||||
const profile = m.copingParts[0].profile;
|
||||
const raised = profile.filter(([,z]) => Math.abs(z-m.levelBottom-0.5)<1e-8).length;
|
||||
if (raised !== (m.columns.length-1)*2) throw new Error('Raised span count mismatch');
|
||||
if (Math.abs(Math.min(...profile.map(p=>p[1]))-m.levelBottom)>1e-8) throw new Error('Underside below column Level');
|
||||
if (!m.columns.every(c=>c.top===m.levelBottom)) throw new Error('Column connection changed');
|
||||
return { type: ${JSON.stringify(type)}, raisedVertices: raised, levelBottom:m.levelBottom, interiorBottom:m.levelBottom+0.5, columns:m.columns.length, profile, screenshot:renderer.domElement.toDataURL('image/png') };
|
||||
})()
|
||||
` });
|
||||
if (res.exceptionDetails) throw new Error(res.exceptionDetails.exception?.description || res.exceptionDetails.text);
|
||||
const { screenshot, ...result } = res.result.value;
|
||||
fs.writeFileSync(path.join(out, `${type}.png`), Buffer.from(screenshot.split(',')[1], 'base64'));
|
||||
results.push(result);
|
||||
}
|
||||
fs.writeFileSync(path.join(out, 'report.json'), JSON.stringify(results, null, 2));
|
||||
console.log(JSON.stringify(results.map(({ profile, ...r }) => r)));
|
||||
} finally { ws.close(); }
|
||||
})().catch(e => { console.error(e); process.exitCode = 1; });
|
||||
@@ -0,0 +1,151 @@
|
||||
// Run with Windows Node against an isolated Chrome CDP session (port 9444).
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const assert = require('node:assert/strict');
|
||||
const out = path.resolve(__dirname, '../.verify/unified-input-20261106');
|
||||
fs.mkdirSync(out, { recursive: true });
|
||||
const report = { checks: [], errors: [], layouts: [] };
|
||||
const delay = ms => new Promise(resolve => setTimeout(resolve, ms));
|
||||
let ws, send;
|
||||
(async () => {
|
||||
const target = await (await fetch('http://127.0.0.1:9444/json/new?about:blank', { method: 'PUT' })).json();
|
||||
ws = new WebSocket(target.webSocketDebuggerUrl);
|
||||
await new Promise((resolve, reject) => { ws.onopen = resolve; ws.onerror = reject; });
|
||||
let seq = 0, download;
|
||||
const pending = new Map(), completed = new Set();
|
||||
ws.onmessage = ({ data }) => {
|
||||
const m = JSON.parse(data), p = pending.get(m.id);
|
||||
if (p) { clearTimeout(p.timer); pending.delete(m.id); m.error ? p.reject(new Error(JSON.stringify(m.error))) : p.resolve(m.result); }
|
||||
if (m.method === 'Runtime.exceptionThrown') report.errors.push(m.params.exceptionDetails.exception?.description || m.params.exceptionDetails.text);
|
||||
if (m.method === 'Browser.downloadWillBegin') download = m.params.guid;
|
||||
if (m.method === 'Browser.downloadProgress' && m.params.state === 'completed') completed.add(m.params.guid);
|
||||
};
|
||||
send = (method, params = {}) => new Promise((resolve, reject) => {
|
||||
const id = ++seq, timer = setTimeout(() => { pending.delete(id); reject(new Error(`Timeout ${method}`)); }, 25000);
|
||||
pending.set(id, { resolve, reject, timer }); ws.send(JSON.stringify({ id, method, params }));
|
||||
});
|
||||
const ev = async expression => {
|
||||
const r = await send('Runtime.evaluate', { expression, awaitPromise: true, returnByValue: true });
|
||||
if (r.exceptionDetails) throw new Error(r.exceptionDetails.exception?.description || r.exceptionDetails.text);
|
||||
return r.result.value;
|
||||
};
|
||||
const until = async expression => { for (let i = 0; i < 100; i++) { if (await ev(expression)) return; await delay(150); } throw new Error(`Wait failed: ${expression}`); };
|
||||
const click = expression => ev(`(() => { const e = ${expression}; if (!e || e.disabled) throw new Error('Missing/disabled target'); e.click(); })()`);
|
||||
const check = (name, pass) => { assert.ok(pass, name); report.checks.push(name); console.log('PASS', name); };
|
||||
const set = (id, value) => ev(`(() => {const e=document.getElementById(${JSON.stringify(id)}); e.value=${JSON.stringify(String(value))}; e.dispatchEvent(new Event(e.tagName==='SELECT'?'change':'input',{bubbles:true}));})()`);
|
||||
const shot = async name => { const r = await send('Page.captureScreenshot', { format: 'png' }); fs.writeFileSync(path.join(out, name + '.png'), Buffer.from(r.data, 'base64')); };
|
||||
const member = text => click(`[...document.querySelectorAll('#rebar-tabs button')].find(b => b.textContent.startsWith(${JSON.stringify(text)}))`);
|
||||
const selectPier = name => click(`[...document.querySelectorAll('#rebar-pier-items button')].find(b=>b.textContent.startsWith(${JSON.stringify(name+' ·')}))`);
|
||||
const open = async name => {
|
||||
await click(`document.querySelector('#file-menu summary')`);
|
||||
await click(`document.getElementById('open-project')`);
|
||||
const doc = await send('DOM.getDocument');
|
||||
const { nodeId } = await send('DOM.querySelector', { nodeId: doc.root.nodeId, selector: '#project-file' });
|
||||
await send('DOM.setFileInputFiles', { nodeId, files: [name] });
|
||||
};
|
||||
await send('Page.enable'); await send('Runtime.enable'); await send('DOM.enable');
|
||||
await send('Emulation.setDeviceMetricsOverride', { width: 1600, height: 1000, deviceScaleFactor: 1, mobile: false });
|
||||
const downloads = path.join(out, 'downloads'); fs.mkdirSync(downloads, { recursive: true });
|
||||
await send('Browser.setDownloadBehavior', { behavior: 'allowAndName', downloadPath: downloads, eventsEnabled: true });
|
||||
await send('Page.navigate', { url: 'http://127.0.0.1:5173/' });
|
||||
await until(`typeof window.openSampleProject==='function'`);
|
||||
const fixture = await ev(`(async () => {
|
||||
const XLSX=await import('/node_modules/xlsx/xlsx.mjs');
|
||||
const {parseWorkbook}=await import('/src/core/parseWorkbook.ts');
|
||||
const bytes=await (await fetch('/samples/'+encodeURIComponent('운곡교P1입력.xlsx'))).arrayBuffer();
|
||||
const base=parseWorkbook(XLSX.read(bytes)).piers.find(p=>p.name==='P1');
|
||||
const piers=[];
|
||||
for(const [type,columnCount] of [['T형',1],['2주형',2],['3주형',3]]) for(const foundationType of ['현장타설말뚝','직접기초','말뚝기초']) {
|
||||
const input={...base,name:'P'+(piers.length+1),type,columnCount,foundationType};
|
||||
piers.push({id:input.name,input,defaults:input,draft:input});
|
||||
}
|
||||
return {schemaVersion:1,project:{name:'입력 통합 검증',bridge:'검증',client:'',author:'',crs:'',memo:''},source:{fileName:'합성 표본',sheets:[]},piers,history:[]};
|
||||
})()`);
|
||||
fixture.project.name = '아주 긴 프로젝트명으로 말줄임과 한 줄 헤더를 검증하는 프로젝트';
|
||||
fixture.project.bridge = fixture.project.name;
|
||||
fixture.piers[0].input.castInPlaceRebar = {
|
||||
column: { mainCount: 46, mainDiameterMm: 32, coverMm: 100, coverReference: 'main-center' },
|
||||
pile: { mainCount: 48, mainDiameterMm: 32, coverMm: 100, coverReference: 'clear', tieKind: 'spiral', tieSpacingMm: 200, cageLengthMm: 12000 },
|
||||
connection: { mode: 'coupler', couplerLengthMm: 165 },
|
||||
};
|
||||
fixture.piers[0].draft = fixture.piers[0].input;
|
||||
const fixturePath = path.join(out, 'fixture.pzr.json'); fs.writeFileSync(fixturePath, JSON.stringify(fixture));
|
||||
await ev('window.openSampleProject()'); await open(fixturePath);
|
||||
await until(`document.querySelectorAll('#rebar-pier-items button').length===9`);
|
||||
await click(`document.getElementById('nav-rebar')`);
|
||||
for (let i = 1; i <= 9; i++) {
|
||||
const cast = [1, 4, 7].includes(i);
|
||||
await selectPier('P'+i); await member('기둥');
|
||||
check(`P${i}: correct foundation-specific member list and editor`, await ev(cast
|
||||
? `document.querySelectorAll('#rebar-tabs button').length===3 && !!document.querySelector('[data-cast-part="combined"]') && ![...document.querySelectorAll('#rebar-tabs button')].some(b=>b.textContent.includes('확대기초'))`
|
||||
: `!!document.getElementById('column-nDiv') && [...document.querySelectorAll('#rebar-tabs button')].some(b=>b.textContent.startsWith('확대기초')) && !document.querySelector('[data-cast-part]')`));
|
||||
}
|
||||
await selectPier('P4'); await member('기둥');
|
||||
check('equal defaults show one shared value with exceptions collapsed', await ev(`!document.getElementById('cip-exceptions').open && document.getElementById('cip-common-mainCount').value==='46'`));
|
||||
await set('cip-common-coverMm', 120); await set('cip-common-coverReference', 'clear'); await click(`document.getElementById('cip-apply')`);
|
||||
check('explicit common cover and reference apply to both segments', await ev(`document.getElementById('cip-column-coverMm').value==='120' && document.getElementById('cip-pile-coverMm').value==='120' && document.getElementById('cip-column-coverReference').value==='clear' && document.getElementById('cip-pile-coverReference').value==='clear'`));
|
||||
await selectPier('P1'); await member('기둥');
|
||||
check('unequal counts and equal cover numbers with different references remain distinct', await ev(`document.getElementById('cip-exceptions').open && document.getElementById('cip-common-mainCount').value==='' && document.getElementById('cip-common-coverMm').value==='' && document.getElementById('cip-column-mainCount').value==='46' && document.getElementById('cip-pile-mainCount').value==='48'`));
|
||||
check('one connection editor and one save button', await ev(`document.querySelectorAll('#cip-connection-mode').length===1 && document.querySelectorAll('#cip-apply').length===1`));
|
||||
await set('cip-common-innerMainDiameterMm', 35.8); await click(`document.getElementById('cip-apply')`);
|
||||
check('shared edit updates both segments and preserves unrelated exceptions', await ev(`document.getElementById('cip-column-innerMainDiameterMm').value==='35.8' && document.getElementById('cip-pile-innerMainDiameterMm').value==='35.8' && document.getElementById('cip-column-mainCount').value==='46' && document.getElementById('cip-pile-mainCount').value==='48' && document.getElementById('cip-pile-coverReference').value==='clear' && document.getElementById('cip-column-coverReference').value==='main-center'`));
|
||||
await set('cip-pile-cageLengthMm', 999999); await click(`document.getElementById('cip-apply')`);
|
||||
check('invalid cage length is rejected inline', await ev(`document.querySelector('[data-cast-part]').textContent.includes('철근망 길이가 말뚝 길이보다')`));
|
||||
await set('cip-pile-cageLengthMm', 12000); await set('cip-common-mainCount', 46.5); await click(`document.getElementById('cip-apply')`);
|
||||
check('fractional main counts are rejected', await ev(`document.querySelector('[role="status"]').textContent!==undefined && document.querySelector('[data-cast-part]').textContent.includes('양수 정수')`));
|
||||
await set('cip-common-mainCount', 50); await set('cip-pile-mainCount', 52); await set('cip-connection-mode', 'continuous');
|
||||
await click(`document.getElementById('cip-apply')`);
|
||||
check('explicit pile exception overrides a shared edit without changing column', await ev(`document.getElementById('cip-column-mainCount').value==='50' && document.getElementById('cip-pile-mainCount').value==='52' && document.getElementById('cip-pile-tieSpacingMm').value==='200' && document.getElementById('cip-connection-couplerLengthMm').value==='165'`));
|
||||
check('pending review remains visible', await ev(`document.getElementById('review-tasks').textContent.includes('CP-F1') && document.getElementById('nav-rebar').getAttribute('aria-label').includes('현장타설 배근 미확정')`));
|
||||
await shot('01-unified-input');
|
||||
for (const width of [1280,1600,1920]) {
|
||||
await send('Emulation.setDeviceMetricsOverride', { width, height: 1000, deviceScaleFactor: 1, mobile: false }); await delay(200);
|
||||
const layout = await ev(`(() => {const h=document.querySelector('.top'),r=h.getBoundingClientRect();return {width:innerWidth,height:r.height,overflow:document.documentElement.scrollWidth>innerWidth,rows:[...h.children].map(e=>Math.round(e.getBoundingClientRect().top)),project:document.getElementById('project-line').textContent};})()`);
|
||||
report.layouts.push(layout);
|
||||
check(`header ${width}: single row and no document overflow`, layout.height<=65 && !layout.overflow);
|
||||
await shot('header-'+width);
|
||||
}
|
||||
check('duplicate project/bridge name is omitted while full title remains available', await ev(`document.getElementById('project-line').title===${JSON.stringify(fixture.project.name+' · 교각 9기')}`));
|
||||
await click(`document.querySelector('#file-menu summary')`);
|
||||
check('file menu opens', await ev(`document.getElementById('file-menu').open`));
|
||||
await ev(`document.getElementById('open-project').focus()`);
|
||||
await send('Input.dispatchKeyEvent', { type: 'keyDown', key: 'Escape', code: 'Escape', windowsVirtualKeyCode: 27 });
|
||||
check('Escape closes the menu and restores summary focus', await ev(`!document.getElementById('file-menu').open && document.activeElement===document.querySelector('#file-menu summary')`));
|
||||
await send('Input.dispatchKeyEvent', { type: 'keyDown', key: 'Enter', code: 'Enter', text: '\r', windowsVirtualKeyCode: 13 });
|
||||
await send('Input.dispatchKeyEvent', { type: 'keyUp', key: 'Enter', code: 'Enter', windowsVirtualKeyCode: 13 });
|
||||
check('keyboard Enter opens the native file menu', await ev(`document.getElementById('file-menu').open`));
|
||||
await send('Input.dispatchKeyEvent', { type: 'keyDown', key: 'Tab', code: 'Tab', windowsVirtualKeyCode: 9 });
|
||||
await send('Input.dispatchKeyEvent', { type: 'keyUp', key: 'Tab', code: 'Tab', windowsVirtualKeyCode: 9 });
|
||||
check('Tab reaches the Excel import action', await ev(`document.activeElement.id==='open-import'`));
|
||||
await click(`document.getElementById('open-import')`);
|
||||
check('Excel action opens the existing import dialog and closes the menu', await ev(`!document.getElementById('file-menu').open && !!document.querySelector('dialog[open]')`));
|
||||
await ev(`document.querySelector('dialog[open]').close()`);
|
||||
await click(`document.getElementById('save-project')`);
|
||||
if (await ev(`document.getElementById('save-dialog').open`)) await click(`document.getElementById('sv-save')`);
|
||||
for (let i=0;i<120 && !(download && completed.has(download));i++) await delay(150);
|
||||
assert.ok(download && completed.has(download), 'download completes');
|
||||
const savedText = fs.readFileSync(path.join(downloads,download), 'utf8');
|
||||
const saved = JSON.parse(savedText).piers.find(p=>p.input.name==='P1').input.castInPlaceRebar;
|
||||
check('download retains independent segment settings and connection schema', saved.column.mainCount===50 && saved.pile.mainCount===52 && saved.pile.coverReference==='clear' && saved.column.coverReference==='main-center' && saved.connection.mode==='continuous' && !saved.common);
|
||||
const savedPath = path.join(out,'saved.pzr.json'); fs.writeFileSync(savedPath,savedText);
|
||||
await send('Page.navigate', { url: 'http://127.0.0.1:5173/' }); await until(`typeof window.openSampleProject==='function'`);
|
||||
await ev('window.openSampleProject()'); await open(savedPath); await until(`document.querySelectorAll('#rebar-pier-items button').length===9`);
|
||||
await click(`document.getElementById('nav-rebar')`); await selectPier('P1'); await member('기둥');
|
||||
check('fresh-page restore retains exceptions and shared changes', await ev(`document.getElementById('cip-exceptions').open && document.getElementById('cip-column-mainCount').value==='50' && document.getElementById('cip-pile-mainCount').value==='52' && document.getElementById('cip-pile-innerMainDiameterMm').value==='35.8' && document.getElementById('cip-connection-mode').value==='continuous'`));
|
||||
// Follow dedicated warnings to verify both segment targets resolve to one card.
|
||||
for (const text of ['RB-CIP01', 'RB-CIP02', 'RB-F00']) {
|
||||
await click(`[...document.querySelectorAll('#rebar-issues li')].find(e=>e.textContent.includes(${JSON.stringify(text)}))?.querySelector('button')`);
|
||||
check(`${text}: warning opens unified editor`, await ev(`!!document.getElementById('cip-apply') && document.querySelector('#rebar-tabs button[aria-pressed="true"]').textContent.startsWith('기둥·말뚝')`));
|
||||
}
|
||||
// ④ is no longer a header step (header ①②③, 2026-11-06): open the 재료표 from ③.
|
||||
await click(`document.getElementById('nav-rebar')`);
|
||||
await click(`[...document.querySelectorAll('button')].find(b=>b.textContent==='철근재료표 보기')`);
|
||||
check('quantity remains explicitly incomplete', await ev(`document.getElementById('quantity-body').textContent.includes('전체 수량 미완료')`));
|
||||
check('no runtime exceptions', report.errors.length===0);
|
||||
report.result='PASS';
|
||||
})().catch(e=>{report.result='FAIL';report.failure=e.stack;process.exitCode=1;}).finally(async()=>{
|
||||
if(report.result==='FAIL' && send) try {const r=await send('Page.captureScreenshot',{format:'png'});fs.writeFileSync(path.join(out,'failure.png'),Buffer.from(r.data,'base64'));}catch{}
|
||||
fs.writeFileSync(path.join(out,'report.json'),JSON.stringify(report,null,2));
|
||||
console.log(JSON.stringify({result:report.result,checks:report.checks.length,failure:report.failure}));
|
||||
ws?.close();
|
||||
});
|
||||
@@ -0,0 +1,192 @@
|
||||
/**
|
||||
* 교대 받침 카드 (2026-11-03, 시안 ② 교량받침·받침블럭·솔플레이트, 교각 카드와 같은 형식). 순수 함수.
|
||||
*
|
||||
* - 솔플레이트 (결정 2026-11-06, spec_abut §5·QA-15, src/core/sole-plate.ts): S = 같은 거더의 짝 받침(A1 ↔ P1 전열,
|
||||
* A2 ↔ Pn)으로 계산, 짝이 없으면 교대 종단경사 i(엑셀 Abut). L = A + 100, W = B + 100 (A × B 기본 700 × 640, 교각 P1
|
||||
* 전열 값 가정). 받침이 1열이라 T는 교대 전체에 하나: max(22, ⌈22 + L·|S|/2⌉). 거더 하면 고정 → 블럭 상면 = z − (T − T_ref).
|
||||
* - h3 기본값 = 받침 시트의 받침높이 − T_ref (A1 275 − 30 = 245): 엑셀 받침높이가 정한 거더 하면을 그대로 둔다
|
||||
* (시안은 교각 P1의 h3 181을 가정했으나 교대 시트에 받침높이가 있어 그 값을 지킨다).
|
||||
* - 높이: E = h1 (블럭 콘크리트) + h2 (몰탈패드 50) + h3 + h4 (= T). z = 패드 상면 = EL.X.
|
||||
*/
|
||||
import type { BearingPair } from '../bearing-pair';
|
||||
import { type BearingSole, SOLE_DEFAULTS, structureSoles } from '../sole-plate';
|
||||
import { ABUT_PAD_MM, type AbutModel } from './geometry';
|
||||
import type { AbutMembers } from './members';
|
||||
|
||||
/** 교대 솔플레이트 입력 (2026-11-06). Every value optional; absent = the default. */
|
||||
export interface AbutBearingRule {
|
||||
/** 받침 본체 높이 h3 (mm). Absent = 받침높이 − T_ref. */
|
||||
h3Mm?: number;
|
||||
tRefMm: number;
|
||||
tMinMm: number;
|
||||
/** 솔플 여유 L − A = W − B (mm). */
|
||||
marginMm: number;
|
||||
/** 받침 평면 A (거더 방향) × B (mm), every bearing. */
|
||||
aMm: number;
|
||||
bMm: number;
|
||||
/** T typed (mm). Absent = 자동. */
|
||||
tMm?: number;
|
||||
/** S typed for every bearing (%). Absent = 짝 받침 계산. */
|
||||
slopePct?: number;
|
||||
}
|
||||
|
||||
export const ABUT_BEARING_RULE_DEFAULT: AbutBearingRule = { tRefMm: SOLE_DEFAULTS.tRefMm, tMinMm: SOLE_DEFAULTS.tMinMm, marginMm: SOLE_DEFAULTS.marginMm, aMm: SOLE_DEFAULTS.aMm, bMm: SOLE_DEFAULTS.bMm };
|
||||
/** 받침블럭 평면 (mm, 정사각) and 몰탈패드 평면 (mm), spec_abut §4.2. */
|
||||
export const ABUT_BLOCK_MM = 1200;
|
||||
export const ABUT_PAD_PLAN_MM = 1000;
|
||||
/** I01: 블럭 콘크리트 높이가 피복 50 + 철근 지름 13보다 작으면 블럭 철근을 교좌면 아래로 묻는다 (spec_abut §6.6). */
|
||||
const I01_MIN_MM = 50 + 13;
|
||||
|
||||
const CIRCLED = ['①', '②', '③', '④', '⑤', '⑥', '⑦', '⑧', '⑨', '⑩'];
|
||||
|
||||
/** Keys the 2026-11-06 rule reads; values stored by the C안 (lengthMm, widthMm, kind) are ignored. */
|
||||
const RULE_KEYS = ['h3Mm', 'tRefMm', 'tMinMm', 'marginMm', 'aMm', 'bMm', 'tMm', 'slopePct'] as const;
|
||||
|
||||
export function resolveBearingRule(changes?: Partial<AbutBearingRule> & Record<string, unknown>): AbutBearingRule {
|
||||
const own = Object.fromEntries(Object.entries(changes ?? {}).filter(([k]) => (RULE_KEYS as readonly string[]).includes(k)));
|
||||
return { ...ABUT_BEARING_RULE_DEFAULT, ...own };
|
||||
}
|
||||
|
||||
/** Bridge context of the abutment card: the girder pairs and the 교대 종단경사 i (decimal). */
|
||||
export interface AbutSoleContext {
|
||||
pairs?: BearingPair[];
|
||||
slopeI?: number;
|
||||
/** 엑셀 방위각 per bearing number (deg model 진북), when an applied 계산 방위각 is in the model. */
|
||||
inputAzimuthDeg?: Record<string, number>;
|
||||
}
|
||||
|
||||
/** Solplate of every bearing of the abutment (one row). */
|
||||
export function abutSoles(model: AbutModel, rule: AbutBearingRule, heightMm: number, ctx: AbutSoleContext = {}): BearingSole[] {
|
||||
const plates = Object.fromEntries(model.bearings.map((b) => [String(b.no), { aMm: rule.aMm, bMm: rule.bMm }]));
|
||||
return structureSoles(
|
||||
model.name,
|
||||
model.bearings.map((b) => ({ no: b.no, row: '1열', azimuthDeg: b.azimuthDeg ?? 0, inputAzimuthDeg: ctx.inputAzimuthDeg?.[String(b.no)] })),
|
||||
{
|
||||
h3Mm: rule.h3Mm ?? heightMm - rule.tRefMm, tRefMm: rule.tRefMm, tMinMm: rule.tMinMm, marginMm: rule.marginMm, plates,
|
||||
tByRow: rule.tMm === undefined ? undefined : { '1열': rule.tMm },
|
||||
slopePct: rule.slopePct === undefined ? undefined : Object.fromEntries(model.bearings.map((b) => [String(b.no), rule.slopePct!])),
|
||||
},
|
||||
ctx.pairs ?? [],
|
||||
ctx.slopeI === undefined ? undefined : ctx.slopeI * 100,
|
||||
);
|
||||
}
|
||||
|
||||
export interface AbutElevationRow {
|
||||
no: number;
|
||||
/** IP3(① 쪽)에서 잰 위치 (m). */
|
||||
sFromIp3M: number;
|
||||
seg: string;
|
||||
seatEl: number;
|
||||
h1: number;
|
||||
h2: number;
|
||||
/** 패드 상면 = EL.X (m). */
|
||||
z: number;
|
||||
h3: number;
|
||||
h4: number;
|
||||
E: number;
|
||||
/** 솔플레이트 상면 EL (m) = 교좌면 + E. */
|
||||
solplateTopEl: number;
|
||||
/** 블럭 끝에서 가장 가까운 계단 경계까지 (m). 경계가 없으면 null. */
|
||||
stepGapM: number | null;
|
||||
/** 받침 방위각 (deg): CAD (엑셀 값, 동 = 0° 반시계) / 진북 (북에서 시계). 입력이 없으면 null. */
|
||||
azimuthCadDeg: number | null;
|
||||
azimuthNorthDeg: number | null;
|
||||
/** 블럭 평면 회전 (deg), 교대축 직각 기준. */
|
||||
rotDeg: number;
|
||||
}
|
||||
|
||||
export function abutElevationRows(model: AbutModel, rule: AbutBearingRule, heightMm: number, ctx: AbutSoleContext = {}): AbutElevationRow[] {
|
||||
const half = ABUT_BLOCK_MM / 2000;
|
||||
const soles = new Map(abutSoles(model, rule, heightMm, ctx).map((x) => [x.no, x]));
|
||||
return model.bearings.map((b) => {
|
||||
const plan = soles.get(b.no)!;
|
||||
// 거더 하면 고정: the block concrete loses T − T_ref (2026-11-06, Q-SH7).
|
||||
const h1 = b.blockHeightMm - ABUT_PAD_MM - plan.dropMm;
|
||||
const E = h1 + ABUT_PAD_MM + plan.h3 + plan.T;
|
||||
const gaps = model.stepBounds.map((x) => Math.abs(x - b.s) - half);
|
||||
return {
|
||||
no: b.no,
|
||||
sFromIp3M: model.length - b.s,
|
||||
seg: CIRCLED[b.no - 1] ?? String(b.no),
|
||||
seatEl: b.seatEl,
|
||||
h1,
|
||||
h2: ABUT_PAD_MM,
|
||||
z: b.z - plan.dropMm / 1000,
|
||||
h3: plan.h3,
|
||||
h4: plan.T,
|
||||
E,
|
||||
solplateTopEl: b.seatEl + E / 1000,
|
||||
stepGapM: gaps.length ? Math.min(...gaps) : null,
|
||||
azimuthCadDeg: b.azimuthDeg === undefined ? null : (((90 - b.azimuthDeg) % 360) + 360) % 360,
|
||||
azimuthNorthDeg: b.azimuthDeg === undefined ? null : ((b.azimuthDeg % 360) + 360) % 360,
|
||||
rotDeg: b.rotDeg,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export interface AbutSolplateRow {
|
||||
no: number;
|
||||
/** 기울기 (%). */
|
||||
S: number;
|
||||
/** S 근거 (계산 / 입력 / 가정 / 수정됨 / 없음) and the 짝 받침, e.g. "P1-①". */
|
||||
source: string;
|
||||
pair?: string;
|
||||
/** 방위각 검사 (″): input azimuth against the pair direction; absent without a pair. */
|
||||
azimuthSec?: number;
|
||||
pairKind?: 'girder' | 'chord';
|
||||
/** 방위각 입력 (엑셀) and 계산 (짝 받침), deg model 진북; 계산 absent without a pair. */
|
||||
azimuthInputDeg: number;
|
||||
azimuthCalcDeg?: number;
|
||||
L: number;
|
||||
W: number;
|
||||
T: number;
|
||||
T1: number;
|
||||
T2: number;
|
||||
/** 검산 문구 "T1 − T2 ≈ L·|S|/100" (W08, 1 mm 이내). */
|
||||
check: string;
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
export function abutSolplateRows(model: AbutModel, rule: AbutBearingRule, heightMm: number, ctx: AbutSoleContext = {}): AbutSolplateRow[] {
|
||||
return abutSoles(model, rule, heightMm, ctx).map((p) => {
|
||||
const rhs = (p.L * Math.abs(p.slopePct)) / 100;
|
||||
return {
|
||||
no: p.no, S: p.slopePct, source: p.slopeSource, azimuthSec: p.azimuthCheckSec, azimuthInputDeg: p.azimuthInputDeg, azimuthCalcDeg: p.azimuthCalcDeg,
|
||||
pair: p.pair ? `${p.pair.structure}-${CIRCLED[p.pair.no - 1] ?? p.pair.no}${p.pairKind === 'chord' ? ' (현)' : ''}` : undefined, pairKind: p.pairKind,
|
||||
L: p.L, W: p.W, T: p.T, T1: p.T1, T2: p.T2,
|
||||
check: `${p.T1 - p.T2} ≈ ${rhs.toFixed(1)} ${Math.abs(p.T1 - p.T2 - rhs) <= 1 + 1e-9 ? '일치' : '불일치 (W08)'}`,
|
||||
warnings: p.warnings,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** Input problems of the 교대 솔플레이트 입력. Empty when it can build a solplate. */
|
||||
export function abutBearingRuleIssues(rule: AbutBearingRule, heightMm: number): string[] {
|
||||
const out: string[] = [];
|
||||
for (const k of ['tRefMm', 'tMinMm', 'aMm', 'bMm'] as const) if (!(Number.isFinite(rule[k]) && rule[k] > 0)) out.push(`${k}는 0보다 커야 합니다.`);
|
||||
if (!(Number.isFinite(rule.marginMm) && rule.marginMm >= 0)) out.push('솔플 여유는 0 이상이어야 합니다.');
|
||||
if (rule.tMm !== undefined && !(Number.isFinite(rule.tMm) && rule.tMm > 0)) out.push('T는 0보다 커야 합니다.');
|
||||
if (rule.slopePct !== undefined && !Number.isFinite(rule.slopePct)) out.push('S는 숫자여야 합니다.');
|
||||
const h3 = rule.h3Mm ?? heightMm - rule.tRefMm;
|
||||
if (!(Number.isFinite(h3) && h3 > 0)) out.push(`받침 높이 h3 (${h3})는 0보다 커야 합니다. 받침높이 ${heightMm}와 T_ref를 확인하세요.`);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 검사 줄 (시안): W02 블럭 콘크리트, W04 블럭이 교좌면 안, W03 블럭 끝 ~ 계단 경계, I01 블럭 철근. 문제 없으면 "없음". */
|
||||
export function abutBearingChecks(model: AbutModel, members: AbutMembers, rule: AbutBearingRule, heightMm: number, ctx: AbutSoleContext = {}): string[] {
|
||||
const rows = abutElevationRows(model, rule, heightMm, ctx);
|
||||
const minH1 = Math.min(...rows.map((r) => r.h1));
|
||||
const half = ABUT_BLOCK_MM / 2000;
|
||||
// Block plan across the wall: centre t = frontSide × lateral, between the 흉벽 front (t 0) and the 벽체 front.
|
||||
const tCentres = model.bearings.map((b) => model.frontSide * b.lateral);
|
||||
const backGap = Math.min(...tCentres.map((t) => t - half));
|
||||
const frontGap = Math.min(...tCentres.map((t) => members.shape.wallFrontM - (t + half)));
|
||||
const stepGaps = rows.map((r) => r.stepGapM).filter((g): g is number => g !== null);
|
||||
const minStep = stepGaps.length ? Math.min(...stepGaps) : null;
|
||||
return [
|
||||
`블럭 콘크리트 ${minH1} > 0 (W02 ${minH1 > 0 ? '없음' : '있음'})`,
|
||||
`블럭이 교좌면 안 (흉벽 ${Math.round(backGap * 1000)} · 전면 ${Math.round(frontGap * 1000)}, W04 ${backGap >= 0 && frontGap >= 0 ? '없음' : '있음'})`,
|
||||
minStep === null ? '블럭 끝 ~ 계단 경계: 경계 없음 (W03 없음)' : `블럭 끝 ~ 계단 경계 최소 ${minStep.toFixed(3)} m (W03 ${minStep >= 0 ? '없음' : '있음'})`,
|
||||
`${minH1} ≥ ${I01_MIN_MM} → I01 ${minH1 >= I01_MIN_MM ? '없음' : '있음 (블럭 철근을 교좌면 아래로)'}`,
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
/**
|
||||
* 교대 입력 편집 (2026-11-03, 교대 편집 카드): changes one value of an abutment record with checks. Pure: it returns a new
|
||||
* record and never changes the old one. Errors are in Korean and name the field.
|
||||
*/
|
||||
import type { AbutRecord } from './parse';
|
||||
import type { WingForm } from './geometry';
|
||||
import { WING_DIMS_DEFAULT, wingDimsIssues, type WingDims } from './wings';
|
||||
import { ABUT_SHAPE_DEFAULT, normalizeShape, shapeIssues, type AbutShape } from './shape';
|
||||
import { resolveSlab, slabIssues, type AbutSlab, type SlabForm } from './slab';
|
||||
import type { AbutGround } from './ground';
|
||||
import { footingRebarIssues, resolveFootingRebar, type AbutFootingRebarSettings } from './rebar/footing';
|
||||
import { resolveWallRebar, wallRebarIssues, type AbutWallRebarSettings } from './rebar/wall';
|
||||
import { parapetRebarIssues, resolveParapetRebar, type AbutParapetRebarSettings } from './rebar/parapet';
|
||||
import { resolveWingRebar, wingRebarIssues, type AbutWingRebarSettings } from './rebar/wing';
|
||||
import { resolveSlabRebar, slabRebarIssues, type AbutSlabRebarSettings } from './rebar/slab';
|
||||
import { blockRebarIssues, resolveBlockRebar, type AbutBlockSettings } from './rebar/block';
|
||||
import { abutBearingRuleIssues, resolveBearingRule, type AbutBearingRule } from './bearing-table';
|
||||
|
||||
export type AbutEditKey = 'seatEl' | 'foundationBottomEl' | 'groundEl' | 'pileLengthMm' | 'skewDeg' | 'slopeI';
|
||||
|
||||
export const ABUT_EDIT_FIELDS: { key: AbutEditKey; label: string; unit: string }[] = [
|
||||
{ key: 'seatEl', label: '교좌면 EL (구간 ①)', unit: 'm' },
|
||||
{ key: 'foundationBottomEl', label: '기초 하단 EL', unit: 'm' },
|
||||
{ key: 'groundEl', label: '지반고 EL', unit: 'm' },
|
||||
{ key: 'pileLengthMm', label: '말뚝길이', unit: 'mm' },
|
||||
{ key: 'skewDeg', label: 'skew', unit: '°' },
|
||||
{ key: 'slopeI', label: '종단경사 i', unit: '' },
|
||||
];
|
||||
|
||||
/** The 저판 thickness (m) below the 기초 하단 is not part of this check; the 교좌면 must sit above the 기초 하단. */
|
||||
export function applyAbutEdit(rec: AbutRecord, key: AbutEditKey, value: number): AbutRecord {
|
||||
const label = ABUT_EDIT_FIELDS.find((f) => f.key === key)?.label ?? key;
|
||||
if (!Number.isFinite(value)) throw new Error(`${label}은(는) 숫자여야 합니다.`);
|
||||
if (key === 'pileLengthMm' && !(value > 0)) throw new Error(`${label}은(는) 0보다 커야 합니다.`);
|
||||
if (key === 'skewDeg' && !(value > 0 && value < 180)) throw new Error(`${label}은(는) 0 초과 180 미만이어야 합니다.`);
|
||||
if (key === 'slopeI' && Math.abs(value) > 0.2) throw new Error(`${label}은(는) ±0.2 이내여야 합니다.`);
|
||||
const next: AbutRecord = { ...rec, [key]: value };
|
||||
if (next.seatEl <= next.foundationBottomEl) {
|
||||
throw new Error('교좌면 EL은 기초 하단 EL보다 높아야 합니다.');
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
/** 날개벽 형식은 측별로 정한다 (2026-11-04: "좌우 같게" 없음). */
|
||||
export function setWingForm(rec: AbutRecord, side: 'left' | 'right', form: WingForm): AbutRecord {
|
||||
const current = rec.wings ?? { left: 'general' as WingForm, right: 'general' as WingForm };
|
||||
return { ...rec, wings: { ...current, [side]: form } };
|
||||
}
|
||||
|
||||
/**
|
||||
* 반대쪽에도 적용 (2026-11-04): copies this side's wing dims to the other side once (no lasting link).
|
||||
* 두께 · 끝 높이 · 헌치는 늘 복사하고, 길이는 두 측의 형식이 같을 때만 복사한다 (단축형 길이 기본 = 뒷굽 끝).
|
||||
* The other side must have a wing.
|
||||
*/
|
||||
export function copyWingDims(rec: AbutRecord, side: 'left' | 'right'): AbutRecord {
|
||||
const other = side === 'left' ? 'right' : 'left';
|
||||
const forms = rec.wings ?? { left: 'general' as WingForm, right: 'general' as WingForm };
|
||||
if (forms[other] === 'none') throw new Error('반대쪽 날개벽이 없습니다.');
|
||||
const from = rec.wingDims?.[side] ?? {};
|
||||
const to: Partial<WingDims> = { ...(rec.wingDims?.[other] ?? {}) };
|
||||
for (const key of ['thicknessMm', 'endHeightMm', 'haunchMm'] as const) {
|
||||
if (from[key] === undefined) delete to[key];
|
||||
else to[key] = from[key];
|
||||
}
|
||||
if (forms[side] === forms[other]) {
|
||||
if (from.lengthMm === undefined) delete to.lengthMm;
|
||||
else to.lengthMm = from.lengthMm;
|
||||
}
|
||||
return { ...rec, wingDims: { ...(rec.wingDims ?? {}), [other]: to } };
|
||||
}
|
||||
|
||||
/** 날개벽 치수 입력 칸 (② 날개벽 카드, 시안 순서). 단축형에는 끝 높이가 없다. */
|
||||
export const WING_DIM_FIELDS: { key: keyof WingDims; label: string; general: boolean; short: boolean }[] = [
|
||||
{ key: 'lengthMm', label: '길이 (mm)', general: true, short: true },
|
||||
{ key: 'thicknessMm', label: '두께 (mm)', general: true, short: true },
|
||||
{ key: 'endHeightMm', label: '끝 높이 (mm)', general: true, short: false },
|
||||
{ key: 'haunchMm', label: '헌치 (mm, 300 × 300의 한 변)', general: true, short: true },
|
||||
];
|
||||
|
||||
/** Changes one wing dim of one side. Length, thickness and end height must be > 0, the haunch ≥ 0. */
|
||||
export function applyWingDim(rec: AbutRecord, side: 'left' | 'right', key: keyof WingDims, value: number): AbutRecord {
|
||||
if (!Number.isFinite(value)) throw new Error('숫자를 입력하세요.');
|
||||
const issues = wingDimsIssues({ ...WING_DIMS_DEFAULT, [key]: value });
|
||||
if (issues.length > 0) throw new Error(issues[0]);
|
||||
return { ...rec, wingDims: { ...(rec.wingDims ?? {}), [side]: { ...(rec.wingDims?.[side] ?? {}), [key]: value } } };
|
||||
}
|
||||
|
||||
/** 받침 값 편집 (2026-11-03): z (패드 상면, m) or 교좌면 override (m, undefined = 자동 — 블럭 높이 규칙을 따른다). */
|
||||
export function applyBearingEdit(rec: AbutRecord, no: number, key: 'z' | 'seatEl', value: number | undefined): AbutRecord {
|
||||
const target = rec.bearings.find((b) => b.no === no);
|
||||
if (!target) throw new Error(`받침 ${no}번이 없습니다.`);
|
||||
if (key === 'z' && (value === undefined || !Number.isFinite(value))) throw new Error('z는 숫자여야 합니다.');
|
||||
if (key === 'seatEl' && value !== undefined && !Number.isFinite(value)) throw new Error('교좌면 EL은 숫자이거나 비워야 합니다.');
|
||||
const next = rec.bearings.map((b) => {
|
||||
if (b.no !== no) return b;
|
||||
if (key === 'z') return { ...b, z: value as number };
|
||||
return value === undefined ? { ...b, seatEl: undefined } : { ...b, seatEl: value };
|
||||
});
|
||||
const out: AbutRecord = { ...rec, bearings: next };
|
||||
// The 교좌면 of every bearing must still lie above the 기초 하단 (the 저판 thickness is checked in the geometry step).
|
||||
const lowest = Math.min(...out.bearings.map((b) => b.seatEl ?? out.seatEl + (b.z - Math.min(...out.bearings.map((x) => x.z)))));
|
||||
if (lowest <= out.foundationBottomEl) throw new Error(`받침 ${no}번 교좌면 EL이 기초 하단 EL보다 낮거나 같습니다.`);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 기본값 복원: the record as imported from the workbook (the wing forms go back to 양측 일반형). */
|
||||
export function restoreAbutDefault(defaults: AbutRecord): AbutRecord {
|
||||
return JSON.parse(JSON.stringify(defaults)) as AbutRecord;
|
||||
}
|
||||
|
||||
/** 형상 입력 change (벽체·흉벽, 확대기초·말뚝, mm): refused with a Korean message when the shape would not build. */
|
||||
/** Several shape values at once (e.g. the 열 위치 "650 · 2000 · 1300 · 650"), checked together. */
|
||||
export function applyShapeEdits(rec: AbutRecord, values: Partial<AbutShape>): AbutRecord {
|
||||
for (const v of Object.values(values)) if (!Number.isFinite(v)) throw new Error('숫자를 입력하세요.');
|
||||
const shape = { ...(rec.shape ?? {}), ...values };
|
||||
const issues = [...shapeIssues(shape), ...slabFitIssues(rec.slab, shape)];
|
||||
if (issues.length > 0) throw new Error(issues[0]);
|
||||
return { ...rec, shape };
|
||||
}
|
||||
|
||||
export function applyShapeEdit(rec: AbutRecord, key: keyof AbutShape, value: number): AbutRecord {
|
||||
if (!Number.isFinite(value)) throw new Error('숫자를 입력하세요.');
|
||||
const shape = { ...(rec.shape ?? {}), [key]: value };
|
||||
const issues = [...shapeIssues(shape), ...slabFitIssues(rec.slab, shape)];
|
||||
if (issues.length > 0) throw new Error(issues[0]);
|
||||
return { ...rec, shape };
|
||||
}
|
||||
|
||||
/** 브라켓 높이 (mm) of a shape input, default when absent. */
|
||||
function bracketHeightMm(shape: Partial<AbutShape> | undefined): number {
|
||||
return { ...ABUT_SHAPE_DEFAULT, ...normalizeShape(shape) }.bracketHeightMm;
|
||||
}
|
||||
|
||||
/** A lower 브라켓 must still hold the 접속슬래브 under the 흉벽 상단. */
|
||||
function slabFitIssues(slab: Partial<AbutSlab> | undefined, shape: Partial<AbutShape>): string[] {
|
||||
const s = resolveSlab(slab);
|
||||
const h = bracketHeightMm(shape);
|
||||
return s.thicknessMm > h ? [`브라켓 높이(${h})는 접속슬래브 두께(${s.thicknessMm}) 이상이어야 합니다.`] : [];
|
||||
}
|
||||
|
||||
/** 접속슬래브 형식 (2026-11-04); the form also sets the slope (매립형 하향 4 %, 노출형 종단경사). */
|
||||
export function setSlabForm(rec: AbutRecord, form: SlabForm): AbutRecord {
|
||||
const { slope: _old, ...kept } = (rec.slab ?? {}) as Partial<AbutSlab> & { slope?: unknown };
|
||||
const slab: Partial<AbutSlab> = { ...kept, form };
|
||||
const issues = slabIssues(resolveSlab(slab), bracketHeightMm(rec.shape));
|
||||
if (issues.length > 0) throw new Error(issues[0]);
|
||||
return { ...rec, slab };
|
||||
}
|
||||
|
||||
/** 접속슬래브 입력칸 (② 접속슬래브 카드, 시안 순서). 헌치 길이는 노출형만. */
|
||||
export const SLAB_EDIT_FIELDS: { key: 'lengthMm' | 'thicknessMm' | 'parapetGapMm' | 'wingGapMm' | 'haunchLengthMm'; label: string; exposedOnly?: boolean; note?: string }[] = [
|
||||
{ key: 'lengthMm', label: '길이 (mm)' },
|
||||
{ key: 'thicknessMm', label: '두께 (mm)' },
|
||||
{ key: 'parapetGapMm', label: '흉벽과 간격 (mm)' },
|
||||
{ key: 'wingGapMm', label: '날개벽과 간격 (mm)' },
|
||||
{ key: 'haunchLengthMm', label: '헌치 길이 (mm)', exposedOnly: true, note: '가정 · 삽도 비율' },
|
||||
];
|
||||
|
||||
/**
|
||||
* 지반·지층 입력 (2026-11-04): merges the given values into the record's 지반; undefined removes a value. Numbers must be finite.
|
||||
* The values are checked by the card (G01–G14), not refused here, as on the pier card.
|
||||
*/
|
||||
export function setAbutGround(rec: AbutRecord, patch: Partial<AbutGround>): AbutRecord {
|
||||
for (const [key, v] of Object.entries(patch)) {
|
||||
if (typeof v === 'number' && !Number.isFinite(v)) throw new Error('숫자를 입력하세요.');
|
||||
if (key === 'strataRows' && (v as { bottomEl: number }[]).some((r) => !Number.isFinite(r.bottomEl))) throw new Error('지층 하단 EL은 숫자여야 합니다.');
|
||||
}
|
||||
const ground: AbutGround = { ...(rec.ground ?? {}), ...patch };
|
||||
for (const key of Object.keys(ground) as (keyof AbutGround)[]) if (ground[key] === undefined) delete ground[key];
|
||||
return { ...rec, ground };
|
||||
}
|
||||
|
||||
/**
|
||||
* ③ 저판 배근 입력 (2026-11-04): merges the values; undefined removes one (a 철근별 간격 goes back to the 기본간격).
|
||||
* Refused with a Korean message when the settings would not lay out.
|
||||
*/
|
||||
export function applyFootingRebar(rec: AbutRecord, patch: Partial<AbutFootingRebarSettings>): AbutRecord {
|
||||
for (const v of Object.values(patch)) if (typeof v === 'number' && !Number.isFinite(v)) throw new Error('숫자를 입력하세요.');
|
||||
const footing: Partial<AbutFootingRebarSettings> = { ...(rec.rebar?.footing ?? {}), ...patch };
|
||||
for (const key of Object.keys(footing) as (keyof AbutFootingRebarSettings)[]) if (footing[key] === undefined) delete footing[key];
|
||||
const issues = footingRebarIssues(resolveFootingRebar(footing));
|
||||
if (issues.length > 0) throw new Error(issues[0]);
|
||||
return { ...rec, rebar: { ...(rec.rebar ?? {}), footing } };
|
||||
}
|
||||
|
||||
/** Changes ③ 벽체 배근 values (only the changed ones are kept; undefined clears one), refused with a Korean message. */
|
||||
export function applyWallRebar(rec: AbutRecord, patch: Partial<AbutWallRebarSettings>): AbutRecord {
|
||||
for (const v of Object.values(patch)) if (typeof v === 'number' && !Number.isFinite(v)) throw new Error('숫자를 입력하세요.');
|
||||
const wall: Partial<AbutWallRebarSettings> = { ...(rec.rebar?.wall ?? {}), ...patch };
|
||||
for (const key of Object.keys(wall) as (keyof AbutWallRebarSettings)[]) if (wall[key] === undefined) delete wall[key];
|
||||
const issues = wallRebarIssues(resolveWallRebar(wall));
|
||||
if (issues.length > 0) throw new Error(issues[0]);
|
||||
return { ...rec, rebar: { ...(rec.rebar ?? {}), wall } };
|
||||
}
|
||||
|
||||
/** Changes ③ 흉벽 · 브라켓 배근 values (only the changed ones are kept; undefined clears one), refused with a Korean message. */
|
||||
export function applyParapetRebar(rec: AbutRecord, patch: Partial<AbutParapetRebarSettings>): AbutRecord {
|
||||
for (const v of Object.values(patch)) if (typeof v === 'number' && !Number.isFinite(v)) throw new Error('숫자를 입력하세요.');
|
||||
const parapet: Partial<AbutParapetRebarSettings> = { ...(rec.rebar?.parapet ?? {}), ...patch };
|
||||
for (const key of Object.keys(parapet) as (keyof AbutParapetRebarSettings)[]) if (parapet[key] === undefined) delete parapet[key];
|
||||
const issues = parapetRebarIssues(resolveParapetRebar(parapet));
|
||||
if (issues.length > 0) throw new Error(issues[0]);
|
||||
return { ...rec, rebar: { ...(rec.rebar ?? {}), parapet } };
|
||||
}
|
||||
|
||||
/** Changes ③ 날개벽 배근 values (좌우 공통, 사용자 결정 2026-11-04; only the changed ones are kept), refused with a Korean message. */
|
||||
export function applyWingRebar(rec: AbutRecord, patch: Partial<AbutWingRebarSettings>): AbutRecord {
|
||||
for (const v of Object.values(patch)) if (typeof v === 'number' && !Number.isFinite(v)) throw new Error('숫자를 입력하세요.');
|
||||
const wing: Partial<AbutWingRebarSettings> = { ...(rec.rebar?.wing ?? {}), ...patch };
|
||||
for (const key of Object.keys(wing) as (keyof AbutWingRebarSettings)[]) if (wing[key] === undefined) delete wing[key];
|
||||
const issues = wingRebarIssues(resolveWingRebar(wing));
|
||||
if (issues.length > 0) throw new Error(issues[0]);
|
||||
return { ...rec, rebar: { ...(rec.rebar ?? {}), wing } };
|
||||
}
|
||||
|
||||
/** Changes ③ 접속슬래브 배근 values (only the changed ones are kept; undefined clears one), refused with a Korean message. */
|
||||
export function applySlabRebar(rec: AbutRecord, patch: Partial<AbutSlabRebarSettings>): AbutRecord {
|
||||
for (const v of Object.values(patch)) if (typeof v === 'number' && !Number.isFinite(v)) throw new Error('숫자를 입력하세요.');
|
||||
const slab: Partial<AbutSlabRebarSettings> = { ...(rec.rebar?.slab ?? {}), ...patch };
|
||||
for (const key of Object.keys(slab) as (keyof AbutSlabRebarSettings)[]) if (slab[key] === undefined) delete slab[key];
|
||||
const issues = slabRebarIssues(resolveSlabRebar(slab));
|
||||
if (issues.length > 0) throw new Error(issues[0]);
|
||||
return { ...rec, rebar: { ...(rec.rebar ?? {}), slab } };
|
||||
}
|
||||
|
||||
/** Changes ③ 받침블럭 배근 values (only the changed ones are kept; undefined clears one), refused with a Korean message. */
|
||||
export function applyBlockRebar(rec: AbutRecord, patch: Partial<AbutBlockSettings>): AbutRecord {
|
||||
for (const v of Object.values(patch)) if (typeof v === 'number' && !Number.isFinite(v)) throw new Error('숫자를 입력하세요.');
|
||||
const block: Partial<AbutBlockSettings> = { ...(rec.rebar?.block ?? {}), ...patch };
|
||||
for (const key of Object.keys(block) as (keyof AbutBlockSettings)[]) if (block[key] === undefined) delete block[key];
|
||||
const issues = blockRebarIssues(resolveBlockRebar(block));
|
||||
if (issues.length > 0) throw new Error(issues[0]);
|
||||
return { ...rec, rebar: { ...(rec.rebar ?? {}), block } };
|
||||
}
|
||||
|
||||
/** Changes one 접속슬래브 dim, refused with a Korean message when the slab would not build. */
|
||||
export function applySlabEdit(rec: AbutRecord, key: (typeof SLAB_EDIT_FIELDS)[number]['key'], value: number): AbutRecord {
|
||||
if (!Number.isFinite(value)) throw new Error('숫자를 입력하세요.');
|
||||
const slab = { ...(rec.slab ?? {}), [key]: value };
|
||||
const issues = slabIssues(resolveSlab(slab), bracketHeightMm(rec.shape));
|
||||
if (issues.length > 0) throw new Error(issues[0]);
|
||||
return { ...rec, slab };
|
||||
}
|
||||
|
||||
|
||||
/** 형상 입력 fields on the 교대 카드 (시안 ② 벽체·흉벽, 확대기초·말뚝), in the order of the mockup. */
|
||||
export const SHAPE_EDIT_FIELDS: { key: keyof AbutShape; label: string; group: 'wall' | 'footing'; note?: string }[] = [
|
||||
// 벽체 · 흉벽 (시안 입력 격자 순서). 벽체 두께는 자동 = 브라켓 폭 + 흉벽 두께 + 교좌면 폭.
|
||||
{ key: 'wallFrontMm', label: '교좌면 폭 (mm)', group: 'wall' },
|
||||
{ key: 'parapetHeightMm', label: '벽체 높이 · 받침 ① 기준 연직 (mm)', group: 'wall', note: '받침 ① 교좌면 ~ 흉벽 상단' },
|
||||
{ key: 'parapetThicknessMm', label: '흉벽 두께 (mm)', group: 'wall' },
|
||||
{ key: 'bracketHeightMm', label: '브라켓 높이 (mm)', group: 'wall', note: '= 접속슬래브 카드 흉벽 높이' },
|
||||
{ key: 'bracketWidthMm', label: '브라켓 폭 (mm)', group: 'wall' },
|
||||
{ key: 'shrinkJointCount', label: '수축줄눈 개수', group: 'wall' },
|
||||
{ key: 'parapetLevelLeftMm', label: '흉벽 상단 Level 좌 (mm)', group: 'wall', note: '기준 확인 QA-4' },
|
||||
{ key: 'parapetLevelRightMm', label: '흉벽 상단 Level 우 (mm)', group: 'wall', note: '기준 확인 QA-4' },
|
||||
{ key: 'seatStepIgnoreMm', label: '인접 무시 기준 (mm)', group: 'wall' },
|
||||
// 확대기초 · 말뚝 (분리 카드, 시안 순서). 뒷굽 · 길이 · 선단 EL은 자동, 열 위치와 열별 본수는 카드에서 따로 입력한다.
|
||||
{ key: 'footingWidthMm', label: '폭 (mm)', group: 'footing' },
|
||||
{ key: 'footingThicknessMm', label: '두께 (mm)', group: 'footing' },
|
||||
{ key: 'footingToeMm', label: '앞굽 (mm)', group: 'footing' },
|
||||
{ key: 'blindingMarginMm', label: '버림 여유 한쪽 (mm)', group: 'footing' },
|
||||
{ key: 'blindingThicknessMm', label: '버림 두께 (mm)', group: 'footing' },
|
||||
{ key: 'pileDiameterMm', label: '말뚝 지름 (mm)', group: 'footing', note: 'QA-5' },
|
||||
{ key: 'pileEdgeMm', label: '연단 · 길이 방향 (mm)', group: 'footing' },
|
||||
{ key: 'pileSpacingMm', label: '본수 규칙 간격 (mm)', group: 'footing' },
|
||||
];
|
||||
|
||||
/** 받침 규칙 입력칸 (② 교량받침 카드): 교각 솔플레이트 규칙과 같은 칸. 받침높이 H는 교대 받침 시트 값으로 고정 (2026-11-03). */
|
||||
/** 교대 솔플레이트 입력 칸 (2026-11-06). Optional ones (h3, T, S) go back to the automatic value when cleared. */
|
||||
export const BEARING_RULE_FIELDS: { key: keyof AbutBearingRule; label: string; optional?: boolean }[] = [
|
||||
{ key: 'h3Mm', label: '받침 높이 h3 (mm, 비우면 받침높이 − T_ref)', optional: true },
|
||||
{ key: 'tRefMm', label: 'T_ref · 엑셀 z 산정 때 두께 (mm)' },
|
||||
{ key: 'tMinMm', label: '솔플 최소두께 T_min (mm)' },
|
||||
{ key: 'marginMm', label: '솔플 여유 L − A = W − B (mm)' },
|
||||
{ key: 'aMm', label: '받침 A · 거더 방향 (mm)' },
|
||||
{ key: 'bMm', label: '받침 B (mm)' },
|
||||
{ key: 'tMm', label: '솔플레이트 T (mm, 비우면 자동)', optional: true },
|
||||
{ key: 'slopePct', label: 'S 직접 수정 (%, 비우면 짝 받침 계산)', optional: true },
|
||||
];
|
||||
|
||||
/**
|
||||
* Changes one 솔플레이트 입력 value (2026-11-06); `undefined` clears an optional one back to automatic. The result must
|
||||
* still build a solplate (positive sizes, h3 > 0 with the 교대 받침높이).
|
||||
*/
|
||||
export function applyBearingRule(rec: AbutRecord, key: keyof AbutBearingRule, value: number | undefined): AbutRecord {
|
||||
const bearingRule: Record<string, unknown> = { ...(rec.bearingRule ?? {}) };
|
||||
if (value === undefined) delete bearingRule[key];
|
||||
else {
|
||||
const n = Number(value);
|
||||
if (!Number.isFinite(n)) throw new Error('솔플레이트 입력 값은 숫자여야 합니다.');
|
||||
bearingRule[key] = n;
|
||||
}
|
||||
const issues = abutBearingRuleIssues(resolveBearingRule(bearingRule), rec.bearingHeightMm);
|
||||
if (issues.length > 0) throw new Error(issues[0]);
|
||||
return { ...rec, bearingRule };
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
/**
|
||||
* 교대 형상 · AA1 (2026-11-03, spec_abut §1·§4.1–4.2): 교대축, 전면 쪽, 교좌면 위의 받침, 교좌면 EL과 블럭 높이를 계산한다.
|
||||
* 순수 함수. 좌표는 (E, N) m 단위, EL은 m 단위, 길이는 별도 표기가 없으면 mm 단위다.
|
||||
*
|
||||
* - 축: IP1 → IP3 선(교대중심선), 길이 L. u = IP1 → IP3 단위벡터, nLeft = u를 반시계 90° 돌린 벡터.
|
||||
* - 받침 s: IP1에서 u 방향으로 잰 거리(m). 축에서 벗어난 거리(lateral): nLeft 방향 거리(m).
|
||||
* - z = 패드 상면(EL.X), 교각과 같다(2026-11-03 결정 1). 교좌면에서 패드 상면까지 블럭 높이는 125 mm
|
||||
* (콘크리트 75 + 패드 50). 교좌면 EL은 콘크리트 하면이다.
|
||||
* - 교좌면: 교대 전체에 교좌면 EL 하나를 입력받는다(첫 받침 ①). 블럭 높이 h = min(z) − 교좌면으로 모든 받침이 같고,
|
||||
* 받침 i의 교좌면 = z_i − h. 받침이 자기 교좌면 EL(seatEl)을 직접 주면 그 값을 쓰고, 블럭 높이는 z_i − seatEl이 된다
|
||||
* (2026-11-03 결정 3).
|
||||
* - 전면: 받침이 있는 쪽. A1은 전면이 1 → n 방향의 왼쪽, 흉벽은 오른쪽에 있다. A2는 반대다(spec_abut §1).
|
||||
* 맞지 않으면 AW03 경고 대상이다.
|
||||
*/
|
||||
|
||||
export type AbutType = 'A1' | 'A2';
|
||||
|
||||
export interface AbutPoint {
|
||||
e: number;
|
||||
n: number;
|
||||
}
|
||||
|
||||
export interface AbutBearing {
|
||||
no: number;
|
||||
/** 위치 (E, N), m. */
|
||||
pos: AbutPoint;
|
||||
/** 패드 상면 EL (z), m. */
|
||||
z: number;
|
||||
/** 이 받침의 교좌면 EL (m). 없으면 z에서 교대 블럭 높이를 뺀 값을 쓴다. */
|
||||
seatEl?: number;
|
||||
/** 받침 방위각 (deg, model-xy: 북에서 시계방향, 교각과 같은 변환 swapXYAzimuth). 없으면 블럭은 교대축에 맞춘다. */
|
||||
azimuthDeg?: number;
|
||||
}
|
||||
|
||||
/** 날개벽 형식, 측별(spec_abut §4.7): 일반형 / 단축형 / 없음. */
|
||||
export type WingForm = 'general' | 'short' | 'none';
|
||||
|
||||
export interface AbutInput {
|
||||
name: string;
|
||||
type: AbutType;
|
||||
/** 첫 받침(구간 ①)의 교좌면 EL (m). 블럭 높이 h = min(z) − seatEl. */
|
||||
seatEl: number;
|
||||
ip1: AbutPoint;
|
||||
ip2: AbutPoint;
|
||||
ip3: AbutPoint;
|
||||
bearings: AbutBearing[];
|
||||
/** skew (deg): 받침축을 시계방향으로 돌린 선이 날개벽 선이다(spec_abut §1). 없으면 날개벽 검사를 하지 않는다. */
|
||||
skewDeg?: number;
|
||||
/** 종단경사 i (소수, 교량진행방향 기준, (−) = 낮아짐). 날개벽 경사는 A1이 −i, A2가 +i다. */
|
||||
slopeI?: number;
|
||||
/** AW01용 거더 방향 CAD 각 (deg). 없으면 AW01을 검사하지 않는다. */
|
||||
girderAzimuthDeg?: number;
|
||||
/** 측별 날개벽 형식: 'left' = 받침 ① 쪽 끝, 'right' = 받침 n 쪽 끝(spec §4.7). 기본은 양측 일반형. */
|
||||
wings?: { left: WingForm; right: WingForm };
|
||||
/** 측별 날개벽 두께 (mm). 없으면 450. 접속슬래브 폭에 쓴다. */
|
||||
wingThicknessMm?: { left?: number; right?: number };
|
||||
/** 접속슬래브와 날개벽 안쪽면 사이 간격 (mm, ② 접속슬래브 카드). 없으면 20. */
|
||||
slabWingGapMm?: number;
|
||||
/** 기초 하단 EL (m), 말뚝길이 (mm), 기초 형식 (AA3, 저판·말뚝). 없으면 저판·말뚝을 만들지 않는다. */
|
||||
foundationBottomEl?: number;
|
||||
pileLengthMm?: number;
|
||||
foundationType?: string;
|
||||
}
|
||||
|
||||
export interface AbutWingModel {
|
||||
/** 날개벽 선, CAD 각 (deg), 배면 쪽(예제: A1 ≈ 255.32, A2 ≈ 75.32). */
|
||||
lineDeg: number;
|
||||
/** 날개벽 선과 거더 방향의 사잇각, 0–90°로 접음(AW01 허용 1°). */
|
||||
girderDiffDeg: number;
|
||||
aw01Ok: boolean;
|
||||
/** 날개벽 경사 (%), (+) = 벽체에서 멀어질수록 높아짐. */
|
||||
slopePct: number;
|
||||
/** 날개벽 사이 접속슬래브 폭 (mm) = L − Σ(두께 / |sin Δ| + 날개벽과 간격), 있는 날개벽마다. */
|
||||
slabWidthMm: number;
|
||||
wings: { left: WingForm; right: WingForm };
|
||||
}
|
||||
|
||||
export interface AbutBearingModel {
|
||||
no: number;
|
||||
/** IP1에서 u 방향 거리 (m). */
|
||||
s: number;
|
||||
/** nLeft 방향으로 잰 축에서의 부호 있는 거리 (m). */
|
||||
lateral: number;
|
||||
z: number;
|
||||
seatEl: number;
|
||||
/** 교좌면에서 패드 상면까지 블럭 높이 (mm), 패드 포함. */
|
||||
blockHeightMm: number;
|
||||
/** 받침 방위각 (deg, model-xy). 입력이 없으면 undefined. */
|
||||
azimuthDeg?: number;
|
||||
/** 블럭 평면 회전 (deg, −90 … 90): 받침 방위각과 교대축 직각(전면) 방향 사이의 각. 장면에서는 rotateY(이 값). 입력이 없으면 0. */
|
||||
rotDeg: number;
|
||||
}
|
||||
|
||||
export interface AbutModel {
|
||||
name: string;
|
||||
type: AbutType;
|
||||
/** IP1 (축의 시작점, s = 0). */
|
||||
ip1: AbutPoint;
|
||||
/** 축 길이 IP1 → IP3 (m). */
|
||||
length: number;
|
||||
u: AbutPoint;
|
||||
nLeft: AbutPoint;
|
||||
/** 받침이 축의 nLeft 쪽이면 +1, 반대쪽이면 −1. */
|
||||
frontSide: 1 | -1;
|
||||
/** 받침이 1 → n 방향의 왼쪽이면 true (A1 배치). */
|
||||
frontLeftOfBearingDir: boolean;
|
||||
/** 입력 순서의 받침. */
|
||||
bearings: AbutBearingModel[];
|
||||
/** 축 위 계단 경계(IP1에서 m), 이웃 받침 사이의 중간. */
|
||||
stepBounds: number[];
|
||||
/** AW03: 배치가 형식과 맞으면 true. */
|
||||
layoutMatchesType: boolean;
|
||||
/** 날개벽 선, 경사, AW01, 접속슬래브 폭. 입력에 skew가 없으면 없다. */
|
||||
wing?: AbutWingModel;
|
||||
}
|
||||
|
||||
const WING_THICKNESS_MM = 450;
|
||||
const SLAB_CLEARANCE_MM = 20;
|
||||
const AW01_TOLERANCE_DEG = 1;
|
||||
|
||||
/** 공통 규칙의 패드 두께 (mm). 블럭 높이에 포함된다(패드 포함). */
|
||||
export const ABUT_PAD_MM = 50;
|
||||
|
||||
const sub = (a: AbutPoint, b: AbutPoint): AbutPoint => ({ e: a.e - b.e, n: a.n - b.n });
|
||||
|
||||
/**
|
||||
* 블럭 평면 회전 (deg): the bearing azimuth direction d = (sin az, cos az) seen in the axis frame (s along u, t along the front),
|
||||
* folded to −90 … 90 because the block is symmetric. 0 when the azimuth is missing.
|
||||
*/
|
||||
export function bearingRotDeg(azimuthDeg: number | undefined, u: AbutPoint, front: AbutPoint): number {
|
||||
if (azimuthDeg === undefined || !Number.isFinite(azimuthDeg)) return 0;
|
||||
const r = (azimuthDeg * Math.PI) / 180;
|
||||
const d = { e: Math.sin(r), n: Math.cos(r) };
|
||||
let phi = (Math.atan2(d.e * u.e + d.n * u.n, d.e * front.e + d.n * front.n) * 180) / Math.PI;
|
||||
if (phi > 90) phi -= 180;
|
||||
if (phi < -90) phi += 180;
|
||||
return phi;
|
||||
}
|
||||
const dot = (a: AbutPoint, b: AbutPoint) => a.e * b.e + a.n * b.n;
|
||||
const cross = (a: AbutPoint, b: AbutPoint) => a.e * b.n - a.n * b.e;
|
||||
|
||||
export function buildAbut(input: AbutInput): AbutModel {
|
||||
if (input.bearings.length < 2) throw new Error(`교대 ${input.name}: 받침이 2개 이상 필요합니다.`);
|
||||
const axisVec = sub(input.ip3, input.ip1);
|
||||
const length = Math.hypot(axisVec.e, axisVec.n);
|
||||
if (!(length > 0)) throw new Error(`교대 ${input.name}: IP1과 IP3이 같은 점입니다.`);
|
||||
const u = { e: axisVec.e / length, n: axisVec.n / length };
|
||||
const nLeft = { e: -u.n, n: u.e };
|
||||
|
||||
const lateral = input.bearings.map((b) => dot(sub(b.pos, input.ip1), nLeft));
|
||||
const meanLateral = lateral.reduce((a, x) => a + x, 0) / lateral.length;
|
||||
const frontSide: 1 | -1 = meanLateral >= 0 ? 1 : -1;
|
||||
|
||||
// 1 → n 방향: 첫 받침(①)에서 마지막 받침까지. 전면 = 받침 쪽. 형식과 맞는지 확인한다.
|
||||
const first = input.bearings[0];
|
||||
const last = input.bearings[input.bearings.length - 1];
|
||||
const dir = sub(last.pos, first.pos);
|
||||
const frontVec = { e: nLeft.e * frontSide, n: nLeft.n * frontSide };
|
||||
const frontLeftOfBearingDir = cross(dir, frontVec) > 0;
|
||||
const layoutMatchesType = input.type === 'A1' ? frontLeftOfBearingDir : !frontLeftOfBearingDir;
|
||||
|
||||
const minZ = Math.min(...input.bearings.map((b) => b.z));
|
||||
const bearings: AbutBearingModel[] = input.bearings.map((b, i) => {
|
||||
const s = dot(sub(b.pos, input.ip1), u);
|
||||
const seatEl = b.seatEl ?? input.seatEl + (b.z - minZ);
|
||||
const blockHeightMm = Math.round((b.z - seatEl) * 1000);
|
||||
return { no: b.no, s, lateral: lateral[i], z: b.z, seatEl, blockHeightMm, azimuthDeg: b.azimuthDeg, rotDeg: bearingRotDeg(b.azimuthDeg, u, frontVec) };
|
||||
});
|
||||
|
||||
const sorted = [...bearings].sort((a, b) => a.s - b.s);
|
||||
const stepBounds = sorted.slice(1).map((b, i) => (sorted[i].s + b.s) / 2);
|
||||
|
||||
const wing = input.skewDeg === undefined ? undefined : buildWing(input, u, nLeft, frontSide, dir, length);
|
||||
return { name: input.name, type: input.type, ip1: input.ip1, length, u, nLeft, frontSide, frontLeftOfBearingDir, bearings, stepBounds, layoutMatchesType, wing };
|
||||
}
|
||||
|
||||
const deg = (rad: number) => (rad * 180) / Math.PI;
|
||||
const norm360 = (x: number) => ((x % 360) + 360) % 360;
|
||||
/** 두 선 방향의 차이(deg), 0–90°로 접는다. 선에는 방향이 없다. */
|
||||
const lineDiff = (a: number, b: number) => {
|
||||
const d = norm360(a - b) % 180;
|
||||
return Math.min(d, 180 - d);
|
||||
};
|
||||
|
||||
function buildWing(input: AbutInput, u: AbutPoint, nLeft: AbutPoint, frontSide: 1 | -1, dir: AbutPoint, length: number): AbutWingModel {
|
||||
const skew = input.skewDeg!;
|
||||
const bearingCad = norm360(deg(Math.atan2(dir.n, dir.e)));
|
||||
// skew만큼 시계방향으로 돌린 선이 날개벽 선이다. 배면 쪽(받침 반대쪽)이 방향을 정한다.
|
||||
const line = norm360(bearingCad - skew);
|
||||
const back = { e: -nLeft.e * frontSide, n: -nLeft.n * frontSide };
|
||||
const radLine = (line * Math.PI) / 180;
|
||||
const pick = Math.cos(radLine) * back.e + Math.sin(radLine) * back.n > 0 ? line : norm360(line + 180);
|
||||
const axisCad = norm360(deg(Math.atan2(u.n, u.e)));
|
||||
const sinDelta = Math.abs(Math.sin(((pick - axisCad) * Math.PI) / 180));
|
||||
const wings = input.wings ?? { left: 'general' as WingForm, right: 'general' as WingForm };
|
||||
// 날개벽 두께는 측별 입력 (2026-11-04, 없으면 450): 축 방향 폭 = 두께 / |sin Δ|.
|
||||
const thick = { left: input.wingThicknessMm?.left ?? WING_THICKNESS_MM, right: input.wingThicknessMm?.right ?? WING_THICKNESS_MM };
|
||||
const gap = input.slabWingGapMm ?? SLAB_CLEARANCE_MM;
|
||||
const present = (f: WingForm, side: 'left' | 'right') => (f === 'none' ? 0 : thick[side] / sinDelta + gap);
|
||||
const slabWidthMm = Math.round(length * 1000 - present(wings.left, 'left') - present(wings.right, 'right'));
|
||||
const girderDiffDeg = input.girderAzimuthDeg === undefined ? NaN : lineDiff(pick, input.girderAzimuthDeg);
|
||||
const slopeI = input.slopeI ?? 0;
|
||||
return {
|
||||
lineDeg: pick,
|
||||
girderDiffDeg,
|
||||
aw01Ok: Number.isNaN(girderDiffDeg) ? true : girderDiffDeg <= AW01_TOLERANCE_DEG,
|
||||
slopePct: (input.type === 'A1' ? -slopeI : slopeI) * 100,
|
||||
slabWidthMm,
|
||||
wings,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* 교대 지반·지층 (2026-11-04, 시안 ② 지반·지층 카드 = 교각과 같은 카드). 순수 함수.
|
||||
*
|
||||
* - 입력은 교각과 같은 이름이다: 위치(육상 · 하상 · 해상), 지하수위, 설계홍수위 · 세굴, 고조위 · 저조위, 지층 행.
|
||||
* 지반고는 엑셀 입력(①에서 수정)이다. 표시만 하고 형상 계산에는 쓰지 않는다.
|
||||
* - 검사는 교각과 같다(G01–G14, 말뚝 근입 G04). 지반고가 저판 하면보다 낮으면 흙덮개가 없다는 정보 AI01을 더한다.
|
||||
*/
|
||||
import { SUPPORT_STRATUM, validateGround, validatePileEmbedment, validateStrataNValues, type GroundCheckInput } from '../ground';
|
||||
import type { Issue, PierInput } from '../types';
|
||||
import type { AbutMembers } from './members';
|
||||
import type { AbutRecord } from './parse';
|
||||
|
||||
export type AbutGround = Pick<PierInput, 'groundPosition' | 'groundWaterOn' | 'groundWaterEl' | 'floodLevelEl' | 'scourDepthM' | 'highWaterEl' | 'lowWaterEl' | 'strataRows'>;
|
||||
|
||||
/** 말뚝 두께 (mm): spec_abut 말뚝 Φ600 × 12T. Used only by the 해상 부식 여유 check. */
|
||||
const ABUT_PILE_THICKNESS_MM = 12;
|
||||
|
||||
/** The check input of an abutment: its 지반 values, 지반고 and 저판 하면 (기초 하단 when the 저판 is not built). */
|
||||
export function abutGroundCheckInput(rec: AbutRecord, members?: AbutMembers): GroundCheckInput {
|
||||
return {
|
||||
name: rec.name,
|
||||
groundEl: rec.groundEl,
|
||||
footingBottomEl: members?.footingBottomEl ?? rec.foundationBottomEl,
|
||||
pileThicknessMm: ABUT_PILE_THICKNESS_MM,
|
||||
...(rec.ground ?? {}),
|
||||
};
|
||||
}
|
||||
|
||||
/** 연암 상면 EL: the bottom of the row above the 연암 row (지반고 for the first row); undefined without a 연암 row. */
|
||||
export function supportTopEl(groundEl: number, rows: { name: string; bottomEl: number }[] | undefined): number | undefined {
|
||||
const list = rows ?? [];
|
||||
const k = list.findIndex((r) => r.name === SUPPORT_STRATUM);
|
||||
if (k < 0) return undefined;
|
||||
return k === 0 ? groundEl : list[k - 1].bottomEl;
|
||||
}
|
||||
|
||||
/** Issues of the 지반·지층 card: the pier checks plus AI01 (지반고 below the 저판 하면). */
|
||||
export function abutGroundIssues(rec: AbutRecord, members?: AbutMembers): Issue[] {
|
||||
const input = abutGroundCheckInput(rec, members);
|
||||
const issues: Issue[] = [];
|
||||
validateGround(input, issues);
|
||||
validateStrataNValues(input, issues);
|
||||
if (members && members.pileBottomEl !== undefined && members.piles.length > 0) {
|
||||
validatePileEmbedment(input, { piles: [{ bottom: members.pileBottomEl, diameter: members.shape.pileDiameterM }] }, issues);
|
||||
}
|
||||
if (rec.groundEl < input.footingBottomEl) {
|
||||
issues.push({
|
||||
code: 'AI01',
|
||||
level: 'I',
|
||||
pier: rec.name,
|
||||
message: `지반고 ${rec.groundEl.toFixed(3)}이 저판 하면 ${input.footingBottomEl.toFixed(3)}보다 ${(input.footingBottomEl - rec.groundEl).toFixed(3)} 낮아 흙덮개가 없습니다.`,
|
||||
});
|
||||
}
|
||||
return issues;
|
||||
}
|
||||
|
||||
/** True when any 지반 value differs from the default (육상, 지하수위 없음, 지층 행 없음). */
|
||||
export function abutGroundChanged(ground: AbutGround | undefined): boolean {
|
||||
if (!ground) return false;
|
||||
return Object.entries(ground).some(([key, v]) => {
|
||||
if (v === undefined) return false;
|
||||
if (key === 'groundPosition') return v !== '육상';
|
||||
if (key === 'groundWaterOn') return v === true;
|
||||
if (key === 'strataRows') return (v as unknown[]).length > 0;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { Issue } from '../types';
|
||||
|
||||
export interface AbutImportIssueGroup {
|
||||
name?: string;
|
||||
primary: Issue[];
|
||||
related: Issue[];
|
||||
}
|
||||
|
||||
/** Keep root errors visible while retaining every dependent row in a disclosure. */
|
||||
export function groupAbutImportIssues(issues: readonly Issue[]): AbutImportIssueGroup[] {
|
||||
const groups: { name?: string; issues: Issue[] }[] = [];
|
||||
const byName = new Map<string, { name: string; issues: Issue[] }>();
|
||||
for (const issue of issues) {
|
||||
if (issue.pier === undefined) {
|
||||
groups.push({ issues: [issue] });
|
||||
continue;
|
||||
}
|
||||
let group = byName.get(issue.pier);
|
||||
if (!group) {
|
||||
group = { name: issue.pier, issues: [] };
|
||||
byName.set(issue.pier, group);
|
||||
groups.push(group);
|
||||
}
|
||||
group.issues.push(issue);
|
||||
}
|
||||
return groups.map(({ name, issues: items }) => {
|
||||
const primary = items.filter((issue) => issue.code !== 'AP04');
|
||||
const related = items.filter((issue) => issue.code === 'AP04');
|
||||
if (!primary.length && related.length) primary.push({
|
||||
...related[0],
|
||||
message: `교대 ${name ?? ''}: 교대 정보 행이 없어 IP·받침 ${related.length}개 행을 연결하지 못했습니다. 전체 교대명을 확인하세요.`,
|
||||
});
|
||||
return { name, primary, related };
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* 교대 수축줄눈 (spec_abut §3 · §4.9, plan_abut AA13, QA-7 가정). Pure.
|
||||
*
|
||||
* - 개수: 입력값, 없으면 ceil(벽체 길이 / 10 m) − 1 (0 이상).
|
||||
* - 위치: 등분점 L·j / (n + 1)에서 가장 가까운 '받침 사이 중간'으로 옮긴다 (같은 거리면 s가 작은 쪽). 받침 사이 중간은
|
||||
* 교좌면 계단 경계와 같은 자리라 줄눈이 받침블럭을 지나지 않는다. 받침이 2개 미만이거나 중간점이 모자라면 등분점.
|
||||
* - 벽체 · 흉벽에만 두고 확대기초는 연속이다 (교대확대기초철근.md). 철근은 끊지 않는다 (spec_abut §6.1).
|
||||
*/
|
||||
|
||||
/** 기본 개수: ceil(L / 10) − 1, L in m. */
|
||||
export function defaultJointCount(lengthM: number): number {
|
||||
return Math.max(0, Math.ceil(lengthM / 10 - 1e-9) - 1);
|
||||
}
|
||||
|
||||
/** The count in use: the typed value, else the default from the length. */
|
||||
export function jointCount(lengthM: number, typed?: number): number {
|
||||
return typed ?? defaultJointCount(lengthM);
|
||||
}
|
||||
|
||||
/** Joint positions s (m from IP1, sorted). `bearingS` = the bearings' s along the 교대축. */
|
||||
export function jointPositions(lengthM: number, bearingS: number[], typed?: number): number[] {
|
||||
const n = jointCount(lengthM, typed);
|
||||
const sorted = [...bearingS].sort((a, b) => a - b);
|
||||
const mids = sorted.slice(1).map((s, i) => (s + sorted[i]) / 2);
|
||||
const used = new Set<number>();
|
||||
const out: number[] = [];
|
||||
for (let j = 1; j <= n; j++) {
|
||||
const ideal = (lengthM * j) / (n + 1);
|
||||
let best = -1;
|
||||
mids.forEach((m, i) => {
|
||||
if (used.has(i)) return;
|
||||
if (best < 0 || Math.abs(m - ideal) < Math.abs(mids[best] - ideal) - 1e-9) best = i;
|
||||
});
|
||||
if (best >= 0) { used.add(best); out.push(mids[best]); } else out.push(ideal);
|
||||
}
|
||||
return out.sort((a, b) => a - b);
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import type { WingForm } from './geometry';
|
||||
import { ABUT_SHAPE_DEFAULT, normalizeShape, wallThicknessMm, type AbutShape } from './shape';
|
||||
import { resolveSlab, SLAB_FORM_LABEL, slabChangedKeys, slabSlopeLabel, type AbutSlab } from './slab';
|
||||
import { abutGroundChanged, type AbutGround } from './ground';
|
||||
|
||||
/** 형상 입력 중 벽체·흉벽 카드와 확대기초·말뚝 카드의 항목 (변경 표시를 카드별로 나눈다). */
|
||||
const WALL_KEYS: (keyof AbutShape)[] = ['wallFrontMm', 'parapetHeightMm', 'parapetThicknessMm', 'bracketHeightMm', 'bracketWidthMm', 'shrinkJointCount', 'parapetLevelLeftMm', 'parapetLevelRightMm', 'seatStepIgnoreMm'];
|
||||
const FOOTING_KEYS: (keyof AbutShape)[] = [
|
||||
'footingWidthMm', 'footingThicknessMm', 'footingToeMm', 'blindingMarginMm', 'blindingThicknessMm', 'pileDiameterMm', 'pileEdgeMm', 'pileSpacingMm',
|
||||
'pileRowBackEdgeMm', 'pileRowGap1Mm', 'pileRowGap2Mm', 'pileCountRow1', 'pileCountRow2', 'pileCountRow3',
|
||||
];
|
||||
|
||||
/** 부재 · 교대 list in ② (시안): the card each row opens. */
|
||||
export type AbutMemberId = 'bearing' | 'shape' | 'footing' | 'wing' | 'slab' | 'ground';
|
||||
|
||||
/** Cards that take input in this step: all six (접속슬래브 · 지반 입력 2026-11-04). */
|
||||
export const ABUT_MEMBER_EDITABLE: Record<AbutMemberId, boolean> = {
|
||||
bearing: true,
|
||||
shape: true,
|
||||
footing: true,
|
||||
wing: true,
|
||||
slab: true,
|
||||
ground: true,
|
||||
};
|
||||
|
||||
const WING_LABEL: Record<WingForm, string> = { general: '일반형', short: '단축형', none: '없음' };
|
||||
|
||||
/** The subset of an abutment record the list reads. */
|
||||
export interface AbutMemberInput {
|
||||
bearings: { seatEl?: number }[];
|
||||
shape?: Partial<AbutShape>;
|
||||
wings?: { left: WingForm; right: WingForm };
|
||||
slab?: Partial<AbutSlab>;
|
||||
ground?: AbutGround;
|
||||
groundEl: number;
|
||||
}
|
||||
|
||||
export interface AbutMemberRow {
|
||||
id: AbutMemberId;
|
||||
label: string;
|
||||
summary: string;
|
||||
/** True when the value differs from the default (or a 받침 has its own 교좌면 EL). */
|
||||
changed: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* The six rows of the 부재 · 교대 list (시안 순서). `slabWidth` is the computed 접속슬래브 폭 text, passed in
|
||||
* so this stays a pure function of the record.
|
||||
*/
|
||||
export function abutMemberRows(rec: AbutMemberInput, slabWidth: string): AbutMemberRow[] {
|
||||
const changes = normalizeShape(rec.shape);
|
||||
const shape = { ...ABUT_SHAPE_DEFAULT, ...changes };
|
||||
const changed = (keys: (keyof AbutShape)[]) => keys.some((key) => (changes[key] ?? ABUT_SHAPE_DEFAULT[key]) !== ABUT_SHAPE_DEFAULT[key]);
|
||||
const wings = rec.wings ?? { left: 'general' as WingForm, right: 'general' as WingForm };
|
||||
const slab = resolveSlab(rec.slab);
|
||||
return [
|
||||
{
|
||||
id: 'bearing',
|
||||
label: '교량받침·받침블럭·솔플레이트',
|
||||
summary: `${rec.bearings.length}개 · z = 패드 상면`,
|
||||
changed: rec.bearings.some((b) => b.seatEl !== undefined),
|
||||
},
|
||||
{
|
||||
id: 'shape',
|
||||
label: '벽체·흉벽',
|
||||
summary: `벽체 두께 ${wallThicknessMm(shape)} (자동) · 흉벽 ${shape.parapetHeightMm}`,
|
||||
changed: changed(WALL_KEYS),
|
||||
},
|
||||
{
|
||||
id: 'footing',
|
||||
label: '확대기초 · 말뚝',
|
||||
summary: `저판 ${shape.footingThicknessMm} · 말뚝 연단 ${shape.pileEdgeMm} · 간격 ${shape.pileSpacingMm}`,
|
||||
changed: changed(FOOTING_KEYS),
|
||||
},
|
||||
{
|
||||
id: 'wing',
|
||||
label: '날개벽',
|
||||
summary: `좌 ${WING_LABEL[wings.left]} · 우 ${WING_LABEL[wings.right]}`,
|
||||
changed: wings.left !== 'general' || wings.right !== 'general',
|
||||
},
|
||||
{
|
||||
id: 'slab',
|
||||
label: '접속슬래브',
|
||||
summary: `${SLAB_FORM_LABEL[slab.form]} · ${slabSlopeLabel(slab)} · 폭 ${slabWidth}`,
|
||||
changed: slabChangedKeys(rec.slab).length > 0,
|
||||
},
|
||||
{
|
||||
id: 'ground',
|
||||
label: '지반·지층',
|
||||
summary: `${rec.ground?.groundPosition ?? '육상'} · 지층 ${rec.ground?.strataRows?.length ?? 0} · 지반고 EL.${rec.groundEl.toFixed(3)}`,
|
||||
changed: abutGroundChanged(rec.ground),
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* 교대 부재 · AA3 (2026-11-03, spec_abut §4.3–4.6): 벽체, 흉벽 상단, 저판, 말뚝의 치수와 위치. Pure.
|
||||
*
|
||||
* - 교대축 기준 t: 전면 쪽이 +. 벽체 전면 +1.6 m (교좌면 끝), 배면 −0.8 m (브라켓 배면). 두께 2.4 m.
|
||||
* - 흉벽: t −0.5 ~ 0 m. 상단 EL(s) = 받침 ① 교좌면 EL + 벽체 높이 H_w (받침 ① 기준 연직, 기본 3.326 m) + i_b × (s − s_1), i_b = 받침 z의 축 방향 경사.
|
||||
* - 저판: 하면 = 기초 하단 EL, 두께 1.1 m. 폭은 t −1.8 ~ +2.8 m (앞굽 1.2, 뒷굽 1.0, 벽체 2.4). 길이 = 교좌면 길이 L.
|
||||
* - 말뚝: 열 위치 t = −1.15 / +0.85 / +2.15 m. 열 안 본수 = int((L − 1.3)/2.0) + 1, 양끝 0.65 m 띄움, 균등 간격.
|
||||
* 말뚝 선단 EL = 기초 하단 EL − 말뚝길이. (skew 평행사변형에서 열 끝이 밀리는 보정은 아직 없다 — QA-5 이후.)
|
||||
* - 벽체 높이 = 구간 교좌면 EL − (기초 하단 EL + 저판 두께).
|
||||
*/
|
||||
import type { AbutModel } from './geometry';
|
||||
import { resolveShape, type AbutShape, type ResolvedShape } from './shape';
|
||||
|
||||
export interface WallSegment {
|
||||
/** 축 위 구간 범위 (IP1에서 m). */
|
||||
sFrom: number;
|
||||
sTo: number;
|
||||
/** 구간의 교좌면 EL (구간 i). */
|
||||
seatEl: number;
|
||||
/** 벽체 높이 (m) = seat − (footing bottom + footing thickness). */
|
||||
heightM: number;
|
||||
}
|
||||
|
||||
export interface PileRow {
|
||||
/** 횡방향 위치 t (m, 전면 +). */
|
||||
t: number;
|
||||
/** 축 위 말뚝 위치 (IP1에서 m). */
|
||||
s: number[];
|
||||
}
|
||||
|
||||
export interface AbutMembers {
|
||||
/** The shape values in use (defaults merged with the user's changes), in m. */
|
||||
shape: ResolvedShape;
|
||||
wallSegments: WallSegment[];
|
||||
/** 흉벽 상단 EL: s = 0 (IP1 끝)과 s = L (IP3 끝), 축 방향 경사 i_b (소수, + = IP3 쪽으로 올라감). */
|
||||
parapetTopStartEl: number;
|
||||
parapetTopEndEl: number;
|
||||
parapetSlope: number;
|
||||
footingTopEl: number;
|
||||
footingBottomEl: number | undefined;
|
||||
piles: PileRow[];
|
||||
pileBottomEl: number | undefined;
|
||||
}
|
||||
|
||||
/** 흉벽 상단 EL at axial position s (m). */
|
||||
export function parapetTopEl(seatFirst: number, bearingSFirst: number, slope: number, s: number, parapetHeightM: number): number {
|
||||
return seatFirst + parapetHeightM + slope * (s - bearingSFirst);
|
||||
}
|
||||
|
||||
export function buildMembers(model: AbutModel, foundation: { bottomEl?: number; pileLengthMm?: number } = {}, shapeChanges?: Partial<AbutShape>): AbutMembers {
|
||||
const L = model.length;
|
||||
const shape = resolveShape(shapeChanges);
|
||||
const sorted = [...model.bearings].sort((a, b) => a.s - b.s);
|
||||
const bounds = [0, ...model.stepBounds, L];
|
||||
const footingBottomEl = foundation.bottomEl;
|
||||
const footingTopEl = footingBottomEl === undefined ? NaN : footingBottomEl + shape.footingThicknessM;
|
||||
|
||||
// 인접 무시 기준 (2026-11-03): a step smaller than shape.seatStepIgnoreM joins the segment before it (one 교좌면 EL).
|
||||
const wallSegments: WallSegment[] = [];
|
||||
sorted.forEach((b, k) => {
|
||||
const prev = wallSegments[wallSegments.length - 1];
|
||||
// Compared with the neighbouring bearing (인접), not with the first step of the joined run.
|
||||
if (prev && k > 0 && Math.abs(b.seatEl - sorted[k - 1].seatEl) < shape.seatStepIgnoreM) {
|
||||
prev.sTo = bounds[k + 1];
|
||||
return;
|
||||
}
|
||||
wallSegments.push({ sFrom: bounds[k], sTo: bounds[k + 1], seatEl: b.seatEl, heightM: b.seatEl - footingTopEl });
|
||||
});
|
||||
|
||||
// 받침 ①(첫 입력)과 마지막 입력 받침이 흉벽 상단선을 정한다 (spec §4.4).
|
||||
const first = model.bearings[0];
|
||||
const last = model.bearings[model.bearings.length - 1];
|
||||
const slope = (last.z - first.z) / (last.s - first.s);
|
||||
const parapetTopStartEl = parapetTopEl(first.seatEl, first.s, slope, 0, shape.parapetHeightM);
|
||||
const parapetTopEndEl = parapetTopEl(first.seatEl, first.s, slope, L, shape.parapetHeightM);
|
||||
|
||||
// 본수 규칙 int((L − 2·연단)/간격) + 1; 열별 본수가 0이 아니면 그 값 (2026-11-04, 시안 열별 말뚝 본수).
|
||||
const ruleCount = Math.floor((L - 2 * shape.pileEdgeM) / shape.pileSpacingM) + 1;
|
||||
const piles: PileRow[] = shape.pileRowsT.map((t, r) => {
|
||||
const count = shape.pileCounts[r] > 0 ? shape.pileCounts[r] : ruleCount;
|
||||
const spacing = count > 1 ? (L - 2 * shape.pileEdgeM) / (count - 1) : 0;
|
||||
return { t, s: Array.from({ length: count }, (_, k) => shape.pileEdgeM + k * spacing) };
|
||||
});
|
||||
const pileBottomEl = footingBottomEl === undefined || foundation.pileLengthMm === undefined ? undefined : footingBottomEl - foundation.pileLengthMm / 1000;
|
||||
|
||||
return {
|
||||
shape,
|
||||
wallSegments,
|
||||
parapetTopStartEl,
|
||||
parapetTopEndEl,
|
||||
parapetSlope: slope,
|
||||
footingTopEl,
|
||||
footingBottomEl,
|
||||
piles,
|
||||
pileBottomEl,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
/** Shared display-name policy for Excel input and saved project records. */
|
||||
export function abutNameError(value: unknown): string | null {
|
||||
if (typeof value !== 'string' || !value.trim()) return '교대명을 입력하세요.';
|
||||
if (/[\u0000-\u001f\u007f-\u009f]/u.test(value)) return '교대명에는 줄바꿈·제어 문자를 사용할 수 없습니다.';
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* 구조물 목록 순서 (2026-11-03, 교대 AB): 시점 교대(A1) → 교각 P1 … Pn → 종점 교대(A2), 교대가 여럿이면 번호 순.
|
||||
* Pure. Used by the 구조물 목록 so the abutments sit at the two ends of the bridge.
|
||||
*/
|
||||
export interface StructureRef {
|
||||
kind: 'pier' | 'abut';
|
||||
name: string;
|
||||
/** 교대 형식 (A1 · A2), only for abutments. */
|
||||
type?: 'A1' | 'A2';
|
||||
}
|
||||
|
||||
const nameOrder = new Intl.Collator('ko', { numeric: true });
|
||||
|
||||
export function structureOrder(abutments: { name: string; type: 'A1' | 'A2' }[], piers: string[]): StructureRef[] {
|
||||
const start = abutments.filter((a) => a.type === 'A1').sort((a, b) => nameOrder.compare(a.name, b.name));
|
||||
const end = abutments.filter((a) => a.type === 'A2').sort((a, b) => nameOrder.compare(a.name, b.name));
|
||||
const others = abutments.filter((a) => a.type !== 'A1' && a.type !== 'A2');
|
||||
return [
|
||||
...start.map((a) => ({ kind: 'abut' as const, name: a.name, type: a.type })),
|
||||
...piers.map((name) => ({ kind: 'pier' as const, name })),
|
||||
...end.map((a) => ({ kind: 'abut' as const, name: a.name, type: a.type })),
|
||||
...others.map((a) => ({ kind: 'abut' as const, name: a.name, type: a.type })),
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
/**
|
||||
* 교대 입력 읽기 (2026-11-03, spec_abut §2): 입력 엑셀의 교대 행을 읽는다. 교대 표는 교각 표와 같은 시트에 있으며,
|
||||
* 첫 두 헤더 칸(교대명 + 형식 / IP점 / 받침번호)으로 구분한다. 워크북 객체에 대해 순수하다. 좌표: 시트 x_m = 북, y_m = 동 → 내부 (E, N) = (y_m, x_m).
|
||||
*
|
||||
* 받침높이 (2026-11-03 결정 1): 교각과 같은 정의 — 패드 상면(z)에서 솔플레이트 상면까지.
|
||||
* 받침 행의 값을 그대로 읽는다(A1 시트 값 275). 여기서 계산하지 않는다.
|
||||
*/
|
||||
import * as XLSX from 'xlsx';
|
||||
import { parseDms } from '../dms';
|
||||
import { swapXYAzimuth } from '../coordinate-frame';
|
||||
import type { Issue } from '../types';
|
||||
import { abutNameError } from './name';
|
||||
import type { AbutInput, AbutPoint, AbutType, WingForm } from './geometry';
|
||||
import type { AbutShape } from './shape';
|
||||
import type { AbutBearingRule } from './bearing-table';
|
||||
import type { WingDims } from './wings';
|
||||
import type { AbutSlab } from './slab';
|
||||
import type { AbutGround } from './ground';
|
||||
import type { AbutFootingRebarSettings } from './rebar/footing';
|
||||
import type { AbutWallRebarSettings } from './rebar/wall';
|
||||
import type { AbutParapetRebarSettings } from './rebar/parapet';
|
||||
import type { AbutWingRebarSettings } from './rebar/wing';
|
||||
import type { AbutSlabRebarSettings } from './rebar/slab';
|
||||
import type { AbutBlockSettings } from './rebar/block';
|
||||
|
||||
type Cell = string | number | null | undefined;
|
||||
type Grid = Cell[][];
|
||||
|
||||
export interface AbutRecordBearing {
|
||||
no: number;
|
||||
/** Position (E, N), m: E = y_m, N = x_m. */
|
||||
pos: AbutPoint;
|
||||
z: number;
|
||||
/** 교좌면_EL of this bearing (m). Absent = the block rule (z − 블럭 높이). */
|
||||
seatEl?: number;
|
||||
/** 이 받침에 적힌 받침높이 (mm). */
|
||||
bearingHeightMm: number;
|
||||
/** 받침 방위각 (deg, model-xy, 교각과 같은 swapXYAzimuth 변환). 비었거나 읽을 수 없으면 없음. */
|
||||
azimuthDeg?: number;
|
||||
row: string;
|
||||
}
|
||||
|
||||
export interface AbutRecord {
|
||||
name: string;
|
||||
sheet: string;
|
||||
type: AbutType;
|
||||
/** 첫 받침(구간 ①)의 교좌면 EL (m). */
|
||||
seatEl: number;
|
||||
/** 기초 하단 EL (m), 지반고 EL (m), 기초 형식, 말뚝길이 (mm). */
|
||||
foundationBottomEl: number;
|
||||
groundEl: number;
|
||||
foundation: string;
|
||||
pileLengthMm: number;
|
||||
/** skew (deg) from the DMS text, and 종단경사 i (decimal). */
|
||||
skewDeg: number;
|
||||
slopeI: number;
|
||||
ip1: AbutPoint;
|
||||
ip2: AbutPoint;
|
||||
ip3: AbutPoint;
|
||||
bearings: AbutRecordBearing[];
|
||||
/** 받침 행에서 읽은 받침높이 (mm). 모든 받침이 같아야 하며, 다르면 AP07 경고. */
|
||||
bearingHeightMm: number;
|
||||
/** 측별 날개벽 형식 (편집 카드에서 정함). Absent = 양측 일반형. */
|
||||
wings?: { left: WingForm; right: WingForm };
|
||||
/** 형상 입력 (벽체·흉벽, 확대기초·말뚝, mm). Only the changed values; the rest are the defaults. Absent = all defaults. */
|
||||
shape?: Partial<AbutShape>;
|
||||
/** 받침 규칙 (② 교량받침 카드: h3, 솔플레이트 T·여유, 받침 종류). Only the changed values. Absent = 교각 P1 가정값 (QA-15). */
|
||||
bearingRule?: Partial<AbutBearingRule>;
|
||||
/** 계산 방위각을 적용한 받침: 받침번호 → 방위각 (deg, model 진북). Absent = 엑셀 입력값 (2026-11-06). */
|
||||
azimuthApplied?: Record<string, number>;
|
||||
/** 날개벽 치수 (② 날개벽 카드, 측별, mm). Only the changed values. */
|
||||
wingDims?: { left?: Partial<WingDims>; right?: Partial<WingDims> };
|
||||
/** 접속슬래브 입력 (② 접속슬래브 카드: 형식, 경사, 치수 mm). Only the changed values. Absent = 매립형 · 하향 4 % 기본값. */
|
||||
slab?: Partial<AbutSlab>;
|
||||
/** 지반·지층 입력 (② 지반·지층 카드, 교각과 같은 필드). Absent = 육상, 지하수위 없음, 지층 행 없음. */
|
||||
ground?: AbutGround;
|
||||
/** ③ 배근 입력 (부재별, 바꾼 값만). Absent = 엑셀 Abut 시트 기본값. */
|
||||
rebar?: { footing?: Partial<AbutFootingRebarSettings>; wall?: Partial<AbutWallRebarSettings>; parapet?: Partial<AbutParapetRebarSettings>; wing?: Partial<AbutWingRebarSettings>; slab?: Partial<AbutSlabRebarSettings>; block?: Partial<AbutBlockSettings> };
|
||||
/** 예전 "좌우 같게" 값 (2026-11-04 이전 파일). 지금은 읽기만 하고 쓰지 않는다. */
|
||||
wingsLinked?: boolean;
|
||||
}
|
||||
|
||||
export interface AbutParseResult {
|
||||
abutments: AbutRecord[];
|
||||
issues: Issue[];
|
||||
}
|
||||
|
||||
const num = (c: Cell) => (c === null || c === undefined || c === '' ? NaN : Number(c));
|
||||
const isBlank = (c: Cell) => c === null || c === undefined || c === '';
|
||||
|
||||
/** 교대 표의 헤더 행 (첫 두 칸). */
|
||||
const isAbutInfoHeader = (r: Cell[]) => r[0] === '교대명' && r[1] === '형식';
|
||||
const isAbutIpHeader = (r: Cell[]) => r[0] === '교대명' && r[1] === 'IP점';
|
||||
const isAbutBearingHeader = (r: Cell[]) => r[0] === '교대명' && r[1] === '받침번호';
|
||||
|
||||
/** 헤더 아래 데이터 행, 둘째 칸이 빌 때까지. 교대명 칸이 비면 위 행을 이어받는다. */
|
||||
function readBlock(grid: Grid, header: number): { cells: Cell[]; row: number }[] {
|
||||
const out: { cells: Cell[]; row: number }[] = [];
|
||||
let last: Cell = null;
|
||||
for (let i = header + 1; i < grid.length; i++) {
|
||||
const r = grid[i] ?? [];
|
||||
if (isBlank(r[1])) break;
|
||||
const name: Cell = isBlank(r[0]) ? last : r[0];
|
||||
last = name;
|
||||
out.push({ cells: [name, ...r.slice(1)], row: i + 1 });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function parseAbutments(wb: XLSX.WorkBook, reservedNames: readonly string[] = []): AbutParseResult {
|
||||
const issues: Issue[] = [];
|
||||
const info: { cells: Cell[]; row: number; sheet: string }[] = [];
|
||||
const ips: { cells: Cell[]; row: number; sheet: string }[] = [];
|
||||
const bearings: { cells: Cell[]; row: number; sheet: string }[] = [];
|
||||
|
||||
for (const sheet of wb.SheetNames) {
|
||||
const grid = XLSX.utils.sheet_to_json<Cell[]>(wb.Sheets[sheet], { header: 1, raw: true, defval: null }) as Grid;
|
||||
grid.forEach((r, i) => {
|
||||
const pick = (match: (r: Cell[]) => boolean, into: typeof info) => {
|
||||
if (!match(r)) return;
|
||||
for (const rec of readBlock(grid, i)) into.push({ ...rec, sheet });
|
||||
};
|
||||
pick(isAbutInfoHeader, info);
|
||||
pick(isAbutIpHeader, ips);
|
||||
pick(isAbutBearingHeader, bearings);
|
||||
});
|
||||
}
|
||||
|
||||
const abutments: AbutRecord[] = [];
|
||||
const counts = new Map<string, number>();
|
||||
for (const rec of info) {
|
||||
const name = String(rec.cells[0] ?? '').trim();
|
||||
counts.set(name, (counts.get(name) ?? 0) + 1);
|
||||
}
|
||||
const reserved = new Set(reservedNames.map((name) => name.trim()));
|
||||
for (const rec of info) {
|
||||
const c = rec.cells;
|
||||
const rawName = String(c[0] ?? '');
|
||||
const name = rawName.trim();
|
||||
const where = `${rec.sheet} ${rec.row}행`;
|
||||
const nameError = abutNameError(rawName);
|
||||
if (nameError) {
|
||||
issues.push({ code: 'AP01', level: 'E', pier: name, sheet: rec.sheet, message: `${where}: ${nameError}` });
|
||||
continue;
|
||||
}
|
||||
if ((counts.get(name) ?? 0) > 1 || reserved.has(name)) {
|
||||
issues.push({ code: 'AP09', level: 'E', pier: name, sheet: rec.sheet, message: `${where}: 구조물명 "${name}"이 중복되었습니다. 교각·교대마다 서로 다른 이름을 사용하세요.` });
|
||||
continue;
|
||||
}
|
||||
const type = String(c[1] ?? '').trim();
|
||||
if (type !== 'A1' && type !== 'A2') {
|
||||
issues.push({ code: 'AP02', level: 'E', pier: name, sheet: rec.sheet, message: `${where}: 교대 ${name}의 형식 "${type}"은 A1 또는 A2여야 합니다.` });
|
||||
continue;
|
||||
}
|
||||
const skewDeg = parseDms(String(c[7] ?? ''));
|
||||
if (skewDeg === null) {
|
||||
issues.push({ code: 'AP03', level: 'E', pier: name, sheet: rec.sheet, message: `${where}: 교대 ${name}의 skew "${String(c[7] ?? '')}"를 읽지 못했습니다.` });
|
||||
continue;
|
||||
}
|
||||
abutments.push({
|
||||
name,
|
||||
sheet: rec.sheet,
|
||||
type,
|
||||
seatEl: num(c[2]),
|
||||
foundationBottomEl: num(c[3]),
|
||||
groundEl: num(c[4]),
|
||||
foundation: String(c[5] ?? ''),
|
||||
pileLengthMm: num(c[6]),
|
||||
skewDeg,
|
||||
slopeI: num(c[8]),
|
||||
ip1: { e: NaN, n: NaN },
|
||||
ip2: { e: NaN, n: NaN },
|
||||
ip3: { e: NaN, n: NaN },
|
||||
bearings: [],
|
||||
bearingHeightMm: NaN,
|
||||
});
|
||||
}
|
||||
|
||||
for (const rec of ips) {
|
||||
const name = String(rec.cells[0] ?? '').trim();
|
||||
const ab = abutments.find((a) => a.name === name);
|
||||
if (!ab) {
|
||||
issues.push({ code: 'AP04', level: 'E', pier: name, sheet: rec.sheet, message: `${rec.sheet} ${rec.row}행: 교대명 ${name}의 교대 정보 행이 없습니다.` });
|
||||
continue;
|
||||
}
|
||||
const point = { e: num(rec.cells[3]), n: num(rec.cells[2]) };
|
||||
const ipName = String(rec.cells[1] ?? '');
|
||||
if (ipName === 'IP1') ab.ip1 = point;
|
||||
else if (ipName === 'IP2') ab.ip2 = point;
|
||||
else if (ipName === 'IP3') ab.ip3 = point;
|
||||
}
|
||||
|
||||
for (const rec of bearings) {
|
||||
const c = rec.cells;
|
||||
const name = String(c[0] ?? '').trim();
|
||||
const ab = abutments.find((a) => a.name === name);
|
||||
if (!ab) {
|
||||
issues.push({ code: 'AP04', level: 'E', pier: name, sheet: rec.sheet, message: `${rec.sheet} ${rec.row}행: 교대명 ${name}의 교대 정보 행이 없습니다.` });
|
||||
continue;
|
||||
}
|
||||
const h = num(c[8]);
|
||||
if (!isBlank(c[5]) && parseDms(String(c[5])) === null) {
|
||||
issues.push({ code: 'AP08', level: 'W', pier: ab.name, sheet: rec.sheet, message: `${rec.sheet} ${rec.row}행: 받침 방위각을 읽을 수 없습니다. 예: 75°56'55.9" 형식. 블럭은 교대축에 맞춥니다.` });
|
||||
}
|
||||
ab.bearings.push({
|
||||
no: num(c[1]),
|
||||
pos: { e: num(c[3]), n: num(c[2]) },
|
||||
z: num(c[4]),
|
||||
seatEl: isBlank(c[7]) ? undefined : num(c[7]),
|
||||
bearingHeightMm: h,
|
||||
azimuthDeg: (() => {
|
||||
const deg = isBlank(c[5]) ? null : parseDms(String(c[5]));
|
||||
return deg === null ? undefined : swapXYAzimuth(deg);
|
||||
})(),
|
||||
row: `${rec.sheet} ${rec.row}행`,
|
||||
});
|
||||
}
|
||||
|
||||
for (const ab of abutments) {
|
||||
const missing = [ab.ip1, ab.ip2, ab.ip3].some((p) => !Number.isFinite(p.e) || !Number.isFinite(p.n));
|
||||
if (missing) issues.push({ code: 'AP05', level: 'E', pier: ab.name, sheet: ab.sheet, message: `교대 ${ab.name}: IP1·IP2·IP3 중 빠진 점이 있습니다.` });
|
||||
if (ab.bearings.length < 2) issues.push({ code: 'AP06', level: 'E', pier: ab.name, sheet: ab.sheet, message: `교대 ${ab.name}: 받침이 2개 이상 필요합니다.` });
|
||||
const heights = [...new Set(ab.bearings.map((b) => b.bearingHeightMm))];
|
||||
if (heights.length > 1) issues.push({ code: 'AP07', level: 'W', pier: ab.name, sheet: ab.sheet, message: `교대 ${ab.name}: 받침높이가 받침마다 다릅니다 (${heights.join(', ')} mm). 첫 값을 씁니다.` });
|
||||
ab.bearingHeightMm = heights[0] ?? NaN;
|
||||
}
|
||||
|
||||
return { abutments: abutments.filter((a) => Number.isFinite(a.ip1.e) && a.bearings.length >= 2), issues };
|
||||
}
|
||||
|
||||
/** The layout input of AA1–AA2 from a parsed 교대 record. */
|
||||
export function toAbutInput(rec: AbutRecord): AbutInput {
|
||||
return {
|
||||
name: rec.name,
|
||||
type: rec.type,
|
||||
seatEl: rec.seatEl,
|
||||
ip1: rec.ip1,
|
||||
ip2: rec.ip2,
|
||||
ip3: rec.ip3,
|
||||
// 계산 방위각을 적용한 받침 (2026-11-06) use that value; the record keeps the workbook value.
|
||||
bearings: rec.bearings.map((b) => ({ no: b.no, pos: b.pos, z: b.z, seatEl: b.seatEl, azimuthDeg: rec.azimuthApplied?.[String(b.no)] ?? b.azimuthDeg })),
|
||||
skewDeg: rec.skewDeg,
|
||||
slopeI: rec.slopeI,
|
||||
wings: rec.wings,
|
||||
wingThicknessMm: { left: rec.wingDims?.left?.thicknessMm, right: rec.wingDims?.right?.thicknessMm },
|
||||
slabWingGapMm: rec.slab?.wingGapMm,
|
||||
foundationBottomEl: rec.foundationBottomEl,
|
||||
pileLengthMm: rec.pileLengthMm,
|
||||
foundationType: rec.foundation,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
/**
|
||||
* 교대 받침블럭 배근 · AD6 (2026-11-05, 받침블럭철근.md §6.6 · 교대 기획서 §10, 사용자 결정 2026-11-05). 순수 함수, 길이는 mm.
|
||||
*
|
||||
* - 배치: 받침마다 블럭 1개 (분리). 블럭 평면 1200 × 1200, 몰탈패드 50 포함 높이 125 → 콘크리트 75.
|
||||
* - S1 (아래, 교대축 직각 간격, 교대축 방향으로 뻗음), S2 (위, 교대축 간격, 교대축 직각으로 뻗음). 규격 H13 (기획서 §6.6).
|
||||
* - 본수 · 다리 · 여유는 교각 받침블럭 규칙(`bearing-block.ts`의 barsAcross · barLength)을 그대로 쓴다. 교각과 달리 깊이만
|
||||
* 교대 규격(H13)으로 계산한다: S2 = 상면 피복, S1 = 상면 피복 + S1/2 + S2/2 (H13 두 개 = 62.7).
|
||||
* - 다리 길이 700은 철근 중심에서 아래로. 묻힘 = 다리 − (블럭 콘크리트 높이 − 깊이): S2 675, S1 688.
|
||||
* - 경고만 (사용자 결정 2026-11-05): S1 다리가 벽체 W2 층(교좌면 − c)을 지나면 RB-AK01 (Q-S4, 확인 후 결정 기록).
|
||||
* - 좌표: 교대 (s, t, z) mm — s 교대축, t 전면 +, z 저판 하면에서. 블럭은 받침 방위각으로 돌려 놓는다 (abut-scene과 같은 회전).
|
||||
*/
|
||||
import { barLength, barsAcross, DEFAULT_BLOCK_REBAR, type BarRow, type BlockRebarSettings } from '../../rebar/bearing-block';
|
||||
import { NOMINAL_DIAMETER, type BarSize } from '../../rebar/common';
|
||||
import type { Issue } from '../../types';
|
||||
|
||||
export type AbutBlockSettings = BlockRebarSettings;
|
||||
|
||||
/** 교대 블럭 규격 (기획서 §6.6): S1 · S2 모두 H13. */
|
||||
export const ABUT_BLOCK_SIZES = { S1: 'H13', S2: 'H13' } as const satisfies Record<string, BarSize>;
|
||||
|
||||
/** 몰탈패드 높이 (mm): 블럭 높이 125에 포함, 콘크리트 높이 = 블럭 높이 − 50. */
|
||||
export const ABUT_BLOCK_PAD_MM = 50;
|
||||
|
||||
/** 기본값 = 교각 P1 값 (받침블럭철근.md): 상면 피복 50 · 측면 50 · 여유 75 · @125 · 다리 700. */
|
||||
export const ABUT_BLOCK_REBAR_DEFAULT: AbutBlockSettings = DEFAULT_BLOCK_REBAR;
|
||||
|
||||
export function resolveBlockRebar(changes?: Partial<AbutBlockSettings>): AbutBlockSettings {
|
||||
return { ...ABUT_BLOCK_REBAR_DEFAULT, ...(changes ?? {}) };
|
||||
}
|
||||
|
||||
export function blockRebarIssues(s: AbutBlockSettings): string[] {
|
||||
const out: string[] = [];
|
||||
for (const key of ['coverTop', 'coverSide', 'firstGap', 'spacingS1', 'spacingS2', 'legLength'] as const) {
|
||||
if (!(s[key] > 0)) out.push(`${key}은(는) 0보다 커야 합니다.`);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 블럭 상면 ~ 철근 중심 (mm): S2 = 상면 피복, S1 = 상면 피복 + S1/2 + S2/2 (교대 H13). */
|
||||
export function abutBarDepths(s: AbutBlockSettings): { S1: number; S2: number } {
|
||||
const h = NOMINAL_DIAMETER[ABUT_BLOCK_SIZES.S1];
|
||||
return { S2: s.coverTop, S1: s.coverTop + h / 2 + h / 2 };
|
||||
}
|
||||
|
||||
/** 한 받침 블럭의 배근 (블럭 콘크리트 높이 mm). */
|
||||
export function abutBlockBars(concreteH: number, s: AbutBlockSettings = ABUT_BLOCK_REBAR_DEFAULT): BarRow[] {
|
||||
const depth = abutBarDepths(s);
|
||||
const length = barLength(1200, s);
|
||||
return [
|
||||
{ name: 'S1', ...barsAcross(1200, s.spacingS1, s), length, embedment: s.legLength - (concreteH - depth.S1) },
|
||||
{ name: 'S2', ...barsAcross(1200, s.spacingS2, s), length, embedment: s.legLength - (concreteH - depth.S2) },
|
||||
];
|
||||
}
|
||||
|
||||
export interface AbutBlockInput {
|
||||
no: number;
|
||||
/** 교대축 위치 (m, IP1 기준), 받침 전면 쪽 t (m), 방위각 회전 (deg), 교좌면 EL (m), 블럭 높이 (mm, 패드 포함). */
|
||||
s: number;
|
||||
t: number;
|
||||
rotDeg: number;
|
||||
seatEl: number;
|
||||
blockHeightMm: number;
|
||||
}
|
||||
|
||||
export interface AbutBlockBar {
|
||||
mark: 'S1' | 'S2';
|
||||
size: BarSize;
|
||||
role: '주철근' | '보조';
|
||||
spacing: string;
|
||||
actual: string;
|
||||
shape: string;
|
||||
length: number;
|
||||
count: number;
|
||||
embedment: number;
|
||||
position: string;
|
||||
note: string;
|
||||
basis: '규칙' | '가정';
|
||||
modified: boolean;
|
||||
}
|
||||
|
||||
/** One bar as a polyline in (s, t, z) mm from the 저판 하면 (z = seatEl − bottom + local height). */
|
||||
export interface AbutBlockPath {
|
||||
mark: 'S1' | 'S2';
|
||||
block: number;
|
||||
pts: [number, number, number][];
|
||||
}
|
||||
|
||||
export interface AbutBlockRebar {
|
||||
settings: AbutBlockSettings;
|
||||
concreteH: number;
|
||||
/** 교좌면 높이 (mm, 저판 하면 기준). 경로 z에서 빼면 교좌면 기준. */
|
||||
seatZ: number;
|
||||
depth: { S1: number; S2: number };
|
||||
embedment: { S1: number; S2: number };
|
||||
paths: AbutBlockPath[];
|
||||
bars: AbutBlockBar[];
|
||||
issues: Issue[];
|
||||
}
|
||||
|
||||
/** W2 층 (교좌면 − c, H16 철근 중심 깊이): 간섭 판정에 쓴다 (wall.ts W2, 교대벽체철근.md §5.1). */
|
||||
export const W2_LAYER_DEPTH_MM = (wallCover: number) => wallCover + NOMINAL_DIAMETER.H16 / 2;
|
||||
|
||||
export function abutBlockRebar(
|
||||
blocks: AbutBlockInput[],
|
||||
bottomEl: number,
|
||||
changes?: Partial<AbutBlockSettings>,
|
||||
wallCover = 100,
|
||||
who = '',
|
||||
): AbutBlockRebar {
|
||||
const s = resolveBlockRebar(changes);
|
||||
const issues: Issue[] = [];
|
||||
const add = (code: string, level: Issue['level'], message: string) => issues.push({ code, level, pier: who || undefined, message });
|
||||
if (s.spacingS1 !== ABUT_BLOCK_REBAR_DEFAULT.spacingS1 || s.spacingS2 !== ABUT_BLOCK_REBAR_DEFAULT.spacingS2) {
|
||||
add('RB-AK03', 'I', '블럭 철근 간격을 기본간격과 따로 고쳤습니다. [되돌리기]로 다시 연동합니다.');
|
||||
}
|
||||
const concreteH = (blocks[0]?.blockHeightMm ?? 125) - ABUT_BLOCK_PAD_MM;
|
||||
const rows = abutBlockBars(concreteH, s);
|
||||
const depth = abutBarDepths(s);
|
||||
const embedment = { S1: rows[0].embedment, S2: rows[1].embedment };
|
||||
const paths: AbutBlockPath[] = [];
|
||||
const half = 1200 / 2;
|
||||
const straight = 1200 - 2 * s.coverSide;
|
||||
const h = straight / 2;
|
||||
const yS1 = concreteH - depth.S1; // 블럭 밑면 위 높이
|
||||
const yS2 = concreteH - depth.S2;
|
||||
for (const b of blocks) {
|
||||
const cos = Math.cos((b.rotDeg * Math.PI) / 180);
|
||||
const sin = Math.sin((b.rotDeg * Math.PI) / 180);
|
||||
const sb = b.s * 1000;
|
||||
const tb = b.t * 1000;
|
||||
const zb = (b.seatEl - bottomEl) * 1000;
|
||||
// 블럭 지역 (x 교대축, zt 교대축 직각, y 높이) → 교대 (s, t, z): abut-scene의 rotateY와 같은 회전.
|
||||
const toAbut = (x: number, y: number, zt: number): [number, number, number] => [sb + x * cos + zt * sin, tb - x * sin + zt * cos, zb + y];
|
||||
const side = (key: 'S1' | 'S2') => (key === 'S1' ? rows[0] : rows[1]);
|
||||
// S1: 교대축 방향으로 뻗고, 교대축 직각 간격으로 놓인다.
|
||||
const r1 = side('S1');
|
||||
for (let i = 0; i < r1.count; i++) {
|
||||
const zc = r1.firstEnd + i * r1.spacing - half; // 블럭 가운데 기준 (firstEnd는 블럭 모서리부터)
|
||||
paths.push({ mark: 'S1', block: b.no, pts: [toAbut(-h, yS1 - s.legLength, zc), toAbut(-h, yS1, zc), toAbut(h, yS1, zc), toAbut(h, yS1 - s.legLength, zc)] });
|
||||
}
|
||||
// S2: 교대축 직각으로 뻗고, 교대축 간격으로 놓인다 (위 층).
|
||||
const r2 = side('S2');
|
||||
for (let i = 0; i < r2.count; i++) {
|
||||
const x = r2.firstEnd + i * r2.spacing - half;
|
||||
paths.push({ mark: 'S2', block: b.no, pts: [toAbut(x, yS2 - s.legLength, -h), toAbut(x, yS2, -h), toAbut(x, yS2, h), toAbut(x, yS2 - s.legLength, h)] });
|
||||
}
|
||||
}
|
||||
|
||||
// 간섭 (Q-S4, 결정 2026-11-05 경고만): S1 다리 아래끝이 W2 층 (교좌면 − c − 철근 중심)보다 아래로 내려가면 경고.
|
||||
const w2 = W2_LAYER_DEPTH_MM(wallCover);
|
||||
if (embedment.S1 > w2) {
|
||||
add('RB-AK01', 'W', `블럭 S1 다리(묻힘 ${Math.round(embedment.S1)})가 벽체 W2 층(교좌면 − ${Math.round(w2)})을 지납니다 (Q-S4, 경고만 — 확인 후 결정을 기록).`);
|
||||
}
|
||||
|
||||
const bars: AbutBlockBar[] = [];
|
||||
const count = (mark: 'S1' | 'S2') => blocks.length * (mark === 'S1' ? rows[0].count : rows[1].count);
|
||||
const spacingText = (mark: 'S1' | 'S2') => `${(mark === 'S1' ? s.spacingS1 : s.spacingS2)}`;
|
||||
const shape = (mark: 'S1' | 'S2') => `직선 ${Math.round(straight)} + 아래 다리 ${s.legLength} × 2 (${mark === 'S1' ? '교대축 방향' : '교대축 직각'})`;
|
||||
bars.push({
|
||||
mark: 'S1',
|
||||
size: ABUT_BLOCK_SIZES.S1,
|
||||
role: '주철근',
|
||||
spacing: `${spacingText('S1')}`,
|
||||
actual: spacingText('S1'),
|
||||
shape: shape('S1'),
|
||||
length: rows[0].length,
|
||||
count: count('S1'),
|
||||
embedment: embedment.S1,
|
||||
position: `블럭 아래 (상면 ${Math.round(depth.S1)}) · 교대축 직각 간격 · 블럭 ${blocks.length}개 × ${rows[0].count}`,
|
||||
note: `묻힘 ${Math.round(embedment.S1)} (교좌면 아래) · 블럭 콘크리트 ${concreteH}`,
|
||||
basis: '규칙',
|
||||
modified: s.spacingS1 !== ABUT_BLOCK_REBAR_DEFAULT.spacingS1,
|
||||
});
|
||||
bars.push({
|
||||
mark: 'S2',
|
||||
size: ABUT_BLOCK_SIZES.S2,
|
||||
role: '보조',
|
||||
spacing: `${spacingText('S2')}`,
|
||||
actual: spacingText('S2'),
|
||||
shape: shape('S2'),
|
||||
length: rows[1].length,
|
||||
count: count('S2'),
|
||||
embedment: embedment.S2,
|
||||
position: `블럭 위 (상면 ${Math.round(depth.S2)}) · 교대축 간격 · 블럭 ${blocks.length}개 × ${rows[1].count}`,
|
||||
note: `묻힘 ${Math.round(embedment.S2)} · 블럭 상면 쪽 (교좌면 아래)`,
|
||||
basis: '규칙',
|
||||
modified: s.spacingS2 !== ABUT_BLOCK_REBAR_DEFAULT.spacingS2,
|
||||
});
|
||||
const seatZ = blocks[0] ? (blocks[0].seatEl - bottomEl) * 1000 : 0;
|
||||
return { settings: s, concreteH, seatZ, depth, embedment, paths, bars, issues };
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
/**
|
||||
* 교대 확대기초(저판) 배근 · AD3 (2026-11-04, 교대확대기초철근.md, 결정 2026-11-02). 순수 함수, 길이는 mm.
|
||||
*
|
||||
* - B1–B4: 폭 방향 주철근, 교대축을 따라 s_L 격자에 번갈아(cycle-1 B1·B2 ⊓⊔, cycle-2 B3·B4 직선). 최외측.
|
||||
* 옵션 ① skew 방향(날개벽 선과 나란히) / ② 교대축 직각(가운데 격자 + 양끝 끝 skew 철근 B1-S·B2-S). 직사각 평면은 ② 고정.
|
||||
* - B5–B8: 길이 방향 배력철근, 폭을 따라 s_W 격자. 상면(B5·B7)은 벽체 밑에서 빼되 벽체 면 안쪽 s_W/2 이내는 남긴다.
|
||||
* - B9: 전단, B1–B4 격자와 B5–B8 격자의 교점. 행 = 폭 격자 k_9W 배수(벽체 밑 제외), 행 안 = 교대축 격자 k_9L 배수, 행마다 엇갈림.
|
||||
* - B10-1(전면·배면, 교대축 방향) · B10-2(양 끝면, skew 선과 나란히): 측면 수평, s_v 단.
|
||||
* - 간격은 저판 기본간격(s_L, s_W, s_v)에서 계산한다. 철근별로 간격을 고치면 그 철근 묶음만 연동이 끊긴다(RB-AF10).
|
||||
* - 높이 z는 저판 하면 기준, 폭 위치 x는 뒷굽 끝(배면)에서, 길이 위치 s는 교대축을 따라 IP1 쪽 끝면에서 잰다.
|
||||
*/
|
||||
import { DEFAULT_MATERIAL, fyOf, hookLength, NOMINAL_DIAMETER, STANDARD_BAR, type BarSize, type Material } from '../../rebar/common';
|
||||
import type { Issue } from '../../types';
|
||||
import { gridLayout, gridText, lenText, type GridLayout, type SkewAngle } from './skew-layout';
|
||||
import { splicePlan, type SplicePlan } from './splice';
|
||||
|
||||
export type MainDir = 'skew' | 'perp';
|
||||
|
||||
export interface AbutFootingRebarSettings {
|
||||
/** 피복 상면 / 측면 / 하면 (철근 중심까지). */
|
||||
cTop: number;
|
||||
cSide: number;
|
||||
cBottom: number;
|
||||
/** 첫 철근 최소 여유 (측면 피복 다음). */
|
||||
eMin: number;
|
||||
/** 저판 기본간격: 교대축 / 교대축 직각 / 연직. */
|
||||
sL: number;
|
||||
sW: number;
|
||||
sV: number;
|
||||
/** B9 간격 배수 (교대축 / 폭), 정수 ≥ 1. */
|
||||
k9L: number;
|
||||
k9W: number;
|
||||
/** 상면 철근 아래 최소 여유 (B10). */
|
||||
eTop: number;
|
||||
/** B1–B4 배근 방향 (skew 평면일 때). */
|
||||
dir: MainDir;
|
||||
/** 철근별 간격 수정 (없으면 기본간격에 연동): B1–B4 (교대축), B5–B8 (폭), B10 (연직). */
|
||||
sMain?: number;
|
||||
sDist?: number;
|
||||
sSide?: number;
|
||||
}
|
||||
|
||||
export const ABUT_FOOTING_REBAR_DEFAULT: AbutFootingRebarSettings = {
|
||||
cTop: 100,
|
||||
cSide: 100,
|
||||
cBottom: 150,
|
||||
eMin: 150,
|
||||
sL: 125,
|
||||
sW: 150,
|
||||
sV: 200,
|
||||
k9L: 2,
|
||||
k9W: 2,
|
||||
eTop: 150,
|
||||
dir: 'skew',
|
||||
};
|
||||
|
||||
/** 규격 (기획서 §1.2, 엑셀 Abut 시트). */
|
||||
export const ABUT_FOOTING_SIZES = { top: 'H16', bottom: 'H22', dist: 'H16', shear: 'H16', side: 'H19' } as const satisfies Record<string, BarSize>;
|
||||
|
||||
/** 형상 (②에서 받음). */
|
||||
export interface AbutFootingGeom {
|
||||
/** 길이 (교대축 방향) · 폭 (교대축 직각) · 두께. */
|
||||
L: number;
|
||||
W: number;
|
||||
H: number;
|
||||
/** 벽체 배면 / 전면 위치 (뒷굽 끝에서). */
|
||||
xb: number;
|
||||
xf: number;
|
||||
angle: SkewAngle;
|
||||
plan: 'skew' | 'rect';
|
||||
}
|
||||
|
||||
export type BarRole = '주철근' | '보조' | '전단·간격재' | '측면';
|
||||
|
||||
export interface AbutFootingBar {
|
||||
mark: string;
|
||||
size: BarSize;
|
||||
role: BarRole;
|
||||
/** 간격 (cycle별) · 실간격, as the table writes them. */
|
||||
spacing: string;
|
||||
actual: string;
|
||||
shape: string;
|
||||
length: number;
|
||||
count: number;
|
||||
position: string;
|
||||
note: string;
|
||||
/** 간격을 기본간격과 따로 고친 묶음 (RB-AF10 "수정됨"). */
|
||||
modified: boolean;
|
||||
splice?: SplicePlan;
|
||||
}
|
||||
|
||||
export interface AbutFootingRebar {
|
||||
settings: AbutFootingRebarSettings;
|
||||
dir: MainDir;
|
||||
z: { B13: number; B57: number; B68: number; B24: number };
|
||||
/** B1–B4 배치: 위치(교대축, IP1 끝면에서)와 cycle. 옵션 ②는 가운데 격자. */
|
||||
main: { positions: number[]; layout: GridLayout | null; text: string };
|
||||
/** 옵션 ② 끝 skew 철근 (한쪽): 끝면 피복선에서 잰 위치, 치수 문자열. */
|
||||
skewEnd: { offsets: number[]; D: number; k: number; r: number; text: string } | null;
|
||||
/** B5–B8 배치: x (뒷굽 끝에서), 상면에 남는지. */
|
||||
dist: { positions: number[]; topKept: boolean[]; layout: GridLayout; bottomText: string; topText: string };
|
||||
/** B10 단 높이와 위 여유, 치수 문자열. */
|
||||
side: { levels: number[]; topGap: number; text: string; depth1: number; depth2: number; hook: number };
|
||||
/** B9 행: 폭 위치 x와 그 행의 교대축 위치. */
|
||||
shear: { rows: { x: number; s: number[] }[]; length: number };
|
||||
/** 옆붙임 (B1 교대축 방향, B5 폭 방향). */
|
||||
sideShift: { B1: number; B5: number };
|
||||
bars: AbutFootingBar[];
|
||||
issues: Issue[];
|
||||
}
|
||||
|
||||
const d = (size: BarSize) => NOMINAL_DIAMETER[size];
|
||||
|
||||
export function resolveFootingRebar(changes?: Partial<AbutFootingRebarSettings>): AbutFootingRebarSettings {
|
||||
return { ...ABUT_FOOTING_REBAR_DEFAULT, ...(changes ?? {}) };
|
||||
}
|
||||
|
||||
/** Input problems in Korean (the edit refuses them). */
|
||||
export function footingRebarIssues(s: AbutFootingRebarSettings): string[] {
|
||||
const out: string[] = [];
|
||||
for (const key of ['cTop', 'cSide', 'cBottom', 'eMin', 'sL', 'sW', 'sV', 'eTop'] as const) if (!(s[key] > 0)) out.push(`${key}은(는) 0보다 커야 합니다.`);
|
||||
for (const key of ['sMain', 'sDist', 'sSide'] as const) if (s[key] !== undefined && !((s[key] as number) > 0)) out.push('철근 간격은 0보다 커야 합니다.');
|
||||
if (!(Number.isInteger(s.k9L) && s.k9L >= 1 && Number.isInteger(s.k9W) && s.k9W >= 1)) out.push('B9 배수는 1 이상의 정수여야 합니다.');
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* The footing rebar of one abutment. `who` names the abutment in the issues. Lengths are rounded to 1 mm in the table;
|
||||
* the layouts keep the exact values for the drawings.
|
||||
*/
|
||||
export function abutFootingRebar(g: AbutFootingGeom, changes?: Partial<AbutFootingRebarSettings>, material: Material = DEFAULT_MATERIAL, who = ''): AbutFootingRebar {
|
||||
const s = resolveFootingRebar(changes);
|
||||
const issues: Issue[] = [];
|
||||
const add = (code: string, level: Issue['level'], message: string) => issues.push({ code, level, pier: who || undefined, message });
|
||||
const SZ = ABUT_FOOTING_SIZES;
|
||||
const sin = g.angle.sin;
|
||||
const dir: MainDir = g.plan === 'rect' ? 'perp' : s.dir;
|
||||
if (g.plan === 'rect') add('RB-AF04', 'I', '직사각 평면이라 B1–B4 배근 방향을 ② 교대축 직각으로 고정합니다.');
|
||||
const sMain = s.sMain ?? s.sL;
|
||||
const sDist = s.sDist ?? s.sW;
|
||||
const sSide = s.sSide ?? s.sV;
|
||||
if (s.sMain !== undefined || s.sDist !== undefined || s.sSide !== undefined || s.k9L !== 2 || s.k9W !== 2) {
|
||||
add('RB-AF10', 'I', '철근 간격을 기본간격과 따로 고쳤거나 B9 배수가 기본값(2)이 아닙니다. [기본간격으로 되돌리기]로 다시 연동합니다.');
|
||||
}
|
||||
|
||||
// 높이 (§3).
|
||||
const z = {
|
||||
B13: g.H - s.cTop,
|
||||
B57: g.H - s.cTop - (d(SZ.top) + d(SZ.dist)) / 2,
|
||||
B24: s.cBottom,
|
||||
B68: s.cBottom + (d(SZ.bottom) + d(SZ.dist)) / 2,
|
||||
};
|
||||
if (!(z.B57 - z.B68 > d(SZ.dist))) add('RB-AF07', 'W', '상면과 하면 철근 층 사이 순간격이 없습니다 (규격 · 피복 확인).');
|
||||
const legMain = z.B13 - z.B24;
|
||||
const legDist = z.B57 - z.B68;
|
||||
const fy = (size: BarSize) => fyOf(material, size);
|
||||
|
||||
// B1–B4 (§4).
|
||||
let mainPositions: number[] = [];
|
||||
let mainLayout: GridLayout | null = null;
|
||||
let mainText = '';
|
||||
let l0Main: number;
|
||||
let l3: number;
|
||||
let skewEnd: AbutFootingRebar['skewEnd'] = null;
|
||||
if (dir === 'skew') {
|
||||
mainLayout = gridLayout(g.L, s.cSide / sin, s.eMin, sMain);
|
||||
mainPositions = mainLayout.positions;
|
||||
mainText = gridText(mainLayout);
|
||||
l0Main = (g.W - 2 * s.cSide) / sin;
|
||||
l3 = (g.xf - 2 * s.cSide) / sin;
|
||||
} else {
|
||||
const shift = g.W * g.angle.cotAbs;
|
||||
const C = g.L - 2 * s.cSide - shift;
|
||||
const n = Math.floor(C / sMain);
|
||||
const e = (C - Math.max(0, n) * sMain) / 2;
|
||||
const start = s.cSide + shift;
|
||||
mainPositions = n < 0 ? [] : Array.from({ length: n + 1 }, (_, i) => start + e + i * sMain);
|
||||
mainText = `${lenText(s.cSide)} · ${lenText(e)} · ${n}@${lenText(sMain)}=${lenText(n * sMain)} · ${lenText(e)} · ${lenText(s.cSide)} (가운데 격자)`;
|
||||
l0Main = g.W - 2 * s.cSide;
|
||||
l3 = g.xf - 2 * s.cSide;
|
||||
// 끝 skew 철근 (§4.3 ②).
|
||||
const D = e + shift;
|
||||
const k = Math.floor(D / sMain + 1e-9);
|
||||
const r = D - k * sMain;
|
||||
if (k >= 1) {
|
||||
const offsets: number[] = []; // from the first perpendicular bar toward the end face
|
||||
for (let i = 1; i <= k - 1; i++) offsets.push(i * sMain);
|
||||
let text: string;
|
||||
if (r < 1e-6) {
|
||||
offsets.push(k * sMain);
|
||||
text = `${lenText(s.cSide)} · ${k - 1 > 0 ? `${k - 1}@${lenText(sMain)}` : ''}`.replace(/ · $/, '');
|
||||
} else {
|
||||
const half = (sMain + r) / 2;
|
||||
const base = (k - 1) * sMain;
|
||||
offsets.push(base + half, D);
|
||||
const rest = k - 2;
|
||||
text = `${lenText(s.cSide)} · 2@${Math.round(half)}${rest > 0 ? ` · ${rest > 1 ? `${rest}@` : ''}${lenText(sMain)}` : ''}`;
|
||||
}
|
||||
skewEnd = { offsets: offsets.map((o) => D - o), D, k, r, text };
|
||||
add('RB-AF05', 'I', `옵션 ② 끝 skew 철근 B1-S · B2-S를 양끝에 한쪽 ${offsets.length}개씩 둡니다 (${text}).`);
|
||||
}
|
||||
}
|
||||
if (mainPositions.length < 2) add('RB-AF01', 'E', 'B1–B4를 놓을 수 없습니다 (길이 · 간격 확인).');
|
||||
if (mainPositions.length % 2 === 1) add('RB-AF03', 'I', `B1–B4 위치 수가 ${mainPositions.length}개(홀수)라 cycle-1이 1개 많습니다.`);
|
||||
const c1 = Math.ceil(mainPositions.length / 2);
|
||||
const c2 = Math.floor(mainPositions.length / 2);
|
||||
|
||||
// B5–B8 (§5).
|
||||
const distLayout = gridLayout(g.W, s.cSide, s.eMin, sDist);
|
||||
const xs = distLayout.positions;
|
||||
const topKept = xs.map((x) => x <= g.xb + sDist / 2 + 1e-9 || x >= g.xf - sDist / 2 - 1e-9);
|
||||
const backIdx = xs.map((x, i) => (topKept[i] && x < g.xb + sDist / 2 + 1e-9 ? i : -1)).filter((i) => i >= 0);
|
||||
const frontIdx = xs.map((x, i) => (topKept[i] && x >= g.xf - sDist / 2 - 1e-9 ? i : -1)).filter((i) => i >= 0);
|
||||
if (backIdx.length === 0 || frontIdx.length === 0) add('RB-AF02', 'W', `${backIdx.length === 0 ? '뒷굽' : '앞굽'} 상면 길이 방향 철근이 0개입니다 (${backIdx.length === 0 ? '뒷굽' : '앞굽'}이 짧음).`);
|
||||
if (xs.length < 2) add('RB-AF01', 'E', 'B5–B8을 놓을 수 없습니다 (폭 · 간격 확인).');
|
||||
if (xs.length % 2 === 1) add('RB-AF03', 'I', `B5–B8 위치 수가 ${xs.length}개(홀수)라 cycle-1이 1개 많습니다.`);
|
||||
const run = (idx: number[]) => (idx.length > 1 ? `${idx.length - 1}@${lenText(sDist)}=${lenText((idx.length - 1) * sDist)}` : '');
|
||||
const topText =
|
||||
backIdx.length && frontIdx.length
|
||||
? [lenText(s.cSide), lenText(distLayout.e), run(backIdx), lenText(xs[frontIdx[0]] - xs[backIdx[backIdx.length - 1]]), run(frontIdx), lenText(distLayout.e), lenText(s.cSide)].filter(Boolean).join(' · ')
|
||||
: gridText(distLayout);
|
||||
const evenCount = (idx: number[]) => idx.filter((i) => i % 2 === 0).length;
|
||||
const oddCount = (idx: number[]) => idx.filter((i) => i % 2 === 1).length;
|
||||
const kept = [...backIdx, ...frontIdx];
|
||||
const l0Dist = g.L - (2 * s.cSide) / sin;
|
||||
|
||||
// B10 (§6).
|
||||
const m = Math.floor((z.B13 - s.eTop - s.cBottom) / sSide);
|
||||
const levels = m < 0 ? [] : Array.from({ length: m + 1 }, (_, j) => s.cBottom + j * sSide);
|
||||
if (m < 1) add('RB-AF09', 'W', 'B10 단 수가 1 미만입니다 (두께 · 연직 간격 확인).');
|
||||
const hook10 = Math.round(hookLength('main90', d(SZ.side)));
|
||||
const depth1 = s.cSide + (d(SZ.bottom) + d(SZ.side)) / 2;
|
||||
const depth2 = s.cSide + (d(SZ.dist) + d(SZ.side)) / 2;
|
||||
const topGap = levels.length ? z.B13 - levels[levels.length - 1] : 0;
|
||||
|
||||
// B9 (§8): rows on the B5–B8 grid (k_9W multiples, not under the wall), bars on the B1–B4 grid (k_9L multiples, staggered).
|
||||
const rowIdx = xs.map((_, i) => i).filter((i) => i % s.k9W === 0 && topKept[i]);
|
||||
const rows = rowIdx.map((i, r) => {
|
||||
const shift = s.k9L >= 2 && r % 2 === 1 ? 1 : 0;
|
||||
return { x: xs[i], s: mainPositions.filter((_, j) => j >= shift && (j - shift) % s.k9L === 0) };
|
||||
});
|
||||
const b9Count = rows.reduce((a, row) => a + row.s.length, 0);
|
||||
const b9Length = z.B13 - z.B24 + 2 * hookLength('stirrup135', d(SZ.shear));
|
||||
|
||||
// 표 (시안 순서).
|
||||
const bars: AbutFootingBar[] = [];
|
||||
const push = (b: Omit<AbutFootingBar, 'length' | 'splice'> & { length: number }) => {
|
||||
const splice = splicePlan(b.length, d(b.size), fy(b.size), material.fck) ?? undefined;
|
||||
const length = Math.round(b.length);
|
||||
if (splice) {
|
||||
const text = `${splice.pieces}조각 이음 l_s ${splice.lap} (${Math.round(splice.staggered[0])} / ${Math.round(splice.staggered[1])} 엇갈림)`;
|
||||
b.note = b.note ? `${b.note} · ${text}` : text;
|
||||
add('RB-AF08', splice.over ? 'W' : 'I', `${b.mark} ${length} > 정척 ${STANDARD_BAR}: ${text}${splice.over ? ' — 조각이 정척을 넘습니다' : ''}.`);
|
||||
}
|
||||
bars.push({ ...b, length, splice });
|
||||
};
|
||||
const mainMod = s.sMain !== undefined;
|
||||
const distMod = s.sDist !== undefined;
|
||||
const sideMod = s.sSide !== undefined;
|
||||
const mainSp = `${lenText(2 * sMain)}`;
|
||||
const mainAct = `${lenText(sMain)}`;
|
||||
const dirText = dir === 'skew' ? '폭 방향 (skew 선과 나란히)' : '교대축 직각';
|
||||
const shiftB1 = (d(SZ.top) + d(SZ.bottom)) / 2;
|
||||
const shiftB5 = (d(SZ.dist) + d(SZ.dist)) / 2;
|
||||
push({ mark: 'B1', size: SZ.top, role: '주철근', spacing: mainSp, actual: mainAct, shape: `⊓ 다리 아래로 ${Math.round(legMain)} (B2 높이까지)`, length: l0Main + 2 * legMain, count: c1, position: `상면 최외측 z ${lenText(z.B13)} · ${dirText}`, note: `cycle-1 · B2 옆 +${shiftB1.toFixed(2)} (교대축 방향)`, modified: mainMod });
|
||||
push({ mark: 'B3', size: SZ.top, role: '주철근', spacing: mainSp, actual: mainAct, shape: '직선', length: l3, count: c2, position: `상면 · 배면 끝 ~ 벽체 전면 − ${lenText(s.cSide)}`, note: 'cycle-2 · 앞굽 상면은 B1만', modified: mainMod });
|
||||
push({ mark: 'B2', size: SZ.bottom, role: '주철근', spacing: mainSp, actual: mainAct, shape: `⊔ 다리 위로 ${Math.round(legMain)} (B1 높이까지)`, length: l0Main + 2 * legMain, count: c1, position: `하면 최외측 z ${lenText(z.B24)}`, note: 'cycle-1', modified: mainMod });
|
||||
push({ mark: 'B4', size: SZ.bottom, role: '주철근', spacing: mainSp, actual: mainAct, shape: '직선', length: l0Main, count: c2, position: '하면 최외측', note: 'cycle-2', modified: mainMod });
|
||||
if (skewEnd) {
|
||||
const n = skewEnd.offsets.length * 2;
|
||||
const len = (g.W - 2 * s.cSide) / sin;
|
||||
push({ mark: 'B1-S', size: SZ.top, role: '주철근', spacing: skewEnd.text, actual: '—', shape: '직선 · 끝면과 나란히 · 전체 폭', length: len, count: n, position: `상면 · 양끝 삼각형 (한쪽 ${skewEnd.offsets.length}개)`, note: `D ${lenText(skewEnd.D)} · k ${skewEnd.k} · r ${lenText(skewEnd.r)}`, modified: mainMod });
|
||||
push({ mark: 'B2-S', size: SZ.bottom, role: '주철근', spacing: skewEnd.text, actual: '—', shape: '직선 · 끝면과 나란히 · 전체 폭', length: len, count: n, position: `하면 · 양끝 삼각형 (한쪽 ${skewEnd.offsets.length}개)`, note: 'B1-S와 같은 위치', modified: mainMod });
|
||||
}
|
||||
const distSp = lenText(2 * sDist);
|
||||
const distAct = lenText(sDist);
|
||||
push({ mark: 'B5', size: SZ.dist, role: '보조', spacing: distSp, actual: distAct, shape: `⊓ 다리 ${Math.round(legDist)} (B6 높이까지)`, length: l0Dist + 2 * legDist, count: evenCount(kept), position: `상면 B1 아래 z ${lenText(z.B57)} · 뒷굽·앞굽 · 교대축 방향`, note: `+${shiftB5.toFixed(1)} 옆붙임 (폭 방향)`, modified: distMod });
|
||||
push({ mark: 'B7', size: SZ.dist, role: '보조', spacing: distSp, actual: distAct, shape: '직선', length: l0Dist, count: oddCount(kept), position: '상면 · 뒷굽·앞굽 · 교대축 방향', note: `벽체 밑 제외 (면 안쪽 s_W/2 = ${lenText(sDist / 2)} 이내는 남김)`, modified: distMod });
|
||||
push({ mark: 'B6', size: SZ.dist, role: '보조', spacing: distSp, actual: distAct, shape: `⊔ 다리 ${Math.round(legDist)} (B5 높이까지)`, length: l0Dist + 2 * legDist, count: xs.filter((_, i) => i % 2 === 0).length, position: `하면 B2 위 z ${lenText(z.B68)}`, note: 'cycle-1', modified: distMod });
|
||||
push({ mark: 'B8', size: SZ.dist, role: '보조', spacing: distSp, actual: distAct, shape: '직선', length: l0Dist, count: xs.filter((_, i) => i % 2 === 1).length, position: '하면 B2 위', note: 'cycle-2', modified: distMod });
|
||||
push({ mark: 'B9', size: SZ.shear, role: '전단·간격재', spacing: `${lenText(s.k9L * sMain)} × ${lenText(s.k9W * sDist)}`, actual: '—', shape: '양끝 135° 갈고리', length: b9Length, count: b9Count, position: `전단 · B1 ~ B2 감쌈 · 벽체 밑 제외 ${rows.length}행`, note: `교대축 ${s.k9L}·s_L × 폭 ${s.k9W}·s_W · 행마다 s_L 엇갈림 · 배수로만 수정`, modified: s.k9L !== 2 || s.k9W !== 2 });
|
||||
const sideSp = lenText(sSide);
|
||||
push({ mark: 'B10-1', size: SZ.side, role: '측면', spacing: sideSp, actual: sideSp, shape: `양끝 90° 갈고리 ${hook10}`, length: g.L - (2 * depth2) / sin + 2 * hook10, count: levels.length * 2, position: `측면 · 전면·배면 · 깊이 ${depth1.toFixed(2)} · +${d(SZ.side).toFixed(1)}`, note: `${levels.length}단 ${m}@${sideSp} · 위 여유 ${lenText(topGap)}`, modified: sideMod });
|
||||
push({ mark: 'B10-2', size: SZ.side, role: '측면', spacing: sideSp, actual: sideSp, shape: `양끝 90° 갈고리 ${hook10}`, length: (g.W - 2 * depth1) / sin + 2 * hook10, count: levels.length * 2, position: `측면 · 양 끝면 (skew) · 깊이 ${depth2.toFixed(1)}`, note: `${levels.length}단 ${m}@${sideSp}`, modified: sideMod });
|
||||
|
||||
return {
|
||||
settings: s,
|
||||
dir,
|
||||
z,
|
||||
main: { positions: mainPositions, layout: mainLayout, text: mainText },
|
||||
skewEnd,
|
||||
dist: { positions: xs, topKept, layout: distLayout, bottomText: gridText(distLayout), topText },
|
||||
side: { levels, topGap, text: `${lenText(s.cBottom)} · ${m}@${sideSp}=${lenText(m * sSide)}`, depth1, depth2, hook: hook10 },
|
||||
shear: { rows, length: Math.round(b9Length) },
|
||||
sideShift: { B1: shiftB1, B5: shiftB5 },
|
||||
bars,
|
||||
issues,
|
||||
};
|
||||
}
|
||||
|
||||
/** The 형상 of the 저판 from the generated abutment (m → mm): 길이 = 교대축 길이, 폭 · 두께 · 벽체 위치는 형상 입력, θ = skew. */
|
||||
export function abutFootingGeom(
|
||||
lengthM: number,
|
||||
shape: { footingFrontM: number; footingBackM: number; footingThicknessM: number; wallBackM: number; wallFrontM: number },
|
||||
angle: SkewAngle,
|
||||
plan: 'skew' | 'rect' = 'skew',
|
||||
): AbutFootingGeom {
|
||||
const mm = (m: number) => Math.round(m * 1e6) / 1e3;
|
||||
return {
|
||||
L: mm(lengthM),
|
||||
W: mm(shape.footingFrontM - shape.footingBackM),
|
||||
H: mm(shape.footingThicknessM),
|
||||
xb: mm(shape.wallBackM - shape.footingBackM),
|
||||
xf: mm(shape.wallFrontM - shape.footingBackM),
|
||||
angle,
|
||||
plan,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,374 @@
|
||||
/**
|
||||
* 교대 흉벽 · 브라켓 배근 · AD5 (2026-11-04, 교대흉벽철근.md, 결정 2026-11-02 · 2026-11-04). 순수 함수, 길이는 mm.
|
||||
*
|
||||
* - P1 전면 연직 (cycle-1) / P2 배면 상단 ⊓ (cycle-1·2) / P3 배면 하단 ㄱ형 (cycle-1·2): 벽체 연직 격자에서 −0 / +15.9 / −17.5.
|
||||
* - P4 · P5 전면 · 배면 수평 (흉벽 상단 경사와 나란함, P5는 브라켓 상면 위 · 아래 두 줄) / P6 · P7 간격재 (브라켓 구간 · 흉벽).
|
||||
* - P8 · P9 끝면 수평 ㄷ형 (아래 · 위) / P10 다웰 (접속슬래브 폭 안, P3 수평 다리를 비켜감, Q-P5) / P11 끝면 연직.
|
||||
* - 좌표는 벽체(wall.ts)와 같다: u = skew 격자 위치, t = 교대축에서 전면 +, z = 저판 하면에서.
|
||||
*/
|
||||
import { DEFAULT_MATERIAL, developmentLength, fyOf, hookLength, NOMINAL_DIAMETER, STANDARD_BAR, type BarSize, type Material } from '../../rebar/common';
|
||||
import type { Issue } from '../../types';
|
||||
import { gridLayout, lenText } from './skew-layout';
|
||||
import { splicePlan, type SplicePlan } from './splice';
|
||||
import type { AbutWallGeom, AbutWallRebar } from './wall';
|
||||
|
||||
export interface AbutParapetRebarSettings {
|
||||
/** 피복 (철근 중심까지). */
|
||||
c: number;
|
||||
/** 흉벽 기본간격: 교대축 / 연직. 교대축 직각은 두지 않는다 (두께 방향 철근이 2줄). */
|
||||
sL: number;
|
||||
sV: number;
|
||||
/** P6 · P7 간격 배수 (교대축 / 연직), 정수 ≥ 1. 한 칸으로 두 철근에 함께 쓴다 (사용자 결정 2026-11-04). */
|
||||
k67L: number;
|
||||
k67V: number;
|
||||
/** P10 간격 · 길이 (기본간격과 연동 안 함). */
|
||||
s10: number;
|
||||
L10: number;
|
||||
/** 묶음별 간격 수정: 연직 P1–P3 (교대축 실간격), 수평 P4 · P5 · P8 · P9 (연직). */
|
||||
sVert?: number;
|
||||
sHoriz?: number;
|
||||
}
|
||||
|
||||
export const ABUT_PARAPET_REBAR_DEFAULT: AbutParapetRebarSettings = { c: 100, sL: 125, sV: 125, k67L: 8, k67V: 4, s10: 400, L10: 600 };
|
||||
|
||||
/** 규격 (기획서 §1.2). */
|
||||
export const ABUT_PARAPET_SIZES = {
|
||||
P1: 'H16', P2: 'H16', P3: 'H16', P4: 'H16', P5: 'H16', P11: 'H16',
|
||||
P6: 'H13', P7: 'H13', P8: 'H13', P9: 'H13',
|
||||
P10: 'D25',
|
||||
} as const satisfies Record<string, BarSize>;
|
||||
export type ParapetMark = keyof typeof ABUT_PARAPET_SIZES;
|
||||
|
||||
/** 흉벽 형상: 벽체 형상 + 흉벽 · 슬래브 값. */
|
||||
export interface AbutParapetGeom {
|
||||
wall: AbutWallGeom;
|
||||
/** 흉벽 배면 t (−500), 벽체 배면 t (−800, 브라켓 배면), 흉벽 전면 t (0). */
|
||||
parapetBack: number;
|
||||
/** 흉벽 상단 z at u = 0 and u = L (저판 하면에서), straight between. 브라켓 상면 = 흉벽 상단 − bracketH. */
|
||||
top: [number, number];
|
||||
bracketH: number;
|
||||
/** 벽체 높이 H_w (받침 ① 교좌면 → 흉벽 상단, 받침 ① 위치에서 연직). */
|
||||
Hp: number;
|
||||
/** 받침 ① 위치 u (mm). 철근표 대표 단면 (사용자 도면 2026-11-04). */
|
||||
ref: number;
|
||||
/** 접속슬래브: 폭 B (교대축 방향), 다웰 선(브라켓 가운데)에서 슬래브 양 측면의 u, 슬래브 끝 t, 측별 모서리 모따기 (0 = 없음). */
|
||||
slab: { B: number; a: number; b: number; nearT: number; chamferA: number; chamferB: number } | null;
|
||||
}
|
||||
|
||||
export type ParapetRole = '연직' | '수평' | '간격재' | '측면' | '다웰';
|
||||
|
||||
export interface AbutParapetBar {
|
||||
mark: ParapetMark;
|
||||
size: BarSize;
|
||||
role: ParapetRole;
|
||||
spacing: string;
|
||||
actual: string;
|
||||
shape: string;
|
||||
/** 대표 길이 (받침 ① 위치 단면) and the range. */
|
||||
length: number;
|
||||
lengthMin: number;
|
||||
lengthMax: number;
|
||||
count: number;
|
||||
position: string;
|
||||
note: string;
|
||||
basis: '규칙' | '가정';
|
||||
modified: boolean;
|
||||
splice?: SplicePlan;
|
||||
}
|
||||
|
||||
export interface ParapetBarPath {
|
||||
mark: ParapetMark;
|
||||
pts: [number, number, number][];
|
||||
}
|
||||
|
||||
export interface AbutParapetRebar {
|
||||
settings: AbutParapetRebarSettings;
|
||||
x: { P1: number; P2back: number; P3: number; P4: number; P5up: number; P5low: number; P10: number };
|
||||
/** 연직 격자 오프셋 (저판 격자 기준). */
|
||||
offsets: { P1: number; P2: number; P3: number };
|
||||
/** 대표 단면 (받침 ① 위치)의 단 수, and that section's u. */
|
||||
levels: { P4: number; P5up: number; P5low: number; P6: number; P7: number };
|
||||
uRef: number;
|
||||
dowel: { n: number; e: number; moved: number; positions: number[] };
|
||||
ld: Record<'H13' | 'H16', number>;
|
||||
paths: ParapetBarPath[];
|
||||
bars: AbutParapetBar[];
|
||||
issues: Issue[];
|
||||
}
|
||||
|
||||
const d = (size: BarSize) => NOMINAL_DIAMETER[size];
|
||||
|
||||
export function resolveParapetRebar(changes?: Partial<AbutParapetRebarSettings>): AbutParapetRebarSettings {
|
||||
return { ...ABUT_PARAPET_REBAR_DEFAULT, ...(changes ?? {}) };
|
||||
}
|
||||
|
||||
/** Input problems in Korean (the edit refuses them). */
|
||||
export function parapetRebarIssues(s: AbutParapetRebarSettings): string[] {
|
||||
const out: string[] = [];
|
||||
for (const key of ['c', 'sL', 'sV', 's10', 'L10'] as const) if (!(s[key] > 0)) out.push(`${key}은(는) 0보다 커야 합니다.`);
|
||||
for (const key of ['sVert', 'sHoriz'] as const) if (s[key] !== undefined && !((s[key] as number) > 0)) out.push('철근 간격은 0보다 커야 합니다.');
|
||||
if (!(Number.isInteger(s.k67L) && s.k67L >= 1 && Number.isInteger(s.k67V) && s.k67V >= 1)) out.push('P6 · P7 배수는 1 이상의 정수여야 합니다.');
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 흉벽 형상 from the 벽체 형상 (브라켓 상면 + 브라켓 높이 = 흉벽 상단) and the 접속슬래브 values (mm). */
|
||||
export function abutParapetGeom(
|
||||
wall: AbutWallGeom,
|
||||
shape: { parapetBackM: number; bracketHeightM: number; parapetHeightM: number },
|
||||
bearing1SM: number,
|
||||
slab: AbutParapetGeom['slab'],
|
||||
): AbutParapetGeom {
|
||||
const mm = (m: number) => Math.round(m * 1e6) / 1e3;
|
||||
const bracketH = mm(shape.bracketHeightM);
|
||||
return { wall, parapetBack: mm(shape.parapetBackM), top: [wall.bracketTop[0] + bracketH, wall.bracketTop[1] + bracketH], bracketH, Hp: mm(shape.parapetHeightM), ref: mm(bearing1SM), slab };
|
||||
}
|
||||
|
||||
/**
|
||||
* u of the two slab side lines (모따기 무시) on the line t = tM, in mm along the skew grid (u = s − k·t). `poly` is the slab of
|
||||
* abutPlanOutline (axis s, t in m): [side-0 near point(s)…, side-L near point(s)…, far L, far 0], so each side line runs through
|
||||
* its first near point and its far corner.
|
||||
*/
|
||||
export function slabSpanAt(poly: [number, number][], tM: number, skewK: number): { a: number; b: number } | null {
|
||||
const n = poly.length;
|
||||
if (n < 4) return null;
|
||||
const at = (p: [number, number], q: [number, number]) => p[0] + ((tM - p[1]) * (q[0] - p[0])) / (q[1] - p[1]);
|
||||
const s0 = at(poly[0], poly[n - 1]);
|
||||
const sL = at(poly[n - 3], poly[n - 2]);
|
||||
const toU = (s: number) => (s - skewK * tM) * 1000;
|
||||
return { a: toU(Math.min(s0, sL)), b: toU(Math.max(s0, sL)) };
|
||||
}
|
||||
|
||||
/**
|
||||
* The 흉벽 · 브라켓 rebar of one abutment, on the 벽체 연직 격자 (`wall`). `who` names the abutment in the issues.
|
||||
*/
|
||||
export function abutParapetRebar(g: AbutParapetGeom, wall: AbutWallRebar, changes?: Partial<AbutParapetRebarSettings>, material: Material = DEFAULT_MATERIAL, who = ''): AbutParapetRebar {
|
||||
const s = resolveParapetRebar(changes);
|
||||
const issues: Issue[] = [];
|
||||
const add = (code: string, level: Issue['level'], message: string) => issues.push({ code, level, pier: who || undefined, message });
|
||||
const SZ = ABUT_PARAPET_SIZES;
|
||||
const W = g.wall;
|
||||
const sin = W.angle.sin;
|
||||
const c = s.c;
|
||||
const sHoriz = s.sHoriz ?? s.sV;
|
||||
if (s.sVert !== undefined || s.sHoriz !== undefined || s.k67L !== 8 || s.k67V !== 4) {
|
||||
add('RB-AP03', 'I', '철근 간격을 기본간격과 따로 고쳤거나 P6 · P7 배수가 기본값(8 × 4)이 아닙니다. [되돌리기]로 다시 연동합니다.');
|
||||
}
|
||||
const fy = (size: BarSize) => fyOf(material, size);
|
||||
const ldOf = (size: BarSize) => Math.ceil(developmentLength(d(size), fy(size), material.fck) - 1e-9);
|
||||
const ld = { H13: ldOf('H13'), H16: ldOf('H16') };
|
||||
const hook = (size: BarSize) => Math.round(hookLength('main90', d(size)));
|
||||
|
||||
const segs = W.segments;
|
||||
const last = segs.length - 1;
|
||||
const segIndex = (u: number) => {
|
||||
const i = segs.findIndex((sg) => u < sg.to);
|
||||
return i < 0 ? last : i;
|
||||
};
|
||||
const seat = (u: number) => W.HF + segs[segIndex(u)].H;
|
||||
const top = (u: number) => g.top[0] + ((g.top[1] - g.top[0]) * u) / W.L;
|
||||
const br = (u: number) => top(u) - g.bracketH;
|
||||
// 대표 단면 = 받침 ① 위치 (벽체 높이 H_w의 기준선, 사용자 도면 2026-11-04). 그 단면의 흉벽 높이가 H_w다.
|
||||
const uRef = Math.min(Math.max(g.ref, 0), W.L - 1e-6);
|
||||
|
||||
// 단면 위치 (§3).
|
||||
const xP4 = -c;
|
||||
const xP1 = xP4 - (d(SZ.P4) + d(SZ.P1)) / 2;
|
||||
const xP2back = g.parapetBack + c;
|
||||
const xP5up = xP2back + (d(SZ.P2) + d(SZ.P5)) / 2;
|
||||
const xP3 = W.xb + c;
|
||||
const xP5low = xP3 + (d(SZ.P3) + d(SZ.P5)) / 2;
|
||||
const xP10 = (W.xb + g.parapetBack) / 2;
|
||||
|
||||
// 연직 격자 (§4.1): P1 = 벽체 연직 격자, P2 = P1 + (d_P1 + d_P2)/2, P3 = 벽체 격자 − (d_W + d_P3)/2.
|
||||
const base = s.sVert === undefined ? wall.vertical.positions : gridLayout(W.L, wall.settings.c / sin, wall.settings.eMin, s.sVert).positions.map((p) => p - wall.vertical.shift);
|
||||
const offP2 = (d(SZ.P1) + d(SZ.P2)) / 2;
|
||||
const offP3 = -(d('H19') + d(SZ.P3)) / 2;
|
||||
const offsets = { P1: -wall.vertical.shift, P2: -wall.vertical.shift + offP2, P3: -wall.vertical.shift + offP3 };
|
||||
|
||||
const paths: ParapetBarPath[] = [];
|
||||
const lengths = new Map<ParapetMark, { u: number; l: number }[]>();
|
||||
const put = (mark: ParapetMark, pts: [number, number, number][], length: number, u: number) => {
|
||||
paths.push({ mark, pts });
|
||||
lengths.set(mark, [...(lengths.get(mark) ?? []), { u, l: length }]);
|
||||
};
|
||||
|
||||
// P1 · P2 · P3 (§4.2). Lengths as functions of u, so the 철근표 대표 can be taken exactly at the 받침 ① 단면.
|
||||
const lenP1 = (u: number) => top(u) - c - offP2 - (seat(u) - ld.H16);
|
||||
const lenP2 = (u: number) => ld.H16 + (xP1 - xP2back) + (top(u) - c - (br(u) - ld.H16));
|
||||
const lenP3 = (u: number) => br(u) - c - (seat(u) - ld.H16) + (xP4 - xP3);
|
||||
let shortBack = false;
|
||||
base.forEach((u, i) => {
|
||||
const z0 = seat(u);
|
||||
const zt = top(u);
|
||||
const zb = br(u);
|
||||
if (i % 2 === 0) put('P1', [[u, xP1, z0 - ld.H16], [u, xP1, zt - c - offP2]], lenP1(u), u);
|
||||
const u2 = u + offP2;
|
||||
const zh = zt - c;
|
||||
const backEnd = zb - ld.H16;
|
||||
if (backEnd < seat(u2)) shortBack = true;
|
||||
put('P2', [[u2, xP1, zh - ld.H16], [u2, xP1, zh], [u2, xP2back, zh], [u2, xP2back, backEnd]], lenP2(u2), u2);
|
||||
const u3 = u + offP3;
|
||||
put('P3', [[u3, xP3, seat(u3) - ld.H16], [u3, xP3, br(u3) - c], [u3, xP4, br(u3) - c]], lenP3(u3), u3);
|
||||
});
|
||||
const atRef: Partial<Record<ParapetMark, number>> = { P1: lenP1(uRef), P2: lenP2(uRef), P3: lenP3(uRef) };
|
||||
if (shortBack) add('RB-AP01', 'W', `흉벽 높이 − 브라켓 높이가 l_d ${ld.H16}보다 작아 P2 뒤 다리가 교좌면 아래로 내려갑니다.`);
|
||||
|
||||
// 수평 단 (§5): 흉벽 상단에서 c + k·s_v 아래, 상단 경사와 나란함.
|
||||
const endIn = (size: BarSize) => (c + (d(SZ.P11) + d(size)) / 2) / sin;
|
||||
const levelZ = (u: number, k: number) => top(u) - c - k * sHoriz;
|
||||
const kMax = Math.ceil((Math.max(g.top[0], g.top[1]) - W.HF) / sHoriz) + 1;
|
||||
// One bar per run of segments where `ok(k, segment)` holds at both segment ends.
|
||||
const runs = (mark: ParapetMark, x: number, ok: (k: number, a: number, b: number) => boolean) => {
|
||||
for (let k = 0; k <= kMax; k++) {
|
||||
const valid = segs.map((sg) => ok(k, sg.from, sg.to));
|
||||
for (let a = 0; a <= last; a++) {
|
||||
if (!valid[a] || (a > 0 && valid[a - 1])) continue;
|
||||
let b = a;
|
||||
while (b < last && valid[b + 1]) b++;
|
||||
const ua = a === 0 ? endIn(SZ[mark]) : segs[a].from + c;
|
||||
const ub = b === last ? W.L - endIn(SZ[mark]) : segs[b].to - c;
|
||||
put(mark, [[ua, x, levelZ(ua, k)], [ub, x, levelZ(ub, k)]], Math.hypot(ub - ua, levelZ(ub, k) - levelZ(ua, k)), (ua + ub) / 2);
|
||||
}
|
||||
}
|
||||
};
|
||||
const aboveSeat = (k: number, u: number) => levelZ(u, k) >= seat(Math.min(u, W.L - 1e-6)) + c - 1e-6;
|
||||
const upper = (k: number, u: number) => levelZ(u, k) >= br(u) - 1e-6;
|
||||
const lower = (k: number, u: number) => levelZ(u, k) <= br(u) - c + 1e-6;
|
||||
const segAt = (sg: { from: number; to: number }) => [sg.from, Math.max(sg.from, sg.to - 1e-6)];
|
||||
runs('P4', xP4, (k, a, b) => segAt({ from: a, to: b }).every((u) => aboveSeat(k, u)));
|
||||
runs('P5', xP5up, (k, a, b) => segAt({ from: a, to: b }).every((u) => upper(k, u)));
|
||||
runs('P5', xP5low, (k, a, b) => segAt({ from: a, to: b }).every((u) => lower(k, u) && aboveSeat(k, u)));
|
||||
const countAt = (u: number, ok: (k: number, u: number) => boolean) => Array.from({ length: kMax + 1 }, (_, k) => k).filter((k) => ok(k, u)).length;
|
||||
const levels = {
|
||||
P4: countAt(uRef, aboveSeat),
|
||||
P5up: countAt(uRef, upper),
|
||||
P5low: countAt(uRef, (k, u) => lower(k, u) && aboveSeat(k, u)),
|
||||
P6: 0,
|
||||
P7: 0,
|
||||
};
|
||||
|
||||
// P6 · P7 (§6): 교대축 @k·s_L, 연직 @k·s_v, 단마다 반 칸 엇갈림. 길이는 skew 방향 (1/sin θ, 벽체 W7과 같은 기준).
|
||||
const h13 = hook(SZ.P6);
|
||||
const sVertBase = s.sVert ?? s.sL;
|
||||
const g67 = gridLayout(W.L, c / sin, wall.settings.eMin, s.k67L * sVertBase);
|
||||
const step = s.k67L * sVertBase;
|
||||
const lo = g67.edge;
|
||||
const hi = W.L - g67.edge;
|
||||
const pRef = g67.positions.reduce((a, b) => (Math.abs(b - uRef) < Math.abs(a - uRef) ? b : a), g67.positions[0] ?? 0);
|
||||
const tie = (mark: 'P6' | 'P7', x0: number, zFrom: (u: number) => number, zTo: (u: number) => number) => {
|
||||
let refCount = 0;
|
||||
g67.positions.forEach((p) => {
|
||||
for (let j = 0, z = zFrom(p); z <= zTo(p) + 1e-6; j++, z += s.k67V * sHoriz) {
|
||||
const u = Math.min(hi, Math.max(lo, p + (j % 2 === 1 ? step / 2 : 0)));
|
||||
put(mark, [[u, x0, z - h13], [u, x0, z], [u, xP4, z], [u, xP4, z - h13]], (xP4 - x0) / sin + 2 * h13, u);
|
||||
if (p === pRef) refCount++;
|
||||
}
|
||||
});
|
||||
return refCount;
|
||||
};
|
||||
levels.P6 = tie('P6', xP3, (u) => seat(u) + c, (u) => br(u) - c);
|
||||
levels.P7 = tie('P7', xP2back, (u) => br(u) + c, (u) => top(u) - c);
|
||||
|
||||
// P8 · P9 (§7): 끝면 ㄷ형, P11 안쪽 c + (d_P11 + d_P8)/2. 단은 P5 아래 · 위와 같은 높이.
|
||||
const in8 = (c + (d(SZ.P11) + d(SZ.P8)) / 2) / sin;
|
||||
for (const [u, dir] of [[in8, 1], [W.L - in8, -1]] as const) {
|
||||
const leg = u + dir * ld.H13;
|
||||
for (let k = 0; k <= kMax; k++) {
|
||||
const z = levelZ(u, k);
|
||||
if (upper(k, u)) put('P9', [[leg, xP2back, z], [u, xP2back, z], [u, xP4, z], [leg, xP4, z]], (xP4 - xP2back) / sin + 2 * ld.H13, u);
|
||||
else if (lower(k, u) && aboveSeat(k, u)) put('P8', [[leg, xP3, z], [u, xP3, z], [u, xP4, z], [leg, xP4, z]], (xP4 - xP3) / sin + 2 * ld.H13, u);
|
||||
}
|
||||
}
|
||||
|
||||
// P11 (§7.1): 끝면 최외측 연직, x = P4 선 · P2 뒤 다리 선 (흉벽 상단 − c까지) · P3 선 (브라켓 상면 − c까지).
|
||||
for (const u of [c / sin, W.L - c / sin]) {
|
||||
const z0 = seat(u) - ld.H16;
|
||||
for (const [x, zt] of [[xP4, top(u) - c], [xP2back, top(u) - c], [xP3, br(u) - c]] as const) put('P11', [[u, x, z0], [u, x, zt]], zt - z0, u);
|
||||
}
|
||||
|
||||
// P10 (§8): n = floor((B − 2c)/s_10) + 1, 슬래브 가운데 맞춤. P3 수평 다리(브라켓 상면 − c)와 (d_P10 + d_P3)/2 이상 떨어지게
|
||||
// 가장 가까운 위치로 옮기고, 반 칸 안에 없으면 RB-AP06 (사용자 결정 2026-11-04, Q-P5). 접속슬래브 R3 회피는 AD8에서.
|
||||
const dowel = { n: 0, e: 0, moved: 0, positions: [] as number[] };
|
||||
if (g.slab) {
|
||||
const { B, a, b } = g.slab;
|
||||
const n = Math.floor((B - 2 * c) / s.s10 + 1e-9) + 1;
|
||||
const e = (B - 2 * c - (n - 1) * s.s10) / 2;
|
||||
const mid = (a + b) / 2;
|
||||
const p3 = base.map((u) => u + offP3);
|
||||
const gap = (d(SZ.P10) + d(SZ.P3)) / 2;
|
||||
const clear = (u: number) => p3.every((q) => Math.abs(u - q) >= gap - 1e-6);
|
||||
let lost = 0;
|
||||
for (let i = 0; i < n; i++) {
|
||||
const u0 = mid + (i - (n - 1) / 2) * s.s10;
|
||||
let u = u0;
|
||||
if (!clear(u0)) {
|
||||
const cands = p3.flatMap((q) => [q - gap, q + gap]).filter(clear).sort((x, y) => Math.abs(x - u0) - Math.abs(y - u0));
|
||||
u = cands[0] ?? u0;
|
||||
if (cands.length === 0 || Math.abs(u - u0) > s.s10 / 2) lost++;
|
||||
else dowel.moved++;
|
||||
}
|
||||
dowel.positions.push(u);
|
||||
const zb = br(u);
|
||||
put('P10', [[u, xP10, zb - s.L10 / 2], [u, xP10, zb + s.L10 / 2]], s.L10, u);
|
||||
}
|
||||
dowel.n = n;
|
||||
dowel.e = e;
|
||||
if (n < 2) add('RB-AP02', 'W', `다웰 P10이 ${n}개입니다 (슬래브 폭 · 간격 확인).`);
|
||||
if (lost) add('RB-AP06', 'W', `다웰 P10 ${lost}개는 P3 수평 다리와 ${lenText(gap)} 이상 떨어진 위치를 반 칸 안에서 찾지 못했습니다.`);
|
||||
// 모따기 (§8): 첫 · 끝 다웰의 슬래브 측면 거리 + 슬래브 끝 거리 ≤ 모따기면 모서리 안.
|
||||
const fromEnd = g.slab.nearT - xP10;
|
||||
const first = dowel.positions[0] - a;
|
||||
const lastD = b - dowel.positions[dowel.positions.length - 1];
|
||||
if ((g.slab.chamferA > 0 && first + fromEnd <= g.slab.chamferA) || (g.slab.chamferB > 0 && lastD + fromEnd <= g.slab.chamferB)) {
|
||||
add('RB-AP05', 'W', '끝 다웰이 접속슬래브 모서리 모따기 안에 듭니다.');
|
||||
}
|
||||
} else {
|
||||
add('RB-AP02', 'W', '접속슬래브 폭을 알 수 없어 다웰 P10을 놓지 않습니다.');
|
||||
}
|
||||
|
||||
// 표 (시안 순서).
|
||||
const bars: AbutParapetBar[] = [];
|
||||
const push = (b: Omit<AbutParapetBar, 'length' | 'lengthMin' | 'lengthMax' | 'count' | 'splice'>) => {
|
||||
const all = lengths.get(b.mark) ?? [];
|
||||
if (all.length === 0) return;
|
||||
const ls = all.map((x) => x.l);
|
||||
const ref = all.reduce((p, q) => (Math.abs(q.u - uRef) < Math.abs(p.u - uRef) ? q : p));
|
||||
const max = Math.max(...ls);
|
||||
const kinds = new Set(ls.map((l) => Math.round(l))).size;
|
||||
if (kinds > 10) add('RB-AP04', 'I', `${b.mark} 길이가 흉벽 상단 경사 · 계단으로 ${kinds}종입니다 (철근표에는 범위).`);
|
||||
const splice = splicePlan(max, d(b.size), fy(b.size), material.fck) ?? undefined;
|
||||
if (splice) add('RB-AP07', splice.over ? 'W' : 'I', `${b.mark} ${Math.round(max)} > 정척 ${STANDARD_BAR}: ${splice.pieces}조각 이음 l_s ${splice.lap}.`);
|
||||
bars.push({ ...b, note: splice ? `${b.note} · ${splice.pieces}조각 이음 l_s ${splice.lap}` : b.note, length: Math.round(atRef[b.mark] ?? ref.l), lengthMin: Math.round(Math.min(...ls)), lengthMax: Math.round(max), count: all.length, splice });
|
||||
};
|
||||
const vMod = s.sVert !== undefined;
|
||||
const hMod = s.sHoriz !== undefined;
|
||||
const sv = s.sVert ?? s.sL;
|
||||
const vSp = lenText(2 * sv);
|
||||
const hSp = lenText(sHoriz);
|
||||
const tieSp = `${lenText(s.k67L * sv)} × ${lenText(s.k67V * sHoriz)}`;
|
||||
const off = (v: number) => (v >= 0 ? `+ ${lenText(v)}` : `− ${lenText(-v)}`);
|
||||
push({ mark: 'P1', size: SZ.P1, role: '연직', spacing: vSp, actual: vSp, shape: `직선 · 교좌면 아래 l_d ${ld.H16}`, position: `전면 x ${lenText(xP1)} (P4 안쪽) · cycle-1`, note: `격자 = 저판 ${off(offsets.P1)} (W2 비켜감) · 위 P2 아래`, basis: '가정', modified: vMod });
|
||||
push({ mark: 'P2', size: SZ.P2, role: '연직', spacing: vSp, actual: lenText(sv), shape: `⊓ 앞 다리 l_d · 뒤 다리 브라켓 상면 아래 l_d`, position: `흉벽 상단 − ${lenText(c)} · 수평부 x ${lenText(xP2back)} ~ ${lenText(xP1)}`, note: `cycle-1 · 2 · 격자 저판 ${off(offsets.P2)}`, basis: '가정', modified: vMod });
|
||||
push({ mark: 'P3', size: SZ.P3, role: '연직', spacing: vSp, actual: lenText(sv), shape: `ㄱ형 · 연직 + 위 끝 수평 다리 (x ${lenText(xP4)}까지)`, position: `브라켓 배면 x ${lenText(xP3)} · 위 끝 브라켓 상면 − ${lenText(c)}`, note: `cycle-1 · 2 · 격자 저판 ${off(offsets.P3)} · 다웰 P10은 이 수평 다리를 비켜감 (Q-P5)`, basis: '가정', modified: vMod });
|
||||
push({ mark: 'P4', size: SZ.P4, role: '수평', spacing: hSp, actual: hSp, shape: '직선 · 상단 경사와 나란함', position: `전면 최외측 x ${lenText(xP4)} · 교대축`, note: `대표 단면 ${levels.P4}단 · 교좌면 계단에서 끊음`, basis: '규칙', modified: hMod });
|
||||
push({ mark: 'P5', size: SZ.P5, role: '수평', spacing: hSp, actual: hSp, shape: '직선 · 상단 경사와 나란함', position: `배면 · 브라켓 상면 위 x ${lenText(xP5up)} / 아래 x ${lenText(xP5low)}`, note: `대표 단면 위 ${levels.P5up}단 + 아래 ${levels.P5low}단 (브라켓 상면 높이 단은 위)`, basis: '규칙', modified: hMod });
|
||||
push({ mark: 'P6', size: SZ.P6, role: '간격재', spacing: tieSp, actual: '—', shape: `양끝 90° 갈고리 ${h13}`, position: `브라켓 높이 구간 간격재 · 두께 ${lenText(xP4 - xP3)}`, note: `${s.k67L}·s × ${s.k67V}·s · 단마다 반 칸 엇갈림 · skew 방향 (1/sin θ)`, basis: '규칙', modified: s.k67L !== 8 || s.k67V !== 4 });
|
||||
push({ mark: 'P7', size: SZ.P7, role: '간격재', spacing: tieSp, actual: '—', shape: `양끝 90° 갈고리 ${h13}`, position: `흉벽 간격재 · 두께 ${lenText(xP4 - xP2back)}`, note: `${s.k67L}·s × ${s.k67V}·s · skew 방향 (1/sin θ)`, basis: '규칙', modified: s.k67L !== 8 || s.k67V !== 4 });
|
||||
push({ mark: 'P8', size: SZ.P8, role: '측면', spacing: hSp, actual: hSp, shape: `ㄷ형 · 다리 l_d ${ld.H13}`, position: '측면 하단 (브라켓 높이 구간)', note: `끝면에서 ${lenText(in8 * sin)}`, basis: '규칙', modified: hMod });
|
||||
push({ mark: 'P9', size: SZ.P9, role: '측면', spacing: hSp, actual: hSp, shape: `ㄷ형 · 다리 l_d ${ld.H13}`, position: '측면 상단 (브라켓 상면 위)', note: '삽도 기준 상단 = P9', basis: '규칙', modified: hMod });
|
||||
push({ mark: 'P10', size: SZ.P10, role: '다웰', spacing: lenText(s.s10), actual: lenText(s.s10), shape: `직선 · 브라켓 ${lenText(s.L10 / 2)} + 슬래브 ${lenText(s.L10 / 2)}`, position: `브라켓 상면 x ${lenText(xP10)} · 다웰`, note: `n = floor((B − 2c)/${lenText(s.s10)}) + 1 · 양끝 여유 ${lenText(dowel.e)}${dowel.moved ? ` · ${dowel.moved}개는 P3 다리를 비켜 옮김` : ''} · R3 회피는 접속슬래브 배근에서`, basis: '규칙', modified: s.s10 !== 400 || s.L10 !== 600 });
|
||||
push({ mark: 'P11', size: SZ.P11, role: '연직', spacing: '—', actual: '—', shape: '직선', position: `끝면 최외측 연직 · x ${lenText(xP4)} · ${lenText(xP2back)} / ${lenText(xP3)}`, note: `W11 위로 이음 (교좌면 − l_d) · 위 흉벽 상단 − ${lenText(c)} / 브라켓 상면 − ${lenText(c)}`, basis: '가정', modified: false });
|
||||
|
||||
return {
|
||||
settings: s,
|
||||
x: { P1: xP1, P2back: xP2back, P3: xP3, P4: xP4, P5up: xP5up, P5low: xP5low, P10: xP10 },
|
||||
offsets,
|
||||
levels,
|
||||
uRef,
|
||||
dowel,
|
||||
ld,
|
||||
paths,
|
||||
bars,
|
||||
issues,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* 교대 skew 배근 공통 (plan_abut AD1, spec_abut §6.1, 2026-11-04). 순수 함수, 길이는 mm.
|
||||
*
|
||||
* - 교대축 직각 철근은 날개벽(skew) 선과 나란히 뻗고, 간격은 교대축을 따라 잰다. 실제 직각 간격은 s · sin θ다.
|
||||
* - skew 끝면의 피복을 교대축 방향으로 재면 c / sin θ, 폭을 skew 선 방향으로 재면 W / sin θ다.
|
||||
* - θ는 교대축과 날개벽 선의 사잇각이다 (예제 A1 95°02′52″, sin θ = 0.99612).
|
||||
*/
|
||||
|
||||
export interface SkewAngle {
|
||||
/** sin θ (> 0). */
|
||||
sin: number;
|
||||
/** |cot θ|: the shift of the end face along the axis per unit of width. 0 for a square plan. */
|
||||
cotAbs: number;
|
||||
}
|
||||
|
||||
export function skewAngle(skewDeg: number): SkewAngle {
|
||||
const r = (skewDeg * Math.PI) / 180;
|
||||
return { sin: Math.abs(Math.sin(r)), cotAbs: Math.abs(Math.cos(r) / Math.sin(r)) };
|
||||
}
|
||||
|
||||
/** A square plan (θ = 90°). */
|
||||
export const SQUARE: SkewAngle = { sin: 1, cotAbs: 0 };
|
||||
|
||||
/** One row of bars laid out over a length with an edge on both sides (교각 확대기초 §3.1과 같은 식). */
|
||||
export interface GridLayout {
|
||||
/** 배치 가능 길이 A = total − 2·edge. */
|
||||
A: number;
|
||||
/** 격자 칸 수 n = floor((A − 2·e_min) / s); positions n + 1. Below 0 when nothing fits (RB-AF01). */
|
||||
n: number;
|
||||
s: number;
|
||||
/** 격자 길이 G = n·s and the end gap e = (A − G) / 2. */
|
||||
G: number;
|
||||
e: number;
|
||||
edge: number;
|
||||
/** Positions from the start of `total`: edge + e + i·s. */
|
||||
positions: number[];
|
||||
}
|
||||
|
||||
export function gridLayout(total: number, edge: number, eMin: number, s: number): GridLayout {
|
||||
const A = total - 2 * edge;
|
||||
const n = Math.floor((A - 2 * eMin) / s);
|
||||
const G = Math.max(0, n) * s;
|
||||
const e = (A - G) / 2;
|
||||
const positions = n < 0 ? [] : Array.from({ length: n + 1 }, (_, i) => edge + e + i * s);
|
||||
return { A, n, s, G, e, edge, positions };
|
||||
}
|
||||
|
||||
/** A length as the drawings write it: m with 3 decimals from 1000 mm ("4.050"), else mm with up to 1 decimal ("185.8", "175"). */
|
||||
export function lenText(mm: number): string {
|
||||
if (mm >= 1000 - 1e-9) return (mm / 1000).toFixed(3);
|
||||
const r = Math.round(mm * 10) / 10;
|
||||
return Number.isInteger(r) ? String(r) : r.toFixed(1);
|
||||
}
|
||||
|
||||
/** "100.4 · 185.8 · 93@125=11.625 · 185.8 · 100.4" of a grid (edge · e · n@s=G · e · edge). */
|
||||
export function gridText(g: GridLayout): string {
|
||||
return [lenText(g.edge), lenText(g.e), `${g.n}@${lenText(g.s)}=${lenText(g.G)}`, lenText(g.e), lenText(g.edge)].join(' · ');
|
||||
}
|
||||
@@ -0,0 +1,310 @@
|
||||
/**
|
||||
* 교대 접속슬래브 배근 · AD8 (2026-11-05, 교대접속슬래브철근.md, 결정 2026-11-02 · 2026-11-03 · 2026-11-05). 순수 함수, 길이는 mm.
|
||||
*
|
||||
* - R1 · R3 상 · 하면 길이 방향 주철근 (교량 진행방향, 측면과 나란히): 교대축을 따라 A = B − 2c, 상면 2·s_a로 구간을 정하고 하면은
|
||||
* 같은 구간을 s_a로 (결정 A). R2 · R4 상 · 하면 교대축 방향 (끝면과 나란히): 길이를 따라 같은 방식, 2·s_l / s_l.
|
||||
* - 모따기 (결정 E): 끝점에서 모따기선까지 < c이면 모따기선 − c에서 자름. R7: 모따기 모서리마다 상 · 하, 끝면 다리 l_d + 대각 + 측면 다리 l_d.
|
||||
* - R8: 하면 교점 @k_8, 이웃 줄 반 칸 엇갈림, R1 · R3을 감쌈 (135° 갈고리). 모따기 안은 뺌. P10 다웰과의 간섭은 보지 않는다 (사용자 결정 2026-11-05).
|
||||
* - 노출형도 같은 규칙 (사용자 결정 2026-11-05): 상면 철근은 상면을, 하면 철근은 하면을 따라간다.
|
||||
* - 좌표: 교대 (s, t, z) mm — s 교대축, t 전면 +, z 저판 하면에서. u = 슬래브 0 측면(s 작은 쪽)에서 교대축을 따라 잰 거리,
|
||||
* t' = 흉벽 쪽 끝에서 길이 방향(t −)으로 잰 거리.
|
||||
*/
|
||||
import { DEFAULT_MATERIAL, developmentLength, fyOf, NOMINAL_DIAMETER, STANDARD_BAR, type BarSize, type Material } from '../../rebar/common';
|
||||
import type { Issue } from '../../types';
|
||||
import { lenText } from './skew-layout';
|
||||
import { splicePlan, type SplicePlan } from './splice';
|
||||
|
||||
export interface AbutSlabRebarSettings {
|
||||
/** 피복 상 · 하면 / 측면 · 끝면. */
|
||||
cv: number;
|
||||
c: number;
|
||||
/** 접속슬래브 기본간격: 교대축 / 교대축 직각. */
|
||||
sA: number;
|
||||
sL: number;
|
||||
eMin: number;
|
||||
/** R8 배수 (두 방향), 엇갈림 k_8·s/2. */
|
||||
k8: number;
|
||||
/** 묶음별 간격 수정 (하면 값, 상면은 2배): R1 · R3 (교대축), R2 · R4 (길이). */
|
||||
sMain?: number;
|
||||
sDist?: number;
|
||||
}
|
||||
|
||||
export const ABUT_SLAB_REBAR_DEFAULT: AbutSlabRebarSettings = { cv: 80, c: 100, sA: 150, sL: 150, eMin: 150, k8: 8 };
|
||||
|
||||
export const ABUT_SLAB_SIZES = { R1: 'H22', R3: 'H22', R2: 'H13', R4: 'H13', R7: 'H13', R8: 'H13' } as const satisfies Record<string, BarSize>;
|
||||
export type SlabMark = keyof typeof ABUT_SLAB_SIZES;
|
||||
|
||||
type V2 = [number, number];
|
||||
|
||||
/** 슬래브 형상 (mm). */
|
||||
export interface AbutSlabGeom {
|
||||
form: 'buried' | 'exposed';
|
||||
/** 폭 (교대축 방향, 흉벽 쪽 끝에서) · 길이 (t 방향). */
|
||||
B: number;
|
||||
Ls: number;
|
||||
/** 흉벽 쪽 끝 t, 먼 끝 t. */
|
||||
near: number;
|
||||
far: number;
|
||||
/** 0 측면 · L 측면 선: 흉벽 쪽 끝 위의 점 (s, t)과 ds/dt (측면 기울기). */
|
||||
side0: { s: number; k: number };
|
||||
sideL: { s: number; k: number };
|
||||
/** 측별 모서리 모따기 (0 = 없음). */
|
||||
chamfer0: number;
|
||||
chamferL: number;
|
||||
/** 측면과 교대축 사잇각의 sin (측면 피복 c를 교대축 방향으로 잴 때 c / sin). */
|
||||
sin: number;
|
||||
/** 단면 (t 내림차순): 흉벽 상단 기준 상면 · 하면 (mm), 흉벽 상단 z (s = 0 · s = L, 저판 하면에서), 교대축 길이 L. */
|
||||
stations: { t: number; top: number; bottom: number }[];
|
||||
parapetTop: [number, number];
|
||||
L: number;
|
||||
/** 경사율 (표시용, %). */
|
||||
gradePct: number;
|
||||
}
|
||||
|
||||
export type SlabRole = '주철근' | '배력철근' | '모서리' | '전단';
|
||||
|
||||
export interface AbutSlabBar {
|
||||
mark: SlabMark;
|
||||
size: BarSize;
|
||||
role: SlabRole;
|
||||
spacing: string;
|
||||
actual: string;
|
||||
shape: string;
|
||||
length: number;
|
||||
lengthMin: number;
|
||||
lengthMax: number;
|
||||
count: number;
|
||||
position: string;
|
||||
note: string;
|
||||
basis: '규칙' | '가정';
|
||||
modified: boolean;
|
||||
splice?: SplicePlan;
|
||||
}
|
||||
|
||||
export interface SlabBarPath {
|
||||
mark: SlabMark;
|
||||
pts: [number, number, number][];
|
||||
}
|
||||
|
||||
export interface AbutSlabRebar {
|
||||
settings: AbutSlabRebarSettings;
|
||||
z: { R1: number; R2: number; R3: number; R4: number };
|
||||
/** 교대축 방향 배치 (u, 상면 R1 / 하면 R3) · 길이 방향 배치 (t', R2 / R4), 치수 문자열. */
|
||||
across: { r1: number[]; r3: number[]; e: number; text: string };
|
||||
along: { r2: number[]; r4: number[]; e: number; text: string };
|
||||
trimmed: number;
|
||||
ld: number;
|
||||
paths: SlabBarPath[];
|
||||
bars: AbutSlabBar[];
|
||||
issues: Issue[];
|
||||
}
|
||||
|
||||
const d = (size: BarSize) => NOMINAL_DIAMETER[size];
|
||||
|
||||
export function resolveSlabRebar(changes?: Partial<AbutSlabRebarSettings>): AbutSlabRebarSettings {
|
||||
return { ...ABUT_SLAB_REBAR_DEFAULT, ...(changes ?? {}) };
|
||||
}
|
||||
|
||||
export function slabRebarIssues(s: AbutSlabRebarSettings): string[] {
|
||||
const out: string[] = [];
|
||||
for (const key of ['cv', 'c', 'sA', 'sL', 'eMin'] as const) if (!(s[key] > 0)) out.push(`${key}은(는) 0보다 커야 합니다.`);
|
||||
for (const key of ['sMain', 'sDist'] as const) if (s[key] !== undefined && !((s[key] as number) > 0)) out.push('철근 간격은 0보다 커야 합니다.');
|
||||
if (!(Number.isInteger(s.k8) && s.k8 >= 1)) out.push('R8 배수는 1 이상의 정수여야 합니다.');
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 상면 2s 격자로 구간을 정하고 하면은 같은 구간을 s로 (결정 A): A = total − 2·edge. */
|
||||
function halfGrid(total: number, edge: number, eMin: number, s: number) {
|
||||
const A = total - 2 * edge;
|
||||
const n = Math.max(0, Math.floor((A - 2 * eMin) / (2 * s) + 1e-9));
|
||||
const G = n * 2 * s;
|
||||
const e = (A - G) / 2;
|
||||
const top = Array.from({ length: n + 1 }, (_, i) => edge + e + i * 2 * s);
|
||||
const bottom = Array.from({ length: 2 * n + 1 }, (_, i) => edge + e + i * s);
|
||||
const text = `${lenText(edge)} · ${lenText(e)} · ${n}@${lenText(2 * s)}=${lenText(G)} · ${lenText(e)} · ${lenText(edge)}`;
|
||||
return { top, bottom, e, text, n };
|
||||
}
|
||||
|
||||
export function abutSlabRebar(g: AbutSlabGeom, changes?: Partial<AbutSlabRebarSettings>, material: Material = DEFAULT_MATERIAL, who = ''): AbutSlabRebar {
|
||||
const s = resolveSlabRebar(changes);
|
||||
const issues: Issue[] = [];
|
||||
const add = (code: string, level: Issue['level'], message: string) => issues.push({ code, level, pier: who || undefined, message });
|
||||
const SZ = ABUT_SLAB_SIZES;
|
||||
const c = s.c;
|
||||
const sMain = s.sMain ?? s.sA;
|
||||
const sDist = s.sDist ?? s.sL;
|
||||
if (s.sMain !== undefined || s.sDist !== undefined || s.k8 !== 8) add('RB-AS04', 'I', '철근 간격을 기본간격과 따로 고쳤거나 R8 배수가 기본값(8)이 아닙니다.');
|
||||
const fy = (size: BarSize) => fyOf(material, size);
|
||||
const ld = Math.ceil(developmentLength(d(SZ.R7), fy(SZ.R7), material.fck) - 1e-9);
|
||||
|
||||
// 높이 (§3): 상면 · 하면에서 잰다 (경사면과 나란히).
|
||||
const z = {
|
||||
R1: s.cv,
|
||||
R2: s.cv + (d(SZ.R1) + d(SZ.R2)) / 2,
|
||||
R3: s.cv,
|
||||
R4: s.cv + (d(SZ.R3) + d(SZ.R4)) / 2,
|
||||
};
|
||||
const parapetAt = (sv: number) => g.parapetTop[0] + ((g.parapetTop[1] - g.parapetTop[0]) * sv) / g.L;
|
||||
const level = (t: number) => {
|
||||
const st = g.stations;
|
||||
for (let i = 0; i + 1 < st.length; i++) {
|
||||
if (t <= st[i].t + 1e-6 && t >= st[i + 1].t - 1e-6) {
|
||||
const k = st[i].t === st[i + 1].t ? 0 : (st[i].t - t) / (st[i].t - st[i + 1].t);
|
||||
return { top: st[i].top + k * (st[i + 1].top - st[i].top), bottom: st[i].bottom + k * (st[i + 1].bottom - st[i].bottom) };
|
||||
}
|
||||
}
|
||||
const end = t > st[0].t ? st[0] : st[st.length - 1];
|
||||
return { top: end.top, bottom: end.bottom };
|
||||
};
|
||||
/** z of a layer at (s, t): top layers measured down from the top, bottom layers up from the bottom. */
|
||||
const zAt = (sv: number, t: number, layer: 'R1' | 'R2' | 'R3' | 'R4') => {
|
||||
const lv = level(t);
|
||||
const p = parapetAt(sv);
|
||||
return layer === 'R1' || layer === 'R2' ? p + lv.top - z[layer] : p + lv.bottom + z[layer];
|
||||
};
|
||||
const side0 = (t: number) => g.side0.s + g.side0.k * (t - g.near);
|
||||
const sideL = (t: number) => g.sideL.s + g.sideL.k * (t - g.near);
|
||||
const sAt = (u: number, t: number) => side0(t) + u; // u along +s from the 0 측면
|
||||
const tOf = (tp: number) => g.near - tp; // t' (from the near end) → t
|
||||
const chamferCut = c * Math.SQRT2;
|
||||
|
||||
const paths: SlabBarPath[] = [];
|
||||
const lengths = new Map<SlabMark, number[]>();
|
||||
const put = (mark: SlabMark, pts: [number, number, number][]) => {
|
||||
let len = 0;
|
||||
for (let i = 1; i < pts.length; i++) len += Math.hypot(pts[i][0] - pts[i - 1][0], pts[i][1] - pts[i - 1][1], pts[i][2] - pts[i - 1][2]);
|
||||
paths.push({ mark, pts });
|
||||
lengths.set(mark, [...(lengths.get(mark) ?? []), len]);
|
||||
};
|
||||
let trimmed = 0;
|
||||
|
||||
// R1 · R3 (§4 · §6): u 배치, t' = c … L_s − c, 흉벽 쪽 끝은 모따기에 걸리면 자름. 단면 꺾임마다 점을 둬 면을 따라감.
|
||||
const across = halfGrid(g.B, c, s.eMin, sMain);
|
||||
const stationTs = g.stations.map((x) => x.t);
|
||||
const runAlong = (mark: 'R1' | 'R3', u: number) => {
|
||||
const fromL = g.B - u;
|
||||
let tpNear = c;
|
||||
if (g.chamfer0 > 0 && u < g.chamfer0 - c + chamferCut) tpNear = g.chamfer0 + chamferCut - u;
|
||||
if (g.chamferL > 0 && fromL < g.chamferL - c + chamferCut) tpNear = Math.max(tpNear, g.chamferL + chamferCut - fromL);
|
||||
if (tpNear > c + 1e-6) trimmed++;
|
||||
const t0 = tOf(tpNear);
|
||||
const t1 = g.far + c;
|
||||
const ts = [t0, ...stationTs.filter((t) => t < t0 - 1e-6 && t > t1 + 1e-6), t1];
|
||||
put(mark, ts.map((t) => [sAt(u, t), t, zAt(sAt(u, t), t, mark)] as [number, number, number]));
|
||||
};
|
||||
for (const u of across.top) runAlong('R1', u);
|
||||
for (const u of across.bottom) runAlong('R3', u);
|
||||
|
||||
// R2 · R4 (§5 · §6): t' 배치, 측면 피복 c / sin, 모따기에 걸리는 쪽은 모따기선 − c.
|
||||
const along = halfGrid(g.Ls, c, s.eMin, sDist);
|
||||
const runAcross = (mark: 'R2' | 'R4', tp: number) => {
|
||||
const t = tOf(tp);
|
||||
let u0 = c / g.sin;
|
||||
let u1 = sideL(t) - side0(t) - c / g.sin;
|
||||
if (g.chamfer0 > 0 && tp < g.chamfer0 - c + chamferCut) {
|
||||
u0 = Math.max(u0, g.chamfer0 + chamferCut - tp);
|
||||
trimmed++;
|
||||
}
|
||||
if (g.chamferL > 0 && tp < g.chamferL - c + chamferCut) {
|
||||
u1 = Math.min(u1, sideL(t) - side0(t) - (g.chamferL + chamferCut - tp));
|
||||
trimmed++;
|
||||
}
|
||||
const a = sAt(u0, t);
|
||||
const b = sAt(u1, t);
|
||||
put(mark, [[a, t, zAt(a, t, mark)], [b, t, zAt(b, t, mark)]]);
|
||||
};
|
||||
for (const tp of along.top) runAcross('R2', tp);
|
||||
for (const tp of along.bottom) runAcross('R4', tp);
|
||||
if (trimmed) add('RB-AS02', 'I', `모따기에 걸려 짧아진 철근 ${trimmed}개 (모따기선 − c에서 자름).`);
|
||||
|
||||
// R7 (§6.1): 모따기 모서리마다 상(R2 층) · 하(R4 층). 끝면 피복선 다리 l_d → 모따기선 c 안쪽 대각 → 측면 피복선 다리 l_d.
|
||||
for (const [ch, side] of [[g.chamfer0, 0], [g.chamferL, 1]] as const) {
|
||||
if (!(ch > 0)) continue;
|
||||
const corner = ch + chamferCut - c; // 341.4 at 300
|
||||
for (const layer of ['R2', 'R4'] as const) {
|
||||
const pt = (u: number, tp: number): [number, number, number] => {
|
||||
const t = tOf(tp);
|
||||
const uu = side === 0 ? u : sideL(t) - side0(t) - u;
|
||||
const sv = sAt(uu, t);
|
||||
return [sv, t, zAt(sv, t, layer)];
|
||||
};
|
||||
put('R7', [pt(corner + ld, c), pt(corner, c), pt(c, corner), pt(c, corner + ld)]);
|
||||
}
|
||||
}
|
||||
|
||||
// R8 (§7): 하면 교점 — R3 위치를 k_8칸마다 (홀수 줄은 k_8/2 밀어), 줄은 R4 위치를 k_8칸마다. 모따기 삼각형 안은 뺌.
|
||||
const hook135 = 151;
|
||||
for (let j = 0; j < along.bottom.length; j += s.k8) {
|
||||
const row = (j / s.k8) % 2;
|
||||
const tp = along.bottom[j];
|
||||
const t = tOf(tp);
|
||||
for (let i = row ? Math.floor(s.k8 / 2) : 0; i < across.bottom.length; i += s.k8) {
|
||||
const u = across.bottom[i];
|
||||
if ((g.chamfer0 > 0 && u + tp < g.chamfer0) || (g.chamferL > 0 && g.B - u + tp < g.chamferL)) continue;
|
||||
const sv = sAt(u, t);
|
||||
const zb = zAt(sv, t, 'R3') - (d(SZ.R3) + d(SZ.R8)) / 2;
|
||||
const zt = zAt(sv, t, 'R1') + (d(SZ.R1) + d(SZ.R8)) / 2;
|
||||
const hk = hook135 / Math.SQRT2;
|
||||
put('R8', [[sv + hk, t, zb + hk], [sv, t, zb], [sv, t, zt], [sv + hk, t, zt - hk]]);
|
||||
}
|
||||
}
|
||||
|
||||
// 표.
|
||||
const bars: AbutSlabBar[] = [];
|
||||
const push = (b: Omit<AbutSlabBar, 'length' | 'lengthMin' | 'lengthMax' | 'count' | 'splice'>) => {
|
||||
const all = lengths.get(b.mark) ?? [];
|
||||
if (all.length === 0) return;
|
||||
const max = Math.max(...all);
|
||||
const splice = splicePlan(max, d(b.size), fy(b.size), material.fck) ?? undefined;
|
||||
if (splice) add('RB-AS01', splice.over ? 'W' : 'I', `${b.mark} ${Math.round(max)} > 정척 ${STANDARD_BAR}: ${splice.pieces}조각 이음 l_s ${splice.lap}.`);
|
||||
bars.push({ ...b, note: splice ? `${b.note} · ${splice.pieces}조각 이음 l_s ${splice.lap}` : b.note, length: Math.round(max), lengthMin: Math.round(Math.min(...all)), lengthMax: Math.round(max), count: all.length, splice });
|
||||
};
|
||||
const mMod = s.sMain !== undefined;
|
||||
const dMod = s.sDist !== undefined;
|
||||
push({ mark: 'R1', size: SZ.R1, role: '주철근', spacing: lenText(2 * sMain), actual: lenText(2 * sMain), shape: '직선 · 면과 나란히', position: `상면 최외측 (피복 ${lenText(s.cv)}) · 교량 진행방향`, note: `${across.text} · 삽도 ② = R1`, basis: '규칙', modified: mMod });
|
||||
push({ mark: 'R2', size: SZ.R2, role: '배력철근', spacing: lenText(2 * sDist), actual: lenText(2 * sDist), shape: '직선', position: `상면 R1 아래 · 교대축 방향 (끝면과 나란히)`, note: along.text, basis: '규칙', modified: dMod });
|
||||
push({ mark: 'R3', size: SZ.R3, role: '주철근', spacing: lenText(sMain), actual: lenText(sMain), shape: '직선 · 면과 나란히', position: `하면 최외측 (피복 ${lenText(s.cv)}) · 교량 진행방향`, note: 'R1 구간을 절반으로 (결정 A) · P10 다웰과의 간섭은 보지 않음', basis: '규칙', modified: mMod });
|
||||
push({ mark: 'R4', size: SZ.R4, role: '배력철근', spacing: lenText(sDist), actual: lenText(sDist), shape: '직선', position: '하면 R3 위 · 교대축 방향', note: 'R2 구간을 절반으로', basis: '규칙', modified: dMod });
|
||||
push({ mark: 'R7', size: SZ.R7, role: '모서리', spacing: '—', actual: '—', shape: `끝면 다리 l_d ${ld} · 모따기 대각 · 측면 다리 l_d ${ld}`, position: '흉벽 쪽 모따기 모서리 · 끝면 · 측면 피복선 위 · 상 · 하', note: '모서리마다 상 · 하 1개 · 상면 R2 층 / 하면 R4 층', basis: '가정', modified: false });
|
||||
push({ mark: 'R8', size: SZ.R8, role: '전단', spacing: `${lenText(s.k8 * sMain)} × ${lenText(s.k8 * sDist)}`, actual: '—', shape: `R1 · R3 감쌈 · 양끝 135° 갈고리 ${hook135}`, position: '전단 · 하면 교점 · 반 칸 엇갈림', note: '모따기 안은 뺌 · R5 · R6 결번', basis: '규칙', modified: s.k8 !== 8 });
|
||||
|
||||
return { settings: s, z, across: { r1: across.top, r3: across.bottom, e: across.e, text: across.text }, along: { r2: along.top, r4: along.bottom, e: along.e, text: along.text }, trimmed, ld, paths, bars, issues };
|
||||
}
|
||||
|
||||
/** 슬래브 형상 from the ② slab plan outline (abutPlanOutline().slab, m) and section (slabSection, m). */
|
||||
export function abutSlabGeom(
|
||||
form: 'buried' | 'exposed',
|
||||
outline: [number, number][],
|
||||
section: { t: number; top: number; bottom: number }[],
|
||||
parapet: { startEl: number; endEl: number; L: number; bottomEl: number },
|
||||
chamfer: [number, number],
|
||||
lengthMm: number,
|
||||
gradePct: number,
|
||||
): AbutSlabGeom {
|
||||
const n = outline.length;
|
||||
const near = section[0].t * 1000;
|
||||
const far = section[section.length - 1].t * 1000;
|
||||
const sideAt = (p: V2, q: V2) => {
|
||||
const k = (q[0] - p[0]) / (q[1] - p[1]);
|
||||
return { s: (p[0] + k * (near / 1000 - p[1])) * 1000, k };
|
||||
};
|
||||
const side0 = sideAt(outline[0], outline[n - 1]);
|
||||
const sideL = sideAt(outline[n - 3], outline[n - 2]);
|
||||
const k0 = side0.k;
|
||||
return {
|
||||
form,
|
||||
B: sideL.s - side0.s,
|
||||
Ls: lengthMm,
|
||||
near,
|
||||
far,
|
||||
side0,
|
||||
sideL,
|
||||
chamfer0: chamfer[0],
|
||||
chamferL: chamfer[1],
|
||||
sin: 1 / Math.sqrt(1 + k0 * k0),
|
||||
stations: section.map((x) => ({ t: x.t * 1000, top: x.top * 1000, bottom: x.bottom * 1000 })),
|
||||
parapetTop: [(parapet.startEl - parapet.bottomEl) * 1000, (parapet.endEl - parapet.bottomEl) * 1000],
|
||||
L: parapet.L * 1000,
|
||||
gradePct,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* 교대 겹침이음 공통 (plan_abut AD2, 교대확대기초철근.md §12.1, 결정 2026-11-02 Q-AF11). 순수 함수, 길이는 mm.
|
||||
*
|
||||
* l_d = 0.6·d_b·f_y / (λ·√fck), l_s = 1.3·l_d (올림 1 mm)
|
||||
* 조각 수 p = 가장 작은 p, (l + (p − 1)·l_s) / p ≤ L_bar
|
||||
* 이음 중심은 인접 철근끼리 ± l_s 엇갈림 → 끝 조각 길이가 ± l_s 바뀐다.
|
||||
*/
|
||||
import { spliceLength, STANDARD_BAR } from '../../rebar/common';
|
||||
|
||||
/** l_s (mm), rounded up to 1 mm. */
|
||||
export function lapLength(db: number, fy: number, fck: number): number {
|
||||
return Math.ceil(spliceLength(db, fy, fck) - 1e-9);
|
||||
}
|
||||
|
||||
export interface SplicePlan {
|
||||
pieces: number;
|
||||
lap: number;
|
||||
/** Piece length with the splice in the middle (equal pieces). */
|
||||
even: number;
|
||||
/** End piece lengths with the stagger: [even − l_s, even + l_s]. */
|
||||
staggered: [number, number];
|
||||
/** True when a staggered piece is still longer than L_bar (RB-AF08 경고). */
|
||||
over: boolean;
|
||||
}
|
||||
|
||||
/** The splice of a bar longer than L_bar; null when it fits in one piece. */
|
||||
export function splicePlan(total: number, db: number, fy: number, fck: number, standard = STANDARD_BAR): SplicePlan | null {
|
||||
if (total <= standard) return null;
|
||||
const lap = lapLength(db, fy, fck);
|
||||
let p = 2;
|
||||
while ((total + (p - 1) * lap) / p > standard && p < 20) p++;
|
||||
const even = (total + (p - 1) * lap) / p;
|
||||
const staggered: [number, number] = [even - lap, even + lap];
|
||||
return { pieces: p, lap, even, staggered, over: staggered[1] > standard + 1e-9 };
|
||||
}
|
||||
@@ -0,0 +1,365 @@
|
||||
/**
|
||||
* 교대 벽체 배근 · AD4 (2026-11-04, 교대벽체철근.md, 결정 2026-11-02). 순수 함수, 길이는 mm.
|
||||
*
|
||||
* - W1 전면 연직 (cycle-1만) / W3 · W4 배면 연직 (cycle-1 / 2): 저판 격자 − (d_B13 + d_W)/2 (§4.3).
|
||||
* - W2 교좌면 상면 (교대축 직각, skew) / W9 (ㄷ) · W10 (ㄴ) 계단 상면 (교대축 방향, 낮은 쪽 갈고리 · 높은 쪽 직선 겹침).
|
||||
* - W5 · W6 전면 · 배면 수평 (계단에서 끊음) / W7 간격재 / W8 끝면 수평 (ㄷ형) / W11 끝면 연직 (ㄷ형).
|
||||
* - 간격은 벽체 기본간격(s_L, s_W, s_v)에서 계산한다. 묶음별로 고치면 그 묶음만 연동이 끊긴다(RB-AB04).
|
||||
* - 좌표: u = 교대축을 따라 IP1 쪽 끝면에서 잰 skew 격자 위치 (끝면과 나란한 선이 u 일정), t = 교대축에서 전면 +,
|
||||
* z = 저판 하면에서. 저판 배근(footing.ts)과 같은 u를 쓴다.
|
||||
*/
|
||||
import { DEFAULT_MATERIAL, developmentLength, fyOf, hookLength, NOMINAL_DIAMETER, STANDARD_BAR, type BarSize, type Material } from '../../rebar/common';
|
||||
import type { Issue } from '../../types';
|
||||
import { ABUT_FOOTING_SIZES, type AbutFootingRebar } from './footing';
|
||||
import { gridLayout, gridText, lenText, type GridLayout, type SkewAngle } from './skew-layout';
|
||||
import { splicePlan, type SplicePlan } from './splice';
|
||||
|
||||
export interface AbutWallRebarSettings {
|
||||
/** 피복 (전 면, 철근 중심까지). */
|
||||
c: number;
|
||||
/** 첫 철근 최소 여유 (끝면 피복 다음). */
|
||||
eMin: number;
|
||||
/** 벽체 기본간격: 교대축 / 교대축 직각 / 연직. */
|
||||
sL: number;
|
||||
sW: number;
|
||||
sV: number;
|
||||
/** W7 간격 배수 (교대축 / 연직), 정수 ≥ 1. */
|
||||
k7L: number;
|
||||
k7V: number;
|
||||
/** W9 · W10 정착장: 연직 갈고리 길이 = 높은 이웃 구간 철근과의 직선 겹침 길이 (사용자 도면 2026-11-04, 기본 330). */
|
||||
la: number;
|
||||
/** 묶음별 간격 수정 (없으면 기본간격에 연동): 연직 W1–W4 · W2 (교대축 실간격), 수평 W5 · W6 · W8 (연직), 계단 상면 W9 · W10 (폭). */
|
||||
sVert?: number;
|
||||
sHoriz?: number;
|
||||
sTop?: number;
|
||||
}
|
||||
|
||||
export const ABUT_WALL_REBAR_DEFAULT: AbutWallRebarSettings = { c: 100, eMin: 150, sL: 125, sW: 125, sV: 125, k7L: 8, k7V: 4, la: 330 };
|
||||
|
||||
/** 규격 (기획서 §1.2). W11 H16은 제안값 (삽도에 규격 표기 없음). */
|
||||
export const ABUT_WALL_SIZES = {
|
||||
W1: 'H19', W3: 'H19', W4: 'H19', W5: 'H19',
|
||||
W2: 'H16', W6: 'H16', W9: 'H16', W10: 'H16', W11: 'H16',
|
||||
W7: 'H13', W8: 'H13',
|
||||
} as const satisfies Record<string, BarSize>;
|
||||
export type WallMark = keyof typeof ABUT_WALL_SIZES;
|
||||
|
||||
/** 형상 (②에서 받음). */
|
||||
export interface AbutWallGeom {
|
||||
L: number;
|
||||
/** 벽체 배면 / 전면 t, 흉벽 배면 t (교대축 기준, 전면 +). */
|
||||
xb: number;
|
||||
xf: number;
|
||||
parapetBack: number;
|
||||
/** 저판 두께. */
|
||||
HF: number;
|
||||
angle: SkewAngle;
|
||||
/** 교좌면 계단 구간, u 순서. no = 구간 번호 (IP3 끝이 1). H = 벽체 높이 (저판 상면에서 교좌면까지). */
|
||||
segments: { from: number; to: number; H: number; no: number }[];
|
||||
/** 브라켓 상면 z (저판 하면에서) at u = 0 and u = L, straight between. */
|
||||
bracketTop: [number, number];
|
||||
}
|
||||
|
||||
export type WallRole = '연직' | '수평' | '간격재' | '계단 상면';
|
||||
|
||||
export interface AbutWallBar {
|
||||
mark: WallMark;
|
||||
size: BarSize;
|
||||
role: WallRole;
|
||||
spacing: string;
|
||||
actual: string;
|
||||
shape: string;
|
||||
/** 대표 길이 (구간 1) and the longest, when they differ by step. */
|
||||
length: number;
|
||||
lengthMax: number;
|
||||
count: number;
|
||||
position: string;
|
||||
note: string;
|
||||
/** 근거: 규칙 (철근표 · 결정) / 가정 (제안값 · fck 가정 정착). */
|
||||
basis: '규칙' | '가정';
|
||||
modified: boolean;
|
||||
splice?: SplicePlan;
|
||||
}
|
||||
|
||||
/** One bar as a polyline in (u, t, z), for the drawings and 3D. */
|
||||
export interface WallBarPath {
|
||||
mark: WallMark;
|
||||
pts: [number, number, number][];
|
||||
}
|
||||
|
||||
export interface AbutWallRebar {
|
||||
settings: AbutWallRebarSettings;
|
||||
/** 단면 위치 (t). */
|
||||
x: { W1: number; W34: number; W5: number; W6: number; W9: number[] };
|
||||
/** 연직철근 격자 (u) and its text; W1 · W3 = 짝수, W4 = 홀수. */
|
||||
vertical: { positions: number[]; layout: GridLayout; text: string; shift: number };
|
||||
zHook: { H19: number; H16: number };
|
||||
/** 구간별 수평 단 수와 치수 문자열 ("17@125=2.125 · 147"). */
|
||||
levels: { no: number; H: number; count: number; text: string }[];
|
||||
ld: Record<'H13' | 'H16' | 'H19', number>;
|
||||
paths: WallBarPath[];
|
||||
bars: AbutWallBar[];
|
||||
issues: Issue[];
|
||||
}
|
||||
|
||||
const d = (size: BarSize) => NOMINAL_DIAMETER[size];
|
||||
|
||||
export function resolveWallRebar(changes?: Partial<AbutWallRebarSettings>): AbutWallRebarSettings {
|
||||
return { ...ABUT_WALL_REBAR_DEFAULT, ...(changes ?? {}) };
|
||||
}
|
||||
|
||||
/** Input problems in Korean (the edit refuses them). */
|
||||
export function wallRebarIssues(s: AbutWallRebarSettings): string[] {
|
||||
const out: string[] = [];
|
||||
for (const key of ['c', 'eMin', 'sL', 'sW', 'sV', 'la'] as const) if (!(s[key] > 0)) out.push(`${key}은(는) 0보다 커야 합니다.`);
|
||||
for (const key of ['sVert', 'sHoriz', 'sTop'] as const) if (s[key] !== undefined && !((s[key] as number) > 0)) out.push('철근 간격은 0보다 커야 합니다.');
|
||||
if (!(Number.isInteger(s.k7L) && s.k7L >= 1 && Number.isInteger(s.k7V) && s.k7V >= 1)) out.push('W7 배수는 1 이상의 정수여야 합니다.');
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 벽체 형상 from the abutment members (m) → mm. */
|
||||
export function abutWallGeom(
|
||||
lengthM: number,
|
||||
members: {
|
||||
shape: { wallFrontM: number; wallBackM: number; parapetBackM: number; footingThicknessM: number; bracketHeightM: number };
|
||||
wallSegments: { sFrom: number; sTo: number; heightM: number }[];
|
||||
parapetTopStartEl: number;
|
||||
parapetTopEndEl: number;
|
||||
footingBottomEl: number | undefined;
|
||||
},
|
||||
angle: SkewAngle,
|
||||
): AbutWallGeom {
|
||||
const mm = (m: number) => Math.round(m * 1e6) / 1e3;
|
||||
const segs = [...members.wallSegments].sort((a, b) => a.sFrom - b.sFrom);
|
||||
const bottom = members.footingBottomEl ?? 0;
|
||||
const bracket = (el: number) => mm(el - members.shape.bracketHeightM - bottom);
|
||||
return {
|
||||
L: mm(lengthM),
|
||||
xb: mm(members.shape.wallBackM),
|
||||
xf: mm(members.shape.wallFrontM),
|
||||
parapetBack: mm(members.shape.parapetBackM),
|
||||
HF: mm(members.shape.footingThicknessM),
|
||||
angle,
|
||||
segments: segs.map((sg, i) => ({ from: mm(sg.sFrom), to: mm(sg.sTo), H: mm(sg.heightM), no: segs.length - i })),
|
||||
bracketTop: [bracket(members.parapetTopStartEl), bracket(members.parapetTopEndEl)],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The wall rebar of one abutment. `footing` gives the 저판 grid (B1 · B3) and the height of B6 · B8 (연직철근 갈고리).
|
||||
* `who` names the abutment in the issues.
|
||||
*/
|
||||
export function abutWallRebar(g: AbutWallGeom, footing: AbutFootingRebar, changes?: Partial<AbutWallRebarSettings>, material: Material = DEFAULT_MATERIAL, who = ''): AbutWallRebar {
|
||||
const s = resolveWallRebar(changes);
|
||||
const issues: Issue[] = [];
|
||||
const add = (code: string, level: Issue['level'], message: string) => issues.push({ code, level, pier: who || undefined, message });
|
||||
const SZ = ABUT_WALL_SIZES;
|
||||
const sin = g.angle.sin;
|
||||
const c = s.c;
|
||||
const sVert = s.sVert ?? s.sL;
|
||||
const sHoriz = s.sHoriz ?? s.sV;
|
||||
const sTop = s.sTop ?? 2 * s.sW;
|
||||
if (s.sVert !== undefined || s.sHoriz !== undefined || s.sTop !== undefined || s.k7L !== 8 || s.k7V !== 4 || s.la !== 330) {
|
||||
add('RB-AB04', 'I', '철근 간격을 기본간격과 따로 고쳤거나 W7 배수 · W9 · W10 정착장이 기본값(8 × 4 · 330)이 아닙니다. [되돌리기]로 다시 연동합니다.');
|
||||
}
|
||||
const fy = (size: BarSize) => fyOf(material, size);
|
||||
const ldOf = (size: BarSize) => Math.ceil(developmentLength(d(size), fy(size), material.fck) - 1e-9);
|
||||
const ld = { H13: ldOf('H13'), H16: ldOf('H16'), H19: ldOf('H19') };
|
||||
const hook = (size: BarSize) => Math.round(hookLength('main90', d(size)));
|
||||
|
||||
const segs = g.segments;
|
||||
const last = segs.length - 1;
|
||||
const segIndex = (u: number) => {
|
||||
const i = segs.findIndex((sg) => u < sg.to);
|
||||
return i < 0 ? last : i;
|
||||
};
|
||||
const zTop = (u: number) => g.HF + segs[segIndex(u)].H;
|
||||
const bracketTop = (u: number) => g.bracketTop[0] + ((g.bracketTop[1] - g.bracketTop[0]) * u) / g.L;
|
||||
const one = segs.find((sg) => sg.no === 1) ?? segs[0];
|
||||
const inOne = (u: number) => segs[segIndex(u)] === one;
|
||||
|
||||
// 단면 위치 (§3).
|
||||
const xW5 = g.xf - c;
|
||||
const xW1 = xW5 - (d(SZ.W5) + d(SZ.W1)) / 2;
|
||||
const xW34 = g.xb + c;
|
||||
const xW6 = xW34 + (d(SZ.W3) + d(SZ.W6)) / 2;
|
||||
const xW9: number[] = [];
|
||||
for (let x = xW1 - (d(SZ.W1) + d(SZ.W9)) / 2; x >= c - 1e-9; x -= sTop) xW9.push(x);
|
||||
|
||||
// 연직철근 격자 (§4.1 · §4.3): the wall's own grid, moved off the 저판 상면 철근.
|
||||
const layout = gridLayout(g.L, c / sin, s.eMin, sVert);
|
||||
const shift = (d(ABUT_FOOTING_SIZES.top) + d(SZ.W1)) / 2;
|
||||
const us = layout.positions.map((p) => p - shift);
|
||||
if (us.length < 2) add('RB-AB01', 'E', '연직철근을 놓을 수 없습니다 (길이 · 간격 확인).');
|
||||
if (footing.dir === 'skew') {
|
||||
const fTop = footing.main.positions.map((p, i) => (i % 2 === 0 ? p + footing.sideShift.B1 : p));
|
||||
const gap = (d(ABUT_FOOTING_SIZES.top) + d(SZ.W1)) / 2;
|
||||
const hits = us.filter((u) => fTop.some((f) => Math.abs(u - f) < gap - 1e-6)).length;
|
||||
if (hits) add('RB-AB03', 'W', `벽체 연직철근 ${hits}개가 저판 상면 철근과 중심 거리 ${lenText(gap)} 미만입니다 (벽체 · 저판 기본간격 확인).`);
|
||||
}
|
||||
|
||||
// 갈고리 높이 (§4.2): 저판 하면 철근망 B6 · B8 위.
|
||||
const zHookOf = (size: BarSize) => footing.z.B68 + (d(ABUT_FOOTING_SIZES.dist) + d(size)) / 2;
|
||||
const zHook = { H19: zHookOf('H19'), H16: zHookOf('H16') };
|
||||
|
||||
const paths: WallBarPath[] = [];
|
||||
const lengths = new Map<WallMark, number[]>();
|
||||
const repr = new Map<WallMark, number>();
|
||||
const put = (mark: WallMark, pts: [number, number, number][], length: number, representative: boolean) => {
|
||||
paths.push({ mark, pts });
|
||||
lengths.set(mark, [...(lengths.get(mark) ?? []), length]);
|
||||
if (representative && !repr.has(mark)) repr.set(mark, length);
|
||||
};
|
||||
|
||||
// W1 · W3 · W4 (§4.2): hooks outward (W1 앞굽 쪽, W3 · W4 뒷굽 쪽). W3 위 끝 = 교좌면 + l_d, 브라켓 상면 − c 이하.
|
||||
const h19 = hook(SZ.W1);
|
||||
let capped = 0;
|
||||
us.forEach((u, i) => {
|
||||
const top = zTop(u);
|
||||
const zh = zHook.H19;
|
||||
if (i % 2 === 0) {
|
||||
put('W1', [[u, xW1 + h19, zh], [u, xW1, zh], [u, xW1, top - c]], top - c - zh + h19, inOne(u));
|
||||
let up = top + ld.H19;
|
||||
if (up > bracketTop(u) - c) {
|
||||
up = bracketTop(u) - c;
|
||||
capped++;
|
||||
}
|
||||
put('W3', [[u, xW34 - h19, zh], [u, xW34, zh], [u, xW34, up]], up - zh + h19, inOne(u));
|
||||
} else {
|
||||
put('W4', [[u, xW34 - h19, zh], [u, xW34, zh], [u, xW34, top - c]], top - c - zh + h19, inOne(u));
|
||||
}
|
||||
});
|
||||
if (capped) add('RB-AB02', 'W', `W3 ${capped}개의 위 끝(교좌면 + l_d)이 브라켓 상면 − ${lenText(c)}를 넘어 그 높이에서 끊었습니다.`);
|
||||
|
||||
// W2 (§5.1): W1 위치 + 옆붙임, 뒤끝 90° 갈고리 아래로, 앞 다리 l_d 아래로.
|
||||
const h16 = hook(SZ.W2);
|
||||
const shiftW2 = (d(SZ.W1) + d(SZ.W2)) / 2;
|
||||
us.forEach((u, i) => {
|
||||
if (i % 2 !== 0) return;
|
||||
const z = zTop(u) - c;
|
||||
const u2 = u + shiftW2;
|
||||
put('W2', [[u2, xW34, z - h16], [u2, xW34, z], [u2, xW1, z], [u2, xW1, z - ld.H16]], (xW1 - xW34) / sin + h16 + ld.H16, inOne(u));
|
||||
});
|
||||
|
||||
// 끝 여유 (§6.1): W5 · W6 · W9 · W10 끝은 W11 안쪽.
|
||||
const endIn = (size: BarSize) => (c + (d(SZ.W11) + d(size)) / 2) / sin;
|
||||
|
||||
// W9 · W10 (§5.2, 사용자 도면 2026-11-04): W2 바로 아래, 구간마다 한 줄. 끝마다 이웃 높낮이로 정한다:
|
||||
// 높은 이웃 쪽 = 경계 너머 정착장 l_a만큼 직선 (그 구간 철근과 겹침), 낮은 이웃 · 끝면 쪽 = 경계 − c (끝면은 W11 안쪽)에서 연직 갈고리 l_a.
|
||||
// 양끝 갈고리 = W9 (ㄷ), 한쪽 갈고리 = W10 (ㄴ). 번호는 모양으로 정한다 (사용자 결정 2026-11-04).
|
||||
const la = s.la;
|
||||
segs.forEach((sg, k) => {
|
||||
const z = g.HF + sg.H - c - (d(SZ.W2) + d(SZ.W9)) / 2;
|
||||
const higher = (nb: (typeof segs)[number] | undefined) => nb !== undefined && nb.H >= sg.H;
|
||||
const lapA = higher(segs[k - 1]);
|
||||
const lapB = higher(segs[k + 1]);
|
||||
const a = lapA ? sg.from - la : k === 0 ? endIn(SZ.W9) : sg.from + c;
|
||||
const b = lapB ? sg.to + la : k === last ? g.L - endIn(SZ.W9) : sg.to - c;
|
||||
const hooks = (lapA ? 0 : 1) + (lapB ? 0 : 1);
|
||||
const mark: WallMark = hooks === 2 ? 'W9' : 'W10';
|
||||
for (const x of xW9) {
|
||||
const pts: [number, number, number][] = [[a, x, z], [b, x, z]];
|
||||
if (!lapA) pts.unshift([a, x, z - la]);
|
||||
if (!lapB) pts.push([b, x, z - la]);
|
||||
put(mark, pts, b - a + hooks * la, true);
|
||||
}
|
||||
});
|
||||
|
||||
// W5 · W6 (§5.3): 단 z_k = H_F + c + k·s_v, 들어가는 구간이 이어진 범위마다 한 줄. 계단 쪽 끝 = 경계 − c.
|
||||
const levelsOf = (H: number) => Math.floor((H - 2 * c) / sHoriz + 1e-9) + 1;
|
||||
const zMax = g.HF + Math.max(...segs.map((sg) => sg.H)) - c;
|
||||
for (let k = 0; g.HF + c + k * sHoriz <= zMax + 1e-9; k++) {
|
||||
const z = g.HF + c + k * sHoriz;
|
||||
const ok = segs.map((sg) => z <= g.HF + sg.H - c + 1e-9);
|
||||
for (let a = 0; a <= last; a++) {
|
||||
if (!ok[a] || (a > 0 && ok[a - 1])) continue;
|
||||
let b = a;
|
||||
while (b < last && ok[b + 1]) b++;
|
||||
for (const [mark, x] of [['W5', xW5], ['W6', xW6]] as const) {
|
||||
const ua = a === 0 ? endIn(SZ[mark]) : segs[a].from + c;
|
||||
const ub = b === last ? g.L - endIn(SZ[mark]) : segs[b].to - c;
|
||||
put(mark, [[ua, x, z], [ub, x, z]], ub - ua, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
const levels = [...segs]
|
||||
.sort((p, q) => p.no - q.no)
|
||||
.map((sg) => {
|
||||
const n = levelsOf(sg.H);
|
||||
const gap = sg.H - 2 * c - (n - 1) * sHoriz;
|
||||
return { no: sg.no, H: sg.H, count: n, text: `${n - 1}@${lenText(sHoriz)}=${lenText((n - 1) * sHoriz)} · ${lenText(c + gap)}` };
|
||||
});
|
||||
if (segs.length > 1 && levels.every((lv) => lv.count === levels[0].count)) add('RB-AB05', 'I', '계단 높이 차가 작아 수평철근 단 수가 구간마다 같습니다 (W5 · W6 끊김 없음).');
|
||||
|
||||
// W7 (§6): 교대축 @k_7L·s_L, 연직 @k_7v·s_v, W3 선 ~ W5 선 (skew, 1/sin θ, 사용자 결정 2026-11-04) + 양끝 90° 갈고리.
|
||||
const h13 = hook(SZ.W7);
|
||||
const g7 = gridLayout(g.L, c / sin, s.eMin, s.k7L * sVert);
|
||||
for (const u of g7.positions) {
|
||||
for (let z = g.HF + c; z <= zTop(u) - c + 1e-9; z += s.k7V * sHoriz) {
|
||||
put('W7', [[u, xW34, z - h13], [u, xW34, z], [u, xW5, z], [u, xW5, z - h13]], (xW5 - xW34) / sin + 2 * h13, inOne(u));
|
||||
}
|
||||
}
|
||||
|
||||
// W8 (§6): 양 끝면, W11 안쪽, ㄷ형 다리 l_d. 단 수는 그 끝 구간 높이.
|
||||
const in8 = (c + (d(SZ.W11) + d(SZ.W8)) / 2) / sin;
|
||||
for (const [u, dir, sg] of [[in8, 1, segs[0]], [g.L - in8, -1, segs[last]]] as const) {
|
||||
for (let z = g.HF + c; z <= g.HF + sg.H - c + 1e-9; z += sHoriz) {
|
||||
const leg = u + dir * ld.H13;
|
||||
put('W8', [[leg, g.xb + c, z], [u, g.xb + c, z], [u, g.xf - c, z], [leg, g.xf - c, z]], (g.xf - g.xb - 2 * c) / sin + 2 * ld.H13, true);
|
||||
}
|
||||
}
|
||||
|
||||
// W11 (§6.1): 끝면 최외측 연직. 교좌면 구간 x = 0 … x_f − c @s_W, 흉벽 · 브라켓 아래 x_b + c · 흉벽 배면 + c · −c.
|
||||
const xs11: number[] = [g.xb + c, g.parapetBack + c, -c];
|
||||
const seat11: number[] = [];
|
||||
for (let x = 0; x <= xW5 + 1e-9; x += s.sW) seat11.push(x);
|
||||
const zh16 = zHook.H16;
|
||||
for (const [u, dir, sg] of [[c / sin, 1, segs[0]], [g.L - c / sin, -1, segs[last]]] as const) {
|
||||
const top = g.HF + sg.H - c;
|
||||
// ㄷ형 (사용자 결정 2026-11-04): 아래 · 위 90° 갈고리 모두 교대축 안쪽으로 (끝면 밖에는 콘크리트가 없음).
|
||||
for (const x of [...xs11, ...seat11]) put('W11', [[u + dir * h16, x, zh16], [u, x, zh16], [u, x, top], [u + dir * h16, x, top]], top - zh16 + 2 * h16, sg === one);
|
||||
}
|
||||
|
||||
// 표 (시안 순서).
|
||||
const bars: AbutWallBar[] = [];
|
||||
const push = (b: Omit<AbutWallBar, 'length' | 'lengthMax' | 'count' | 'splice'>) => {
|
||||
const all = lengths.get(b.mark) ?? [];
|
||||
if (all.length === 0) return;
|
||||
const max = Math.max(...all);
|
||||
const splice = splicePlan(max, d(b.size), fy(b.size), material.fck) ?? undefined;
|
||||
const note = splice ? `${b.note} · ${splice.pieces}조각 이음 l_s ${splice.lap}` : b.note;
|
||||
if (splice) add('RB-AB01', splice.over ? 'W' : 'I', `${b.mark} ${Math.round(max)} > 정척 ${STANDARD_BAR}: ${splice.pieces}조각 이음 l_s ${splice.lap}.`);
|
||||
bars.push({ ...b, note, length: Math.round(repr.get(b.mark) ?? Math.min(...all)), lengthMax: Math.round(max), count: all.length, splice });
|
||||
};
|
||||
const vMod = s.sVert !== undefined;
|
||||
const hMod = s.sHoriz !== undefined;
|
||||
const tMod = s.sTop !== undefined;
|
||||
const vSp = lenText(2 * sVert);
|
||||
const vText = gridText(layout);
|
||||
push({ mark: 'W1', size: SZ.W1, role: '연직', spacing: vSp, actual: vSp, shape: `아래 90° 갈고리 ${h19} (앞굽 쪽)`, position: `전면 x ${lenText(xW1)} (W5 안쪽) · cycle-1만`, note: `격자 = 저판 − ${lenText(shift)} · 위 교좌면 − ${lenText(c)} · 길이는 구간 1 (구간마다 다름)`, basis: '규칙', modified: vMod });
|
||||
push({ mark: 'W3', size: SZ.W3, role: '연직', spacing: vSp, actual: lenText(sVert), shape: `아래 갈고리 (뒷굽 쪽) · 위 교좌면 + l_d ${ld.H19}`, position: `배면 x ${lenText(xW34)} · cycle-1`, note: `l_d ${ld.H19} (fck ${material.fck}) · 브라켓 상면 − ${lenText(c)} 이하`, basis: '가정', modified: vMod });
|
||||
push({ mark: 'W4', size: SZ.W4, role: '연직', spacing: vSp, actual: lenText(sVert), shape: '아래 갈고리 (뒷굽 쪽)', position: `배면 x ${lenText(xW34)} · cycle-2`, note: `위 교좌면 − ${lenText(c)} · 교대축 ${vText}`, basis: '규칙', modified: vMod });
|
||||
push({ mark: 'W2', size: SZ.W2, role: '수평', spacing: vSp, actual: vSp, shape: `⊓ 뒤 갈고리 ${h16} · 앞 다리 l_d ${ld.H16}`, position: `교좌면 − ${lenText(c)} · 교대축 직각 (skew) · x ${lenText(xW34)} ~ ${lenText(xW1)}`, note: `W1 위치 +${lenText(shiftW2)} 옆붙임`, basis: '가정', modified: vMod });
|
||||
const hSp = lenText(sHoriz);
|
||||
const lvText = levels.map((lv) => `구간 ${lv.no} ${lv.count}단`).join(' · ');
|
||||
push({ mark: 'W5', size: SZ.W5, role: '수평', spacing: hSp, actual: hSp, shape: '직선 · 계단에서 끊음', position: `전면 최외측 x ${lenText(xW5)} · 교대축`, note: `${lvText} · 끝은 W11 안쪽`, basis: '규칙', modified: hMod });
|
||||
push({ mark: 'W6', size: SZ.W6, role: '수평', spacing: hSp, actual: hSp, shape: '직선 · 계단에서 끊음', position: `배면 W3 · W4 안쪽 x ${lenText(xW6)} · 교대축`, note: 'W5와 같은 단', basis: '규칙', modified: hMod });
|
||||
push({ mark: 'W7', size: SZ.W7, role: '간격재', spacing: `${lenText(s.k7L * sVert)} × ${lenText(s.k7V * sHoriz)}`, actual: '—', shape: `양끝 90° 갈고리 ${h13}`, position: `내측 간격재 · W3 선 ~ W5 선`, note: `${s.k7L}·s × ${s.k7V}·s · 철근표 H13 · skew 방향 (1/sin θ)`, basis: '규칙', modified: s.k7L !== 8 || s.k7V !== 4 });
|
||||
push({ mark: 'W8', size: SZ.W8, role: '수평', spacing: hSp, actual: hSp, shape: `ㄷ형 · 다리 l_d ${ld.H13}`, position: `양 끝면 (skew) · W11 안쪽 ${lenText(in8 * sin)}`, note: `낮은 끝 · 높은 끝 구간 높이로 단 수`, basis: '규칙', modified: hMod });
|
||||
const tSp = lenText(sTop);
|
||||
push({ mark: 'W9', size: SZ.W9, role: '계단 상면', spacing: tSp, actual: tSp, shape: `ㄷ · 양끝 연직 갈고리 ${lenText(la)} (경계 − ${lenText(c)})`, position: `양쪽 이웃이 낮거나 끝면인 계단 상면 · 교대축 · 폭 ${xW9.length}개`, note: `W2 바로 아래 · 첫 줄 ${lenText(xW9[0] ?? 0)} (W1 선 피함) · 정착장 ${lenText(la)}`, basis: '규칙', modified: tMod || la !== 330 });
|
||||
push({ mark: 'W10', size: SZ.W10, role: '계단 상면', spacing: tSp, actual: tSp, shape: `ㄴ · 한쪽 연직 갈고리 ${lenText(la)} · 반대쪽 높은 구간 철근과 직선 겹침 ${lenText(la)}`, position: `높은 이웃이 있는 계단 상면 · 교대축 · 폭 ${xW9.length}개`, note: '번호는 모양으로: 양끝 갈고리 W9 (ㄷ) / 한쪽 갈고리 W10 (ㄴ)', basis: '규칙', modified: tMod || la !== 330 });
|
||||
push({ mark: 'W11', size: SZ.W11, role: '연직', spacing: `${lenText(s.sW)} / 300`, actual: '—', shape: `ㄷ형 · 아래 · 위 90° 갈고리 ${h16} (안쪽)`, position: `끝면 최외측 연직 · x 0 … ${lenText(xW5)} @${lenText(s.sW)} + ${xs11.map(lenText).join(' · ')}`, note: '규격 H16 제안 (삽도 표기 없음) · 흉벽 P11로 이어짐', basis: '가정', modified: false });
|
||||
|
||||
return {
|
||||
settings: s,
|
||||
x: { W1: xW1, W34: xW34, W5: xW5, W6: xW6, W9: xW9 },
|
||||
vertical: { positions: us, layout, text: vText, shift },
|
||||
zHook,
|
||||
levels,
|
||||
ld,
|
||||
paths,
|
||||
bars,
|
||||
issues,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,512 @@
|
||||
/**
|
||||
* 교대 날개벽 배근 · AD7 (2026-11-04, 교대날개벽철근.md, 결정 2026-11-02 · 2026-11-04). 순수 함수, 길이는 mm.
|
||||
*
|
||||
* - 연직: WR1 · WR2 배면 (@s_h), WR11 · WR12 전면 (@2·s_h). 기준선 = 면 위 뒷굽 끝 − c. 등단면(WR2 · WR12)은 교대 쪽으로
|
||||
* ceil((L_heel − δ)/s) + 1개, 저판 하면망 위 갈고리. 변화단면(WR1 · WR11)은 끝 쪽으로 floor((끝 − c − 기준선 − e_min)/s)개, 아래 끝 경사 하단 + c.
|
||||
* - 수평: WR3–5 배면 (@s_v), WR13–15 전면 (@2·s_v), 띠 = 등단면 / 변화단면1 / 변화단면2. 맨 위 2단은 상단 경사와 나란히
|
||||
* (사용자 결정 2026-11-04, Q-WR1), 일반 단은 그 아래 s 이상. 교대 쪽 끝은 교대 단면을 지나 반대쪽 면 − c, 90° 갈고리.
|
||||
* - WR6 경사 하단 2개, WR7-1 · 7-2 · 7-3 헌치 꺾임 (전면 수평과 같은 단), WR9 저판 상면 헌치 보강 꺾임 (@2·s_h 자동 개수, 사용자 도면),
|
||||
* WR10 모서리 ㄱ자 (@s_v, 교대 끝면 쪽), WR20 간격재. 교대 철근과의 간섭은 보지 않는다 (Q-WR2 · Q-WR4 결정).
|
||||
* - 단축형 (QA-14 · §9): WR1 · WR11 · WR6 없음, 연직은 모두 등단면.
|
||||
* - 좌표: 교대 (s, t, z) — s 교대축 (IP1에서), t 교대축 직각 (전면 +), z 저판 하면에서. 날개벽 거리 d는 교대축 선에서 날개벽 방향.
|
||||
*/
|
||||
import { DEFAULT_MATERIAL, developmentLength, fyOf, hookLength, NOMINAL_DIAMETER, STANDARD_BAR, type BarSize, type Material } from '../../rebar/common';
|
||||
import type { Issue } from '../../types';
|
||||
import { lenText } from './skew-layout';
|
||||
import { splicePlan, type SplicePlan } from './splice';
|
||||
|
||||
export interface AbutWingRebarSettings {
|
||||
c: number;
|
||||
/** 날개벽 기본간격: 수평 (연직철근이 늘어서는 간격) / 연직 (수평 단 간격). 배면 s, 전면 · 헌치 2s. */
|
||||
sH: number;
|
||||
sV: number;
|
||||
/** 등단면 개수 올림 허용차. */
|
||||
delta: number;
|
||||
eMin: number;
|
||||
/** WR20 배수 (수평 · 연직). */
|
||||
k20: number;
|
||||
/** 묶음별 간격 수정 (배면 값, 전면은 2배): 연직 WR1 · 2 · 11 · 12, 수평 WR3–5 · 13–15. */
|
||||
sVert?: number;
|
||||
sHoriz?: number;
|
||||
}
|
||||
|
||||
export const ABUT_WING_REBAR_DEFAULT: AbutWingRebarSettings = { c: 100, sH: 125, sV: 125, delta: 25, eMin: 150, k20: 8 };
|
||||
|
||||
/** 규격 (기획서 §1.2). WR8 · WR16–19는 결번. */
|
||||
export const ABUT_WING_SIZES = {
|
||||
WR1: 'H16', WR2: 'H16', WR11: 'H16', WR12: 'H16', WR6: 'H16', WR9: 'H16',
|
||||
WR3: 'H25', WR4: 'H25', WR5: 'H25', WR13: 'H25', WR14: 'H25', WR15: 'H25',
|
||||
'WR7-1': 'H25', 'WR7-2': 'H25', 'WR7-3': 'H25', WR10: 'H25',
|
||||
WR20: 'H13',
|
||||
} as const satisfies Record<string, BarSize>;
|
||||
export type WingMark = keyof typeof ABUT_WING_SIZES;
|
||||
|
||||
/** WR10 ㄱ자 (사용자 도면 2026-11-05): 날개벽 바깥면 피복, 교대 쪽 다리, 날개벽 쪽 다리 (mm). */
|
||||
export const WR10 = { cover: 123, abutLeg: 3000, wingLeg: 1000 } as const;
|
||||
|
||||
type V2 = [number, number];
|
||||
|
||||
/** 한 측 날개벽 형상 (mm, 교대 s · t, z는 저판 하면에서). */
|
||||
export interface AbutWingGeom {
|
||||
side: 'left' | 'right';
|
||||
form: 'general' | 'short';
|
||||
/** 전면(바깥)면이 교대축 선과 만나는 점 (s, t). */
|
||||
p0: V2;
|
||||
/** 날개벽 방향 단위 벡터 (s, t), 교대에서 멀어지는 쪽. */
|
||||
w: V2;
|
||||
/** 전면 → 배면(흙 쪽) 면 이동 (s, t): 교대축 방향으로 두께만큼. */
|
||||
inward: V2;
|
||||
/** 날개벽 길이 (교대축 선에서), 끝 높이, 헌치 다리. */
|
||||
L: number;
|
||||
endH: number;
|
||||
haunch: number;
|
||||
/** 교대축 선에서 날개벽 방향으로 잰 뒷굽 끝 · 벽체 배면 · 흉벽 배면 거리. */
|
||||
heelD: number;
|
||||
wallBackD: number;
|
||||
parapetBackD: number;
|
||||
/** 교대 단면 t: 흉벽 배면 · 벽체 배면 · 벽체 전면. */
|
||||
parapetBackT: number;
|
||||
wallBackT: number;
|
||||
wallFrontT: number;
|
||||
/** z (저판 하면에서): 저판 상면, 날개벽 상단 시작 (교대축 선) · 경사, 단부 하단, 교좌면 (가장 가까운 받침), 브라켓 상면. */
|
||||
footTop: number;
|
||||
topStart: number;
|
||||
slope: number;
|
||||
bottomEnd: number;
|
||||
seat: number;
|
||||
bracketTop: number;
|
||||
/** 저판 하면망 B6 · B8 높이 (z). */
|
||||
zB68: number;
|
||||
/** 교좌면 계단 (u = s − k·t 범위 mm, 교좌면 z): WR7-3이 긴 대각으로 이웃 계단까지 갈 때 그 교좌면 아래인지 본다. 없으면 seat 하나. */
|
||||
seats?: { from: number; to: number; z: number }[];
|
||||
skewK?: number;
|
||||
}
|
||||
|
||||
export type WingRole = '연직' | '수평' | '헌치' | '모서리' | '간격재' | '경사 하단';
|
||||
|
||||
export interface AbutWingBar {
|
||||
mark: WingMark;
|
||||
size: BarSize;
|
||||
role: WingRole;
|
||||
spacing: string;
|
||||
actual: string;
|
||||
shape: string;
|
||||
length: number;
|
||||
lengthMin: number;
|
||||
lengthMax: number;
|
||||
count: number;
|
||||
position: string;
|
||||
note: string;
|
||||
basis: '규칙' | '가정';
|
||||
modified: boolean;
|
||||
splice?: SplicePlan;
|
||||
}
|
||||
|
||||
export interface WingBarPath {
|
||||
mark: WingMark;
|
||||
/** (s, t, z) mm. */
|
||||
pts: [number, number, number][];
|
||||
}
|
||||
|
||||
export interface AbutWingRebar {
|
||||
side: 'left' | 'right';
|
||||
settings: AbutWingRebarSettings;
|
||||
/** 면별 연직 배치 (면 위 거리, 삽도 치수 기준): 기준선, 등단면 칸 수, 변화단면 칸 수, 끝 여유, 마지막 등단면 위치, 치수 문자열. */
|
||||
vertical: Record<'front' | 'back', { base: number; nE: number; nV: number; endGap: number; lastE: number; text: string }>;
|
||||
/** 수평 단 수: 교대 쪽 (상단 높이 H_s) · 끝 (H_e), 면별. */
|
||||
levels: Record<'front' | 'back', { start: number; end: number }>;
|
||||
haunchLevels: { 'WR7-1': number; 'WR7-2': number; 'WR7-3': number };
|
||||
ld: Record<'H13' | 'H16' | 'H25', number>;
|
||||
paths: WingBarPath[];
|
||||
bars: AbutWingBar[];
|
||||
issues: Issue[];
|
||||
}
|
||||
|
||||
const d = (size: BarSize) => NOMINAL_DIAMETER[size];
|
||||
|
||||
export function resolveWingRebar(changes?: Partial<AbutWingRebarSettings>): AbutWingRebarSettings {
|
||||
return { ...ABUT_WING_REBAR_DEFAULT, ...(changes ?? {}) };
|
||||
}
|
||||
|
||||
export function wingRebarIssues(s: AbutWingRebarSettings): string[] {
|
||||
const out: string[] = [];
|
||||
for (const key of ['c', 'sH', 'sV', 'eMin'] as const) if (!(s[key] > 0)) out.push(`${key}은(는) 0보다 커야 합니다.`);
|
||||
if (!(s.delta >= 0)) out.push('등단면 올림 허용차는 0 이상이어야 합니다.');
|
||||
for (const key of ['sVert', 'sHoriz'] as const) if (s[key] !== undefined && !((s[key] as number) > 0)) out.push('철근 간격은 0보다 커야 합니다.');
|
||||
if (!(Number.isInteger(s.k20) && s.k20 >= 1)) out.push('WR20 배수는 1 이상의 정수여야 합니다.');
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 일반 단 + 맨 위 경사 2단의 단 수 at 저판 상면 위 높이 H (면 간격 s): floor((H − c − 2s − c)/s) + 1 + 2. */
|
||||
export function wingLevelCount(H: number, c: number, s: number): number {
|
||||
return Math.max(0, Math.floor((H - 2 * c - 2 * s) / s + 1e-9) + 1) + 2;
|
||||
}
|
||||
|
||||
/** 한 측 날개벽 형상 from the ② wing solid (m, plan E · N) and the abutment (mm, z from the 기초 하단). */
|
||||
export function abutWingGeom(
|
||||
model: { ip1: { e: number; n: number }; u: { e: number; n: number }; nLeft: { e: number; n: number }; frontSide: number },
|
||||
members: { shape: { parapetBackM: number; wallBackM: number; wallFrontM: number; bracketHeightM: number }; footingTopEl: number; footingBottomEl: number | undefined },
|
||||
wing: {
|
||||
side: 'left' | 'right';
|
||||
form: 'general' | 'short';
|
||||
corners: { e: number; n: number }[];
|
||||
lengthM: number;
|
||||
endHeightM: number;
|
||||
haunchM: number;
|
||||
heelDistanceM: number;
|
||||
topStartEl: number;
|
||||
topEndEl: number;
|
||||
bottomEndEl: number;
|
||||
bands: { name: string; zBottomEl: number }[];
|
||||
},
|
||||
zB68: number,
|
||||
seats?: { from: number; to: number; z: number }[],
|
||||
skewK?: number,
|
||||
): AbutWingGeom {
|
||||
const front = { e: model.nLeft.e * model.frontSide, n: model.nLeft.n * model.frontSide };
|
||||
const st = (p: { e: number; n: number }): V2 => {
|
||||
const d0 = { e: p.e - model.ip1.e, n: p.n - model.ip1.n };
|
||||
return [(d0.e * model.u.e + d0.n * model.u.n) * 1000, (d0.e * front.e + d0.n * front.n) * 1000];
|
||||
};
|
||||
const [c0, c1, , c3] = wing.corners;
|
||||
const p0 = st(c0);
|
||||
const p1 = st(c1);
|
||||
const len = Math.hypot(p1[0] - p0[0], p1[1] - p0[1]);
|
||||
const w: V2 = [(p1[0] - p0[0]) / len, (p1[1] - p0[1]) / len];
|
||||
const q3 = st(c3);
|
||||
const k = Math.abs(w[1]);
|
||||
const bottom = members.footingBottomEl ?? 0;
|
||||
const z = (el: number) => (el - bottom) * 1000;
|
||||
const L = wing.lengthM * 1000;
|
||||
const seatBand = wing.bands.find((b) => b.name === '변화단면2');
|
||||
return {
|
||||
side: wing.side,
|
||||
form: wing.form,
|
||||
p0,
|
||||
w,
|
||||
inward: [q3[0] - p0[0], q3[1] - p0[1]],
|
||||
L,
|
||||
endH: wing.endHeightM * 1000,
|
||||
haunch: wing.haunchM * 1000,
|
||||
heelD: wing.heelDistanceM * 1000,
|
||||
wallBackD: (Math.abs(members.shape.wallBackM) * 1000) / k,
|
||||
parapetBackD: (Math.abs(members.shape.parapetBackM) * 1000) / k,
|
||||
parapetBackT: members.shape.parapetBackM * 1000,
|
||||
wallBackT: members.shape.wallBackM * 1000,
|
||||
wallFrontT: members.shape.wallFrontM * 1000,
|
||||
footTop: z(members.footingTopEl),
|
||||
topStart: z(wing.topStartEl),
|
||||
slope: (wing.topEndEl - wing.topStartEl) / wing.lengthM,
|
||||
bottomEnd: z(wing.bottomEndEl),
|
||||
seat: seatBand ? z(wing.bands.find((b) => b.name === '변화단면1')!.zBottomEl) : z(members.footingTopEl),
|
||||
bracketTop: z(wing.topStartEl - members.shape.bracketHeightM),
|
||||
zB68,
|
||||
seats,
|
||||
skewK,
|
||||
};
|
||||
}
|
||||
|
||||
/** @param la9 WR9 연직 다리 = 벽체 W9 · W10 정착장 (벽체 카드 입력, 기본 330). */
|
||||
export function abutWingRebar(g: AbutWingGeom, changes?: Partial<AbutWingRebarSettings>, material: Material = DEFAULT_MATERIAL, who = '', la9 = 330): AbutWingRebar {
|
||||
const s = resolveWingRebar(changes);
|
||||
const issues: Issue[] = [];
|
||||
const side = g.side === 'left' ? '좌' : '우';
|
||||
const add = (code: string, level: Issue['level'], message: string) => issues.push({ code, level, pier: who || undefined, message: `${side} 날개벽 · ${message}` });
|
||||
const SZ = ABUT_WING_SIZES;
|
||||
const c = s.c;
|
||||
const sBack = s.sVert ?? s.sH;
|
||||
const sBackV = s.sHoriz ?? s.sV;
|
||||
if (s.sVert !== undefined || s.sHoriz !== undefined || s.k20 !== 8 || s.delta !== 25) add('RB-AW03', 'I', '간격을 기본간격과 따로 고쳤거나 δ · WR20 배수가 기본값이 아닙니다.');
|
||||
const fy = (size: BarSize) => fyOf(material, size);
|
||||
const ldOf = (size: BarSize) => Math.ceil(developmentLength(d(size), fy(size), material.fck) - 1e-9);
|
||||
const ld = { H13: ldOf('H13'), H16: ldOf('H16'), H25: ldOf('H25') };
|
||||
const hook = (size: BarSize) => Math.round(hookLength('main90', d(size)));
|
||||
|
||||
// 평면 틀: 전면 위 점 = p0 + d·w, 배면 위 점 = p0 + inward + d·w. n = 전면 → 배면 단위 (w에 수직), T = 수직 두께.
|
||||
const [ws, wt] = g.w;
|
||||
const iw = g.inward[0] * ws + g.inward[1] * wt;
|
||||
const nv: V2 = [g.inward[0] - iw * ws, g.inward[1] - iw * wt];
|
||||
const T = Math.hypot(nv[0], nv[1]);
|
||||
const n: V2 = [nv[0] / T, nv[1] / T];
|
||||
/** Point at axis-based distance d, `depth` from the front face toward the back. */
|
||||
const at = (dd: number, depth: number): V2 => [g.p0[0] + dd * ws + depth * n[0], g.p0[1] + dd * wt + depth * n[1]];
|
||||
// 배면은 면 위 거리를 수선의 발에서 잰다 (기획서 §4.1: 면 길이 차 = 두께 · cot φ). o = 배면 거리 − 축선 기준 거리.
|
||||
const o = iw;
|
||||
const top = (dd: number) => g.topStart + g.slope * dd;
|
||||
const general = g.form === 'general' && g.heelD < g.L;
|
||||
const bottom = (dd: number) => (!general || dd <= g.heelD ? g.footTop : g.footTop + ((g.bottomEnd - g.footTop) * (dd - g.heelD)) / (g.L - g.heelD));
|
||||
|
||||
const paths: WingBarPath[] = [];
|
||||
const lengths = new Map<WingMark, number[]>();
|
||||
const put = (mark: WingMark, pts: [number, number, number][], length: number) => {
|
||||
paths.push({ mark, pts });
|
||||
lengths.set(mark, [...(lengths.get(mark) ?? []), length]);
|
||||
};
|
||||
const P3 = (p: V2, z: number): [number, number, number] => [p[0], p[1], z];
|
||||
|
||||
// 연직 (§4.1 · §4.2). 면 위 거리 df: 전면은 축선 기준, 배면은 수선의 발 기준 (df = d + o).
|
||||
const zHook16 = g.zB68 + (d('H16') + d(SZ.WR2)) / 2;
|
||||
const h16 = hook(SZ.WR2);
|
||||
const vertical = {} as AbutWingRebar['vertical'];
|
||||
for (const face of ['front', 'back'] as const) {
|
||||
const sp = face === 'front' ? 2 * sBack : sBack;
|
||||
const off = face === 'front' ? 0 : o;
|
||||
const depth = face === 'front' ? c : T - c;
|
||||
// 갈고리 방향: 전면은 저판 끝면과 같은 면이라 바깥으로 꺾으면 콘크리트 밖 → 두 면 모두 +n (전면은 날개벽 안쪽, 배면은 흙 쪽 저판 안).
|
||||
const out = 1;
|
||||
const heelF = Math.min(g.heelD, g.L) + off;
|
||||
const endF = g.L + off;
|
||||
const base = heelF - c;
|
||||
const nE = Math.max(0, Math.ceil((heelF - g.wallBackD - s.delta) / sp - 1e-9));
|
||||
const nV = general ? Math.max(0, Math.floor((endF - c - base - s.eMin) / sp + 1e-9)) : 0;
|
||||
const endGap = endF - c - base - nV * sp;
|
||||
const markE: WingMark = face === 'front' ? 'WR12' : 'WR2';
|
||||
const markV: WingMark = face === 'front' ? 'WR11' : 'WR1';
|
||||
for (let i = 0; i <= nE; i++) {
|
||||
const dd = base - i * sp - off;
|
||||
const p = at(dd, depth);
|
||||
const zt = top(dd) - c;
|
||||
const ph: V2 = [p[0] + out * n[0] * h16, p[1] + out * n[1] * h16];
|
||||
put(markE, [P3(ph, zHook16), P3(p, zHook16), P3(p, zt)], zt - zHook16 + h16);
|
||||
}
|
||||
for (let j = 1; j <= nV; j++) {
|
||||
const dd = base + j * sp - off;
|
||||
const p = at(dd, depth);
|
||||
const zb = bottom(dd) + c;
|
||||
const zt = top(dd) - c;
|
||||
if (zt - zb < 2 * c) continue;
|
||||
put(markV, [P3(p, zb), P3(p, zt)], zt - zb);
|
||||
}
|
||||
const lastE = base - nE * sp;
|
||||
vertical[face] = {
|
||||
base,
|
||||
nE,
|
||||
nV,
|
||||
endGap,
|
||||
lastE,
|
||||
text: `${lenText(c)} · ${lenText(endGap)} · ${nV}@${lenText(sp)}=${lenText(nV * sp)} · ${nE}@${lenText(sp)}=${lenText(nE * sp)} · ${lenText(lastE)}`,
|
||||
};
|
||||
}
|
||||
if (g.endH < 2 * c + sBack) add('RB-AW04', 'W', `끝 높이 ${lenText(g.endH)}가 2c + s보다 작아 연직철근 놓을 자리가 없습니다.`);
|
||||
|
||||
// 수평 (§5): 일반 단 h_k = c + k·s (저판 상면 위), 맨 위 2단은 상단 경사와 나란히. 띠는 높이로.
|
||||
const bandOf = (z: number) => (z >= g.bottomEnd - 1e-6 || !general ? 0 : z > g.seat + 1e-6 ? 1 : 2);
|
||||
const h25 = hook('H25');
|
||||
// 교대 쪽 끝: 흉벽(+브라켓) 전면 − c (t = −c) 또는 벽체 전면 − c, d로.
|
||||
const dAt = (t: number) => -t / -wt; // t = d·wt along the front face (p0 on the axis, t0 = 0)
|
||||
const dInner = [dAt(-c), dAt(-c), dAt(g.wallFrontT - c)];
|
||||
const abutEdge = (z: number) => (z > g.bracketTop + 1e-6 ? g.parapetBackD : g.wallBackD);
|
||||
let shortAnchor = false;
|
||||
const levels = { front: { start: 0, end: 0 }, back: { start: 0, end: 0 } };
|
||||
for (const face of ['front', 'back'] as const) {
|
||||
const sp = face === 'front' ? 2 * sBackV : sBackV;
|
||||
const depth = face === 'front' ? c + (d('H16') + d('H25')) / 2 : T - c - (d('H16') + d('H25')) / 2;
|
||||
const marks: WingMark[] = face === 'front' ? ['WR13', 'WR14', 'WR15'] : ['WR3', 'WR4', 'WR5'];
|
||||
const dEnd = g.L - c;
|
||||
// 맨 위 2단: 경사와 나란히, 교대 단면을 지나 흉벽 전면 − c.
|
||||
for (let m = 0; m < 2; m++) {
|
||||
const za = top(dInner[0]) - c - m * sp;
|
||||
const zb = top(dEnd) - c - m * sp;
|
||||
const a = at(dInner[0], depth);
|
||||
const b = at(dEnd, depth);
|
||||
put(marks[0], [P3(a, za - h25), P3(a, za), P3(b, zb)], Math.hypot(dEnd - dInner[0], zb - za) + h25);
|
||||
}
|
||||
// 일반 단.
|
||||
const cutTop = (dd: number) => top(dd) - c - 2 * sp; // 일반 단은 이 높이 이하 (맨 위 2단 아래 s 이상)
|
||||
for (let k = 0; ; k++) {
|
||||
const z = g.footTop + c + k * sp;
|
||||
if (z > Math.max(cutTop(0), cutTop(g.L)) + 1e-6) break;
|
||||
// d 범위: 상단 끊김 (경사로 교대 쪽이 낮음) · 경사 하단 (끝 쪽에서 올라옴).
|
||||
let dA = 0;
|
||||
if (z > cutTop(0) + 1e-6) dA = (z - (g.topStart - c - 2 * sp)) / g.slope;
|
||||
let dB = dEnd;
|
||||
if (general && z < bottom(dEnd) + c - 1e-6) dB = g.heelD + ((z - c - g.footTop) / (g.bottomEnd - g.footTop)) * (g.L - g.heelD);
|
||||
else if (general && z > bottom(dEnd) + c - 1e-6) dB = dEnd;
|
||||
if (dB - dA < c) continue;
|
||||
const band = bandOf(z);
|
||||
const mark = marks[band];
|
||||
const startsAtAbut = dA <= abutEdge(z) + 1e-6;
|
||||
const b = at(dB, depth);
|
||||
if (startsAtAbut) {
|
||||
const di = dInner[band];
|
||||
const a = at(di, depth);
|
||||
if (band === 0 && abutEdge(z) - di + h25 < ld.H25) shortAnchor = true;
|
||||
put(mark, [P3(a, z - h25), P3(a, z), P3(b, z)], dB - di + h25);
|
||||
} else {
|
||||
const a = at(dA, depth);
|
||||
put(mark, [P3(a, z), P3(b, z)], dB - dA);
|
||||
}
|
||||
}
|
||||
levels[face] = { start: wingLevelCount(g.topStart - g.footTop, c, sp), end: wingLevelCount(top(g.L) - g.footTop, c, sp) };
|
||||
}
|
||||
if (shortAnchor) add('RB-AW02', 'W', `등단면 수평철근(WR3 · WR13)의 교대 쪽 정착이 l_d ${ld.H25}보다 짧습니다 (흉벽 두께).`);
|
||||
|
||||
// WR6 (§6): 경사 하단과 나란히 c 띄워 전면 · 배면 각 1, 끝면 − c → 뒷굽 끝 → 저판 안 l_d.
|
||||
if (general) {
|
||||
const slopeLen = Math.hypot(g.L - g.heelD, g.bottomEnd - g.footTop);
|
||||
const ux = (g.L - g.heelD) / slopeLen;
|
||||
const uz = (g.bottomEnd - g.footTop) / slopeLen;
|
||||
for (const depth of [c, T - c]) {
|
||||
const dTop = g.L - c;
|
||||
const zTop = bottom(dTop) + c;
|
||||
const zH = g.footTop + c;
|
||||
const dH = g.heelD; // 뒷굽 끝 위 c
|
||||
const dIn = dH - ld.H16 * ux;
|
||||
const zIn = zH - ld.H16 * uz;
|
||||
put('WR6', [P3(at(dTop, depth), zTop), P3(at(dH, depth), zH), P3(at(dIn, depth), zIn)], Math.hypot(dTop - dH, zTop - zH) + ld.H16);
|
||||
}
|
||||
}
|
||||
|
||||
// 헌치 · 모서리 (§6 · §7): 평면에서 배면(흙 쪽) 면과 교대 배면이 만나는 모서리.
|
||||
const eIn: V2 = [-n[0], -n[1]]; // 배면에서 날개벽 안쪽 (전면 쪽)
|
||||
const tUnit: V2 = [0, 1]; // 교대 안쪽 (전면 쪽)
|
||||
const uOut: V2 = [Math.sign(g.inward[0]), 0]; // 날개벽 배면에서 교대 배면을 따라 반대쪽 날개벽 쪽
|
||||
const lineX = (p: V2, u: V2, q: V2, v: V2): V2 => {
|
||||
const den = u[0] * v[1] - u[1] * v[0];
|
||||
const f = ((q[0] - p[0]) * v[1] - (q[1] - p[1]) * v[0]) / den;
|
||||
return [p[0] + u[0] * f, p[1] + u[1] * f];
|
||||
};
|
||||
const add2 = (p: V2, u: V2, f: number): V2 => [p[0] + u[0] * f, p[1] + u[1] * f];
|
||||
const haunchLevels = { 'WR7-1': 0, 'WR7-2': 0, 'WR7-3': 0 };
|
||||
const wDir: V2 = [ws, wt];
|
||||
// 날개벽 안쪽 다리 (WR7 · WR10): 경사 하단이 올라오는 곳에서 c 안쪽까지만, 최대 l_d.
|
||||
const legEnd = (A: V2, z: number) => {
|
||||
const dA = (A[0] - g.p0[0]) * ws + (A[1] - g.p0[1]) * wt;
|
||||
let lim = g.L - c;
|
||||
if (general && z - c < g.bottomEnd) lim = Math.min(lim, g.heelD + ((z - c - g.footTop) / (g.bottomEnd - g.footTop)) * (g.L - g.heelD));
|
||||
return Math.max(0, Math.min(ld.H25, lim - dA));
|
||||
};
|
||||
if (g.haunch > 0) {
|
||||
const sp = 2 * sBackV;
|
||||
for (let k = 0; ; k++) {
|
||||
const z = g.footTop + c + k * sp;
|
||||
if (z > g.topStart - c + 1e-6) break;
|
||||
let mark: WingMark = z > g.bracketTop + 1e-6 ? 'WR7-1' : z > g.seat + 1e-6 ? 'WR7-2' : 'WR7-3';
|
||||
// 평면 (사용자 도면 2026-11-05): 헌치 빗변과 나란한 긴 대각이 안쪽 모서리(날개벽 배면 × 교대 배면)를 콘크리트 쪽으로 c 떨어져
|
||||
// 지나, 교대 쪽은 전면 − c 선(WR7-1 · 7-2 흉벽 전면, WR7-3 벽체 전면), 날개벽 쪽은 날개벽 바깥면 − c 선에서 끝난다.
|
||||
// 양끝은 평면 안 90° 갈고리 394: 교대 쪽은 전면 선을 따라 교대 안쪽으로, 날개벽 쪽은 바깥면 선을 따라 날개벽 끝 쪽으로.
|
||||
const plan = (m: WingMark) => {
|
||||
const backT = m === 'WR7-1' ? g.parapetBackT : g.wallBackT;
|
||||
const tEnd = m === 'WR7-3' ? g.wallFrontT - c : -c;
|
||||
const corner = lineX(at(0, T), wDir, [0, backT], [1, 0]);
|
||||
const hw = add2(corner, wDir, g.haunch);
|
||||
const ha = add2(corner, uOut, g.haunch);
|
||||
const dl = Math.hypot(ha[0] - hw[0], ha[1] - hw[1]);
|
||||
const du: V2 = [(ha[0] - hw[0]) / dl, (ha[1] - hw[1]) / dl];
|
||||
const away: V2 = [corner[0] - (hw[0] + ha[0]) / 2, corner[1] - (hw[1] + ha[1]) / 2];
|
||||
const al = Math.hypot(away[0], away[1]);
|
||||
const L0 = add2(corner, [away[0] / al, away[1] / al], c);
|
||||
const F = lineX(L0, du, [0, tEnd], [1, 0]);
|
||||
const G = lineX(L0, du, at(0, c), wDir);
|
||||
const legG = Math.min(h25, legEnd(G, z));
|
||||
const len = h25 + Math.hypot(G[0] - F[0], G[1] - F[1]) + legG;
|
||||
return { pts: [add2(F, uOut, h25), F, G, add2(G, wDir, legG)] as V2[], len };
|
||||
};
|
||||
let bar = plan(mark);
|
||||
// WR7-3의 긴 대각이 이웃 계단(교좌면이 더 낮음)까지 가면 그 교좌면 위로 나온다 → 그 단은 WR7-2 (흉벽 전면 − c, 브라켓 안).
|
||||
if (mark === 'WR7-3' && g.seats) {
|
||||
const seatAt = (q: V2) => {
|
||||
const u = q[0] - (g.skewK ?? 0) * q[1];
|
||||
return (g.seats!.find((x) => u < x.to) ?? g.seats![g.seats!.length - 1]).z;
|
||||
};
|
||||
if (bar.pts.some((q) => z > seatAt(q) + 1e-6)) {
|
||||
mark = 'WR7-2';
|
||||
bar = plan(mark);
|
||||
}
|
||||
}
|
||||
haunchLevels[mark]++;
|
||||
put(mark, bar.pts.map((q) => P3(q, z)), bar.len);
|
||||
}
|
||||
}
|
||||
// WR9 (사용자 도면 2026-11-04, 연직헌치 보강철근): 저판 상면 헌치(날개벽 배면 ~ 저판 상면, 다리 g.haunch) 보강.
|
||||
// 날개벽 방향으로 벽체 배면 ~ 뒷굽 끝 @2·s_h (가운데 맞춤). 날개벽에 수직인 단면에서: 배면 − c 연직 다리 l_a (벽체 W9 · W10 정착장)
|
||||
// → 헌치 빗변과 나란히 c 안쪽 대각 → 저판 하면망 높이까지 → 날개벽 반대쪽으로 90° 갈고리.
|
||||
if (g.haunch > 0) {
|
||||
const sp9 = 2 * sBack;
|
||||
const k9 = g.haunch - c * Math.SQRT2; // (깊이 − T) + (z − 저판 상면) = k9 on the bar line
|
||||
const zTopLeg = g.footTop + k9 + c; // at depth T − c
|
||||
const zLow = zHook16;
|
||||
const depthLow = T + k9 - (zLow - g.footTop);
|
||||
// 대각 · 갈고리가 날개벽 옆으로 ~1.4 m 가며 skew로 뒤쪽(t −)으로도 밀리므로, 갈고리 끝이 저판 배면 + c 안에 드는 범위까지만.
|
||||
const footBackT = -g.heelD * Math.abs(wt);
|
||||
const hookT0 = at(0, depthLow + h16)[1];
|
||||
const dKeep = wt < 0 ? (footBackT + c - hookT0) / wt : Infinity;
|
||||
const from = g.wallBackD + c;
|
||||
const to = Math.min(Math.min(g.heelD, g.L) - c, dKeep);
|
||||
const n9 = to > from ? Math.floor((to - from) / sp9 + 1e-9) + 1 : 0;
|
||||
const off = (to - from - (n9 - 1) * sp9) / 2;
|
||||
for (let i = 0; i < n9; i++) {
|
||||
const dd = from + off + i * sp9;
|
||||
const pts: [number, number, number][] = [
|
||||
P3(at(dd, T - c), zTopLeg + la9),
|
||||
P3(at(dd, T - c), zTopLeg),
|
||||
P3(at(dd, depthLow), zLow),
|
||||
P3(at(dd, depthLow + h16), zLow),
|
||||
];
|
||||
put('WR9', pts, la9 + Math.hypot(depthLow - (T - c), zTopLeg - zLow) + h16);
|
||||
}
|
||||
}
|
||||
// WR10 (§7, 사용자 도면 2026-11-05): ㄱ자 @s_v, 저판 상면 + c ~ 흉벽 상단 − c. 꺾이는 점 = 날개벽 바깥면(교대 끝면)에서 피복 123 안쪽 선과
|
||||
// 흉벽 배면 + 123 선(흉벽 · 벽체 구간 모두 콘크리트 안)이 만나는 곳. 교대 쪽 다리 3,000 (s 방향, 교대 안쪽), 날개벽 쪽 다리 1,000 (바깥면 − 123).
|
||||
{
|
||||
const B = lineX(at(0, WR10.cover), wDir, [0, g.parapetBackT + WR10.cover], [1, 0]);
|
||||
for (let z = g.footTop + c; z <= g.topStart - c + 1e-6; z += sBackV) {
|
||||
const leg = Math.min(WR10.wingLeg, legEnd(B, z));
|
||||
put('WR10', [P3(add2(B, uOut, WR10.abutLeg), z), P3(B, z), P3(add2(B, wDir, leg), z)], WR10.abutLeg + leg);
|
||||
}
|
||||
}
|
||||
// WR20 (§8): 간격재, 수평 @k·s_h × 연직 @k·s_v, 단마다 반 칸 엇갈림, 전면 · 배면 수평철근 사이 + 양끝 갈고리.
|
||||
{
|
||||
const h13 = hook(SZ.WR20);
|
||||
const dx = s.k20 * sBack;
|
||||
const dz = s.k20 * sBackV;
|
||||
const dF = c + (d('H16') + d('H25')) / 2;
|
||||
const dB = T - dF;
|
||||
let j = 0;
|
||||
for (let z = g.footTop + c; z <= g.topStart + g.slope * g.L - c + 1e-6; z += dz, j++) {
|
||||
for (let dd = g.wallBackD + c + (j % 2 ? dx / 2 : 0); dd <= g.L - c + 1e-6; dd += dx) {
|
||||
if (z > top(dd) - c || z - h13 < bottom(dd) + c) continue; // 갈고리까지 날개벽 안
|
||||
const a = at(dd, dF);
|
||||
const b = at(dd, dB);
|
||||
put('WR20', [P3(a, z - h13), P3(a, z), P3(b, z), P3(b, z - h13)], dB - dF + 2 * h13);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 표 (시안 순서).
|
||||
const bars: AbutWingBar[] = [];
|
||||
const push = (b: Omit<AbutWingBar, 'length' | 'lengthMin' | 'lengthMax' | 'count' | 'splice'>) => {
|
||||
const all = lengths.get(b.mark) ?? [];
|
||||
if (all.length === 0) return;
|
||||
const max = Math.max(...all);
|
||||
const splice = splicePlan(max, d(b.size), fy(b.size), material.fck) ?? undefined;
|
||||
if (splice) add('RB-AW01', splice.over ? 'W' : 'I', `${b.mark} ${Math.round(max)} > 정척 ${STANDARD_BAR}: ${splice.pieces}조각 이음 l_s ${splice.lap}.`);
|
||||
bars.push({ ...b, note: splice ? `${b.note} · ${splice.pieces}조각 이음 l_s ${splice.lap}` : b.note, length: Math.round(max), lengthMin: Math.round(Math.min(...all)), lengthMax: Math.round(max), count: all.length, splice });
|
||||
};
|
||||
const vMod = s.sVert !== undefined;
|
||||
const hMod = s.sHoriz !== undefined;
|
||||
const v2 = lenText(2 * sBack);
|
||||
const v1 = lenText(sBack);
|
||||
const hz2 = lenText(2 * sBackV);
|
||||
const hz1 = lenText(sBackV);
|
||||
push({ mark: 'WR11', size: SZ.WR11, role: '연직', spacing: v2, actual: v2, shape: '직선 · 아래 경사 하단 + c', position: '전면 · 변화단면', note: `기준선 (뒷굽 끝 − ${lenText(c)}) → 끝 쪽 ${vertical.front.nV}@${v2} · 끝 여유 ${lenText(vertical.front.endGap)}`, basis: '규칙', modified: vMod });
|
||||
push({ mark: 'WR12', size: SZ.WR12, role: '연직', spacing: v2, actual: v2, shape: `아래 90° 갈고리 ${h16} (저판 B6 · B8 위, 안쪽으로)`, position: '전면 · 등단면', note: `기준선 + 교대 쪽 ${vertical.front.nE}@${v2} · 마지막 ${lenText(vertical.front.lastE)} · 전면은 저판 끝면과 같은 면이라 갈고리를 안쪽으로`, basis: '규칙', modified: vMod });
|
||||
push({ mark: 'WR1', size: SZ.WR1, role: '연직', spacing: v1, actual: v1, shape: '직선 · 아래 경사 하단 + c', position: '배면 · 변화단면', note: `${vertical.back.nV}@${v1} · 끝 여유 ${lenText(vertical.back.endGap)}`, basis: '규칙', modified: vMod });
|
||||
push({ mark: 'WR2', size: SZ.WR2, role: '연직', spacing: v1, actual: v1, shape: `아래 90° 갈고리 ${h16}`, position: '배면 · 등단면', note: `기준선 + ${vertical.back.nE}@${v1} · 마지막 ${lenText(vertical.back.lastE)}`, basis: '규칙', modified: vMod });
|
||||
push({ mark: 'WR13', size: SZ.WR13, role: '수평', spacing: hz2, actual: hz2, shape: `교대 쪽 흉벽 지나 90° 갈고리 ${h25} · 맨 위 2단 경사와 나란히`, position: '전면 수평 · 등단면 띠', note: '흉벽 전면 − c까지 (Q-WR1: 맨 위 2단 경사)', basis: '규칙', modified: hMod });
|
||||
push({ mark: 'WR14', size: SZ.WR14, role: '수평', spacing: hz2, actual: hz2, shape: `흉벽 + 브라켓 지나 90° 갈고리 ${h25}`, position: '전면 수평 · 변화단면1 (~ 교좌면)', note: '흉벽 전면 − c까지', basis: '규칙', modified: hMod });
|
||||
push({ mark: 'WR15', size: SZ.WR15, role: '수평', spacing: hz2, actual: hz2, shape: `벽체 지나 90° 갈고리 ${h25}`, position: '전면 수평 · 변화단면2 (교좌면 아래)', note: '벽체 전면 − c까지', basis: '규칙', modified: hMod });
|
||||
push({ mark: 'WR3', size: SZ.WR3, role: '수평', spacing: hz1, actual: hz1, shape: `흉벽 지나 90° 갈고리 ${h25} · 맨 위 2단 경사와 나란히`, position: '배면 수평 · 등단면 띠', note: `교대 쪽 ${levels.back.start}단 · 끝 ${levels.back.end}단`, basis: '규칙', modified: hMod });
|
||||
push({ mark: 'WR4', size: SZ.WR4, role: '수평', spacing: hz1, actual: hz1, shape: `흉벽 + 브라켓 지나 90° 갈고리 ${h25}`, position: '배면 수평 · 변화단면1', note: '', basis: '규칙', modified: hMod });
|
||||
push({ mark: 'WR5', size: SZ.WR5, role: '수평', spacing: hz1, actual: hz1, shape: `벽체 지나 90° 갈고리 ${h25}`, position: '배면 수평 · 변화단면2', note: '', basis: '규칙', modified: hMod });
|
||||
push({ mark: 'WR6', size: SZ.WR6, role: '경사 하단', spacing: '—', actual: '—', shape: `경사 하단과 나란히 · 아래 저판 안 l_d ${ld.H16}`, position: '경사 하단 · 전면 · 배면 각 1', note: '"cycle-2" 삭제 (결정)', basis: '규칙', modified: false });
|
||||
const wr7Shape = (front: string) => `평면 꺾임 · 갈고리 ${h25} · 안쪽 모서리 c 지나는 대각 (헌치 빗변과 나란히) · ${front} − c ~ 날개벽 바깥면 − c · 갈고리 ${h25}`;
|
||||
push({ mark: 'WR7-1', size: SZ['WR7-1'], role: '헌치', spacing: hz2, actual: hz2, shape: wr7Shape('흉벽 전면'), position: '위 헌치 · 브라켓 상면 ~ 상단', note: '사용자 도면 2026-11-05: 긴 대각 + 양끝 평면 갈고리', basis: '규칙', modified: hMod });
|
||||
push({ mark: 'WR7-2', size: SZ['WR7-2'], role: '헌치', spacing: hz2, actual: hz2, shape: wr7Shape('흉벽 전면'), position: '아래 헌치 윗부분 · 교좌면 ~ 브라켓 상면', note: '띠 경계 브라켓 상면 / 교좌면 (Q-WR6)', basis: '규칙', modified: hMod });
|
||||
push({ mark: 'WR7-3', size: SZ['WR7-3'], role: '헌치', spacing: hz2, actual: hz2, shape: wr7Shape('벽체 전면'), position: '아래 헌치 아랫부분 · 저판 상면 ~ 교좌면', note: 'WR8 결번', basis: '규칙', modified: hMod });
|
||||
push({ mark: 'WR9', size: SZ.WR9, role: '헌치', spacing: lenText(2 * sBack), actual: lenText(2 * sBack), shape: `꺾임 · 연직 다리 ${lenText(la9)} · 헌치 빗변과 나란한 대각 · 저판 하면망에서 90° 갈고리 ${h16}`, position: '저판 상면 헌치 (날개벽 배면) · 벽체 배면 ~ 뒷굽 끝', note: `연직헌치 보강 (사용자 도면 2026-11-04) · @${lenText(2 * sBack)} 자동 개수 · 연직 다리 = 벽체 W9 · W10 정착장`, basis: '규칙', modified: vMod });
|
||||
push({ mark: 'WR10', size: SZ.WR10, role: '모서리', spacing: hz1, actual: hz1, shape: `ㄱ자 · 교대 쪽 ${WR10.abutLeg} · 날개벽 쪽 ${WR10.wingLeg}`, position: `교대 끝면 · 날개벽 바깥면 − ${WR10.cover} · 흉벽 배면 + ${WR10.cover} · 저판 상면 ~ 흉벽 상단`, note: '사용자 도면 2026-11-05 (교대와 날개벽을 묶는 모서리 보강)', basis: '규칙', modified: hMod });
|
||||
push({ mark: 'WR20', size: SZ.WR20, role: '간격재', spacing: `${lenText(s.k20 * sBack)} × ${lenText(s.k20 * sBackV)}`, actual: '—', shape: `양끝 90° 갈고리 ${hook(SZ.WR20)}`, position: `내측 간격재 · 두께 ${lenText(T - 2 * (c + (d('H16') + d('H25')) / 2))} 걸침`, note: '철근표 H20은 오기 → H13', basis: '규칙', modified: s.k20 !== 8 });
|
||||
|
||||
return { side: g.side, settings: s, vertical, levels, haunchLevels, ld, paths, bars, issues };
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
/**
|
||||
* 교대 형상 입력 (2026-11-03, 시안 ② 벽체·흉벽 · 확대기초·말뚝 카드): the values that used to be constants, in mm.
|
||||
* The defaults are the spec_abut values. A record keeps only the values the user changed; the rest come from here.
|
||||
*
|
||||
* Axis frame: t (교대축 직각) is + toward the front. 벽체 전면 = wallFrontMm (교좌면 폭, 흉벽 전면 t 0에서).
|
||||
* 흉벽 sits behind the axis (t −parapetThicknessMm … 0); the 브라켓 (bracketWidthMm) behind it. 벽체 배면 = −(흉벽 두께 + 브라켓 폭),
|
||||
* so 벽체 두께 = 브라켓 폭 + 흉벽 두께 + 교좌면 폭 (시안: 300 + 500 + 1600 = 2400, 자동).
|
||||
* 저판 (시안 확대기초·말뚝): 폭과 앞굽을 입력하고 뒷굽은 자동 (폭 − 앞굽 − 벽체 두께). 앞굽 끝 t = 교좌면 폭 + 앞굽, 뒷굽 끝 = 앞굽 끝 − 폭.
|
||||
*/
|
||||
export interface AbutShape {
|
||||
wallFrontMm: number;
|
||||
parapetHeightMm: number;
|
||||
parapetThicknessMm: number;
|
||||
bracketHeightMm: number;
|
||||
/** 브라켓 폭 (mm): 흉벽 배면에서 벽체 배면까지 (시안 300). */
|
||||
bracketWidthMm: number;
|
||||
footingThicknessMm: number;
|
||||
/** 저판 폭 (mm, 교대축 직각) and 앞굽 (mm, 벽체 전면에서). 뒷굽은 자동. */
|
||||
footingWidthMm: number;
|
||||
footingToeMm: number;
|
||||
/** 말뚝 연단 (길이 방향, mm)과 본수 규칙의 간격 (mm): 본수 = int((L − 2·연단)/간격) + 1. */
|
||||
pileEdgeMm: number;
|
||||
pileSpacingMm: number;
|
||||
/** 말뚝 지름 (mm, 원기둥, QA-5). */
|
||||
pileDiameterMm: number;
|
||||
/** 말뚝 열 위치 (mm, 저판 배면부터): 배면 연단, 3열→2열 간격, 2열→1열 간격. 전면 연단은 자동 (폭 − 합). */
|
||||
pileRowBackEdgeMm: number;
|
||||
pileRowGap1Mm: number;
|
||||
pileRowGap2Mm: number;
|
||||
/** 열별 본수 (1열 전면, 2열, 3열 배면). 0 = 본수 규칙대로. */
|
||||
pileCountRow1: number;
|
||||
pileCountRow2: number;
|
||||
pileCountRow3: number;
|
||||
/** 버림 여유 (한쪽, mm)와 두께 (mm). */
|
||||
blindingMarginMm: number;
|
||||
blindingThicknessMm: number;
|
||||
/** 수축줄눈 개수 (개). Shown in the 삽도; not used by the geometry yet. */
|
||||
shrinkJointCount: number;
|
||||
/** 흉벽 상단 Level 좌·우 (mm). Not used by the geometry (QA-4, 가정). */
|
||||
parapetLevelLeftMm: number;
|
||||
parapetLevelRightMm: number;
|
||||
/** 인접 무시 기준 (mm): 이웃 교좌면 EL 차이가 이 값보다 작으면 계단을 두지 않고 한 구간으로 묶는다 (교각 Q2와 같은 뜻). */
|
||||
seatStepIgnoreMm: number;
|
||||
}
|
||||
|
||||
export const ABUT_SHAPE_DEFAULT: AbutShape = {
|
||||
wallFrontMm: 1600,
|
||||
/** 벽체 높이 H_w: 받침 ① 위치에서 받침 ① 교좌면 ~ 흉벽 상단 연직 거리 (사용자 도면, 2026-11-04). 모든 교대 기본값. */
|
||||
parapetHeightMm: 3326,
|
||||
parapetThicknessMm: 500,
|
||||
bracketHeightMm: 850,
|
||||
bracketWidthMm: 300,
|
||||
footingThicknessMm: 1100,
|
||||
footingWidthMm: 4600,
|
||||
footingToeMm: 1200,
|
||||
pileEdgeMm: 650,
|
||||
pileSpacingMm: 2000,
|
||||
pileDiameterMm: 600,
|
||||
pileRowBackEdgeMm: 650,
|
||||
pileRowGap1Mm: 2000,
|
||||
pileRowGap2Mm: 1300,
|
||||
pileCountRow1: 0,
|
||||
pileCountRow2: 0,
|
||||
pileCountRow3: 0,
|
||||
blindingMarginMm: 100,
|
||||
blindingThicknessMm: 100,
|
||||
shrinkJointCount: 1,
|
||||
parapetLevelLeftMm: 450,
|
||||
parapetLevelRightMm: 995,
|
||||
seatStepIgnoreMm: 5,
|
||||
};
|
||||
|
||||
/**
|
||||
* The user's changes with the old 벽체 두께 input (before 2026-11-03) turned into the 브라켓 폭:
|
||||
* 브라켓 폭 = 벽체 두께 − 교좌면 폭 − 흉벽 두께. Saved projects keep opening with the same wall.
|
||||
*/
|
||||
export function normalizeShape(
|
||||
changes?: Partial<AbutShape> & { wallThicknessMm?: number; footingFrontMm?: number; footingBackMm?: number },
|
||||
): Partial<AbutShape> {
|
||||
if (!changes) return {};
|
||||
const { wallThicknessMm, footingFrontMm, footingBackMm, ...rest } = changes;
|
||||
const seat = rest.wallFrontMm ?? ABUT_SHAPE_DEFAULT.wallFrontMm;
|
||||
const parapet = rest.parapetThicknessMm ?? ABUT_SHAPE_DEFAULT.parapetThicknessMm;
|
||||
const out: Partial<AbutShape> = { ...rest };
|
||||
if (wallThicknessMm !== undefined && rest.bracketWidthMm === undefined) out.bracketWidthMm = wallThicknessMm - seat - parapet;
|
||||
// 저판 끝 (before 2026-11-04) → 폭 · 앞굽: 앞굽 = 앞굽 끝 − 교좌면 폭, 폭 = 앞굽 끝 − 뒷굽 끝.
|
||||
if ((footingFrontMm !== undefined || footingBackMm !== undefined) && rest.footingWidthMm === undefined && rest.footingToeMm === undefined) {
|
||||
const front = footingFrontMm ?? seat + ABUT_SHAPE_DEFAULT.footingToeMm;
|
||||
const back = footingBackMm ?? front - ABUT_SHAPE_DEFAULT.footingWidthMm;
|
||||
out.footingToeMm = front - seat;
|
||||
out.footingWidthMm = front - back;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 저판 앞굽 끝·뒷굽 끝 (mm, t) and the 뒷굽 (mm, 자동). */
|
||||
export function footingEnds(v: AbutShape): { frontMm: number; backMm: number; heelMm: number } {
|
||||
const frontMm = v.wallFrontMm + v.footingToeMm;
|
||||
const backMm = frontMm - v.footingWidthMm;
|
||||
return { frontMm, backMm, heelMm: v.footingWidthMm - v.footingToeMm - wallThicknessMm(v) };
|
||||
}
|
||||
|
||||
/** 말뚝 열 위치 t (mm): 배면 3열, 2열, 전면 1열 (저판 배면부터 잰 값으로). 전면 연단은 자동. */
|
||||
export function pileRowsMm(v: AbutShape): { back: number; middle: number; front: number; frontEdgeMm: number } {
|
||||
const { backMm } = footingEnds(v);
|
||||
const back = backMm + v.pileRowBackEdgeMm;
|
||||
const middle = back + v.pileRowGap1Mm;
|
||||
const front = middle + v.pileRowGap2Mm;
|
||||
return { back, middle, front, frontEdgeMm: v.footingWidthMm - v.pileRowBackEdgeMm - v.pileRowGap1Mm - v.pileRowGap2Mm };
|
||||
}
|
||||
|
||||
/** 벽체 두께 (mm, 자동) = 브라켓 폭 + 흉벽 두께 + 교좌면 폭. */
|
||||
export function wallThicknessMm(v: AbutShape): number {
|
||||
return v.bracketWidthMm + v.parapetThicknessMm + v.wallFrontMm;
|
||||
}
|
||||
|
||||
/** The same values in m, for the geometry (the model works in m). */
|
||||
export interface ResolvedShape {
|
||||
wallFrontM: number;
|
||||
wallBackM: number;
|
||||
parapetHeightM: number;
|
||||
parapetBackM: number;
|
||||
bracketHeightM: number;
|
||||
footingThicknessM: number;
|
||||
footingFrontM: number;
|
||||
footingBackM: number;
|
||||
pileEdgeM: number;
|
||||
pileSpacingM: number;
|
||||
/** 말뚝 열 t (m): 배면 3열, 2열, 전면 1열. */
|
||||
pileRowsT: number[];
|
||||
/** 열별 본수 (배면 3열, 2열, 전면 1열의 순서), 0 = 본수 규칙. */
|
||||
pileCounts: number[];
|
||||
pileDiameterM: number;
|
||||
blindingMarginM: number;
|
||||
blindingThicknessM: number;
|
||||
/** 인접 무시 기준 (m). */
|
||||
seatStepIgnoreM: number;
|
||||
}
|
||||
|
||||
/** Merges the user's changes over the defaults and converts to m. */
|
||||
export function resolveShape(changes?: Partial<AbutShape>): ResolvedShape {
|
||||
const v: AbutShape = { ...ABUT_SHAPE_DEFAULT, ...normalizeShape(changes) };
|
||||
const wallBackMm = -(v.parapetThicknessMm + v.bracketWidthMm);
|
||||
return {
|
||||
wallFrontM: v.wallFrontMm / 1000,
|
||||
wallBackM: wallBackMm / 1000,
|
||||
parapetHeightM: v.parapetHeightMm / 1000,
|
||||
parapetBackM: -v.parapetThicknessMm / 1000,
|
||||
bracketHeightM: v.bracketHeightMm / 1000,
|
||||
footingThicknessM: v.footingThicknessMm / 1000,
|
||||
footingFrontM: footingEnds(v).frontMm / 1000,
|
||||
footingBackM: footingEnds(v).backMm / 1000,
|
||||
pileEdgeM: v.pileEdgeMm / 1000,
|
||||
pileSpacingM: v.pileSpacingMm / 1000,
|
||||
pileRowsT: [pileRowsMm(v).back / 1000, pileRowsMm(v).middle / 1000, pileRowsMm(v).front / 1000],
|
||||
pileCounts: [v.pileCountRow3, v.pileCountRow2, v.pileCountRow1],
|
||||
pileDiameterM: v.pileDiameterMm / 1000,
|
||||
blindingMarginM: v.blindingMarginMm / 1000,
|
||||
blindingThicknessM: v.blindingThicknessMm / 1000,
|
||||
seatStepIgnoreM: v.seatStepIgnoreMm / 1000,
|
||||
};
|
||||
}
|
||||
|
||||
/** Input problems in Korean; empty when the shape can be built. */
|
||||
export function shapeIssues(changes: Partial<AbutShape>): string[] {
|
||||
const v: AbutShape = { ...ABUT_SHAPE_DEFAULT, ...normalizeShape(changes) };
|
||||
const out: string[] = [];
|
||||
if (!(v.wallFrontMm > 0)) out.push('교좌면 폭은 0보다 커야 합니다.');
|
||||
if (!(v.bracketWidthMm >= 0)) out.push('브라켓 폭은 0 이상이어야 합니다.');
|
||||
if (!(v.parapetHeightMm > 0)) out.push('벽체 높이(받침 ① 기준)는 0보다 커야 합니다.');
|
||||
if (!(v.parapetThicknessMm > 0)) out.push('흉벽 두께는 0보다 커야 합니다.');
|
||||
if (!(v.bracketHeightMm >= 0)) out.push('브라켓 높이는 0 이상이어야 합니다.');
|
||||
if (!(v.footingThicknessMm > 0)) out.push('저판 두께는 0보다 커야 합니다.');
|
||||
if (!(v.pileEdgeMm > 0) || !(v.pileSpacingMm > 0)) out.push('말뚝 연단과 간격은 0보다 커야 합니다.');
|
||||
if (!(v.footingToeMm > 0)) out.push('저판 앞굽은 0보다 커야 합니다.');
|
||||
if (!(footingEnds(v).heelMm > 0)) out.push(`저판 뒷굽(자동 ${footingEnds(v).heelMm})이 0보다 커야 합니다: 폭 > 앞굽 + 벽체 두께.`);
|
||||
if (!(v.pileDiameterMm > 0)) out.push('말뚝 지름은 0보다 커야 합니다.');
|
||||
if (!(v.pileRowBackEdgeMm > 0) || !(v.pileRowGap1Mm > 0) || !(v.pileRowGap2Mm > 0) || !(pileRowsMm(v).frontEdgeMm > 0)) {
|
||||
out.push('말뚝 열 위치는 0보다 크고, 합이 저판 폭보다 작아야 합니다 (전면 연단 > 0).');
|
||||
}
|
||||
for (const n of [v.pileCountRow1, v.pileCountRow2, v.pileCountRow3]) {
|
||||
if (!(Number.isInteger(n) && n >= 0)) out.push('열별 말뚝 본수는 0(규칙) 이상의 정수여야 합니다.');
|
||||
}
|
||||
if (!(v.blindingMarginMm >= 0) || !(v.blindingThicknessMm >= 0)) out.push('버림 여유와 두께는 0 이상이어야 합니다.');
|
||||
if (!(Number.isInteger(v.shrinkJointCount) && v.shrinkJointCount >= 0)) out.push('수축줄눈 개수는 0 이상의 정수여야 합니다.');
|
||||
if (!(v.seatStepIgnoreMm >= 0)) out.push('인접 무시 기준은 0 이상이어야 합니다.');
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* 교대 접속슬래브 (2026-11-04, spec_abut §4.8, 시안 ② 접속슬래브 카드): 형식 · 경사 · 치수 입력과 단면 (t, EL). 순수 함수.
|
||||
*
|
||||
* - 매립형 (type-1, 기본): 하면 = 브라켓 상면, 두께 일정. 브라켓 위(벽체 배면까지)는 수평이고 그 뒤로 경사(기본 하향 4 %).
|
||||
* - 노출형 (type-2, 2026-11-04 삽도 기준, QA-8): 상면 = 흉벽 상단에서 경사(기본 종단경사)를 따른다. 끝은 브라켓 상면에
|
||||
* 얹히고(끝 두께 = 브라켓 높이), 벽체 배면부터 헌치 길이 동안 두께가 줄어 그 뒤로는 두께가 일정하다.
|
||||
* - 경사는 형식이 정한다 (2026-11-04): 매립형 = 하향 4 %, 노출형 = 종단경사 따라 (엑셀 A555 "type-1 = 하향경사 4 %, type-2 = 종단경사").
|
||||
* - t: 교대축 직각 (전면 +, 배면 −, m). 슬래브는 흉벽 배면에서 "흉벽과 간격"만큼 띄워 시작하고 길이만큼 배면 쪽으로 간다.
|
||||
* - 단면의 EL은 그 s의 흉벽 상단 EL에서 잰 값이다 (흉벽 상단 경사는 호출하는 쪽에서 더한다).
|
||||
*/
|
||||
|
||||
export type SlabForm = 'buried' | 'exposed';
|
||||
|
||||
export interface AbutSlab {
|
||||
form: SlabForm;
|
||||
lengthMm: number;
|
||||
thicknessMm: number;
|
||||
parapetGapMm: number;
|
||||
wingGapMm: number;
|
||||
/** 노출형 헌치 길이 (벽체 배면부터, mm). 매립형은 쓰지 않는다. 기본 2000은 삽도 비율에서 잡은 가정값이다. */
|
||||
haunchLengthMm: number;
|
||||
}
|
||||
|
||||
export const ABUT_SLAB_DEFAULT: AbutSlab = {
|
||||
form: 'buried',
|
||||
lengthMm: 7000,
|
||||
thicknessMm: 450,
|
||||
parapetGapMm: 5,
|
||||
wingGapMm: 20,
|
||||
haunchLengthMm: 2000,
|
||||
};
|
||||
|
||||
/** 매립형 하향 경사 (%, 배면 쪽으로 내려감). */
|
||||
export const SLAB_FALL_PCT = 4;
|
||||
|
||||
export const SLAB_FORM_LABEL: Record<SlabForm, string> = { buried: '매립형', exposed: '노출형' };
|
||||
|
||||
/** The user's values over the defaults. A `slope` stored by an earlier version (2026-11-04 오전) is dropped: the form sets it. */
|
||||
export function resolveSlab(changes?: Partial<AbutSlab>): AbutSlab {
|
||||
const { slope: _old, ...rest } = (changes ?? {}) as Partial<AbutSlab> & { slope?: unknown };
|
||||
return { ...ABUT_SLAB_DEFAULT, ...rest };
|
||||
}
|
||||
|
||||
/** Keys whose value differs from the default (형식 and the dims), for the change marks. */
|
||||
export function slabChangedKeys(changes?: Partial<AbutSlab>): (keyof AbutSlab)[] {
|
||||
const s = resolveSlab(changes);
|
||||
return (Object.keys(ABUT_SLAB_DEFAULT) as (keyof AbutSlab)[]).filter((k) => s[k] !== ABUT_SLAB_DEFAULT[k]);
|
||||
}
|
||||
|
||||
/** Slope (%), + = rises away from the wall: 매립형 하향 4 % → −4, 노출형 종단경사 → the wing slope (A1 = −i, A2 = +i). */
|
||||
export function slabGradePct(slab: AbutSlab, wingSlopePct: number): number {
|
||||
return slab.form === 'buried' ? -SLAB_FALL_PCT : wingSlopePct;
|
||||
}
|
||||
|
||||
/** Slope text as on the card: "하향 4 %" or "종단경사 +1.91 %". */
|
||||
export function slabSlopeLabel(slab: AbutSlab, wingSlopePct?: number): string {
|
||||
if (slab.form === 'buried') return `하향 ${SLAB_FALL_PCT} %`;
|
||||
if (wingSlopePct === undefined) return '종단경사';
|
||||
return `종단경사 ${wingSlopePct >= 0 ? '+' : ''}${wingSlopePct.toFixed(2)} %`;
|
||||
}
|
||||
|
||||
/** Input problems in Korean; empty when the slab can be built. The slab must fit under the 흉벽 상단 on the 브라켓. */
|
||||
export function slabIssues(s: AbutSlab, bracketHeightMm: number): string[] {
|
||||
const out: string[] = [];
|
||||
if (!(s.lengthMm > 0)) out.push('접속슬래브 길이는 0보다 커야 합니다.');
|
||||
if (!(s.thicknessMm > 0)) out.push('접속슬래브 두께는 0보다 커야 합니다.');
|
||||
if (!(s.parapetGapMm >= 0)) out.push('흉벽과 간격은 0 이상이어야 합니다.');
|
||||
if (!(s.wingGapMm >= 0)) out.push('날개벽과 간격은 0 이상이어야 합니다.');
|
||||
if (s.thicknessMm > bracketHeightMm) out.push(`접속슬래브 두께는 브라켓 높이(${bracketHeightMm}) 이하여야 합니다.`);
|
||||
if (s.form === 'exposed') {
|
||||
if (!(s.haunchLengthMm > 0)) out.push('노출형 헌치 길이는 0보다 커야 합니다.');
|
||||
else if (s.haunchLengthMm >= s.lengthMm) out.push('노출형 헌치 길이는 슬래브 길이보다 짧아야 합니다.');
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** One station of the section: t (m) and the top / bottom levels (m, from the 흉벽 상단 at that s). */
|
||||
export interface SlabStation {
|
||||
t: number;
|
||||
top: number;
|
||||
bottom: number;
|
||||
}
|
||||
|
||||
/** The 벽체·흉벽 values the section needs (m, from the resolved shape). */
|
||||
export interface SlabShapeRef {
|
||||
parapetBackM: number;
|
||||
wallBackM: number;
|
||||
bracketHeightM: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Section stations from the near end (흉벽 쪽) to the far end, at every bend: near end, 벽체 배면, 헌치 끝 (노출형), far end.
|
||||
* Between stations the top and the bottom are straight, so a strip between two stations is flat on top and below.
|
||||
*/
|
||||
export function slabSection(slab: AbutSlab, gradePct: number, sh: SlabShapeRef): SlabStation[] {
|
||||
const g = gradePct / 100;
|
||||
const th = slab.thicknessMm / 1000;
|
||||
const t0 = sh.parapetBackM - slab.parapetGapMm / 1000;
|
||||
const t1 = t0 - slab.lengthMm / 1000;
|
||||
const bracket = -sh.bracketHeightM;
|
||||
const wb = sh.wallBackM;
|
||||
let level: (t: number) => { top: number; bottom: number };
|
||||
const marks = [t0, wb, t1];
|
||||
if (slab.form === 'buried') {
|
||||
level = (t) => {
|
||||
const bottom = bracket + g * Math.max(0, wb - t);
|
||||
return { top: bottom + th, bottom };
|
||||
};
|
||||
} else {
|
||||
const he = wb - slab.haunchLengthMm / 1000;
|
||||
marks.push(he);
|
||||
const top = (t: number) => g * (t0 - t);
|
||||
level = (t) => {
|
||||
if (t >= wb) return { top: top(t), bottom: bracket };
|
||||
const full = top(t) - th;
|
||||
if (t <= he) return { top: top(t), bottom: full };
|
||||
const k = (wb - t) / (wb - he);
|
||||
return { top: top(t), bottom: bracket + k * (top(he) - th - bracket) };
|
||||
};
|
||||
}
|
||||
const ts = [...new Set(marks.filter((t) => t <= t0 + 1e-9 && t >= t1 - 1e-9).map((t) => Math.round(t * 1e9) / 1e9))].sort((a, b) => b - a);
|
||||
return ts.map((t) => ({ t, ...level(t) }));
|
||||
}
|
||||
|
||||
/** Top and bottom at any t inside the slab, by straight lines between the stations. */
|
||||
export function slabLevelAt(stations: SlabStation[], t: number): { top: number; bottom: number } {
|
||||
for (let i = 0; i + 1 < stations.length; i++) {
|
||||
const a = stations[i];
|
||||
const b = stations[i + 1];
|
||||
if (t <= a.t + 1e-9 && t >= b.t - 1e-9) {
|
||||
const k = a.t === b.t ? 0 : (a.t - t) / (a.t - b.t);
|
||||
return { top: a.top + k * (b.top - a.top), bottom: a.bottom + k * (b.bottom - a.bottom) };
|
||||
}
|
||||
}
|
||||
const end = t > stations[0].t ? stations[0] : stations[stations.length - 1];
|
||||
return { top: end.top, bottom: end.bottom };
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* 교대 상세 (2026-11-03, 교각 상세와 같은 개념): the values the 삽도 shows, computed from the model. Read only.
|
||||
* Each row is [label, value]. Lengths in mm, levels in m, as in the 삽도.
|
||||
*/
|
||||
import { jointPositions } from './joints';
|
||||
import type { AbutRecord } from './parse';
|
||||
import { buildAbut, type AbutModel } from './geometry';
|
||||
import { toAbutInput } from './parse';
|
||||
import { buildMembers, type AbutMembers } from './members';
|
||||
import { buildWings } from './wings';
|
||||
|
||||
const m3 = (v: number) => `${v.toFixed(3)} m`;
|
||||
const mm = (v: number) => `${Math.round(v)} mm`;
|
||||
|
||||
export function abutSummaryRows(rec: AbutRecord): [string, string][] {
|
||||
const model: AbutModel = buildAbut(toAbutInput(rec));
|
||||
const members = buildMembers(model, { bottomEl: rec.foundationBottomEl, pileLengthMm: rec.pileLengthMm }, rec.shape);
|
||||
const wings = buildWings(model, members, rec.wings, rec.wingDims);
|
||||
const shape = members.shape;
|
||||
const first = model.bearings[0];
|
||||
const L = model.length;
|
||||
const rows: [string, string][] = [
|
||||
['형식 / 기초 형식', `${rec.type} · ${rec.foundation}`],
|
||||
['교좌면 EL (구간 ①)', m3(rec.seatEl)],
|
||||
['기초 하단 EL', m3(rec.foundationBottomEl)],
|
||||
['지반고 EL', m3(rec.groundEl)],
|
||||
['저판 상면 EL', m3(members.footingTopEl)],
|
||||
['교대축 길이 (IP1 → IP3)', m3(L)],
|
||||
['skew', `${rec.skewDeg.toFixed(2)}°`],
|
||||
['종단경사 i', `${(rec.slopeI * 100).toFixed(2)} %`],
|
||||
['받침블럭 높이 (패드 포함)', mm(first.blockHeightMm)],
|
||||
['벽체 높이 (구간 ①)', m3(members.wallSegments.find((w) => Math.abs(w.seatEl - first.seatEl) < 1e-9)?.heightM ?? NaN)],
|
||||
['벽체 두께 · 전면', `${Math.round(shape.wallFrontM * 1000 - shape.wallBackM * 1000)} mm · ${Math.round(shape.wallFrontM * 1000)} mm`],
|
||||
['벽체 높이 (받침 ① 기준) · 흉벽 두께', `${Math.round(shape.parapetHeightM * 1000)} mm · ${Math.round(-shape.parapetBackM * 1000)} mm`],
|
||||
['흉벽 상단 EL (IP1 끝 / IP3 끝)', `${members.parapetTopStartEl.toFixed(3)} / ${members.parapetTopEndEl.toFixed(3)} m`],
|
||||
['브라켓 상면 EL (구간 ①)', m3(first.seatEl + shape.parapetHeightM - shape.bracketHeightM)],
|
||||
['흉벽 경사', `${(members.parapetSlope * 100).toFixed(3)} %`],
|
||||
['계단 경계 (IP3 쪽에서)', model.stepBounds.map((s) => (L - s).toFixed(3)).reverse().join(' / ') + ' m'],
|
||||
['저판 폭 (앞굽 끝 / 뒷굽 끝)', `${Math.round(shape.footingFrontM * 1000)} / ${Math.round(shape.footingBackM * 1000)} mm`],
|
||||
['말뚝 (열 × 본수)', `${members.piles.length} × ${members.piles[0]?.s.length ?? 0} = ${members.piles.length * (members.piles[0]?.s.length ?? 0)} 본`],
|
||||
['말뚝 선단 EL', members.pileBottomEl === undefined ? '—' : m3(members.pileBottomEl)],
|
||||
['수축줄눈 (기본 ceil(L/10) − 1, 받침 사이 중간)', (() => { const js = jointPositions(L, model.bearings.map((b) => b.s), rec.shape?.shrinkJointCount); return js.length ? `${js.length} 개 · IP3에서 ${js.map((s) => (L - s).toFixed(3)).reverse().join(' / ')} m` : '0 개'; })()],
|
||||
];
|
||||
if (wings.length > 0) {
|
||||
const w = wings[0];
|
||||
rows.push(['날개벽 경사', `${model.wing ? (model.wing.slopePct).toFixed(2) : '—'} %`]);
|
||||
rows.push(['날개벽 뒷굽 거리 (교대축 기준)', mm(w.heelDistanceM * 1000)]);
|
||||
}
|
||||
if (model.wing) rows.push(['접속슬래브 폭', mm(model.wing.slabWidthMm)]);
|
||||
return rows;
|
||||
}
|
||||
|
||||
/** 입력값 rows of the 교대 panel (시안 "A1 입력값" card): what the user typed, read only. */
|
||||
export function abutInputRows(rec: AbutRecord): [string, string][] {
|
||||
const rows: [string, string][] = [
|
||||
['형식 / 기초 형식', `${rec.type} (${rec.type === 'A1' ? '시점' : '종점'}) · ${rec.foundation}`],
|
||||
['교좌면 EL (구간 ①)', `${rec.seatEl.toFixed(3)} m`],
|
||||
['기초 하단 EL', `${rec.foundationBottomEl.toFixed(3)} m`],
|
||||
['지반고 EL', `${rec.groundEl.toFixed(3)} m`],
|
||||
['skew', `${rec.skewDeg.toFixed(2)}°`],
|
||||
['종단경사 i', `${(rec.slopeI * 100).toFixed(2)} %`],
|
||||
['말뚝 길이', `${Math.round(rec.pileLengthMm)} mm`],
|
||||
['IP', 'IP1 → IP3 직선'],
|
||||
['받침', `${rec.bearings.length}개 · z = 패드 상면`],
|
||||
];
|
||||
return rows;
|
||||
}
|
||||
|
||||
/** The 8 detail rows shown in the collapsed 상세 (the rest stay in the 삽도 view). */
|
||||
export const ABUT_DETAIL_LABELS = [
|
||||
'벽체 높이 (구간 ①)',
|
||||
'흉벽 상단 EL (IP1 끝 / IP3 끝)',
|
||||
'브라켓 상면 EL (구간 ①)',
|
||||
'저판 상면 EL',
|
||||
'계단 경계 (IP3 쪽에서)',
|
||||
'말뚝 (열 × 본수)',
|
||||
'말뚝 선단 EL',
|
||||
'접속슬래브 폭',
|
||||
];
|
||||
|
||||
/** 구간 표 of the 벽체·흉벽 card (시안 하단): one row per 교좌면 step (2026-11-03). Values come from the model and members. */
|
||||
export const ABUT_SEGMENT_HEADER = ['구간', '시작 (m)', '끝 (m)', '교좌면 EL (m)', '받침블럭 높이 (mm)', '벽체 높이 (m)'];
|
||||
|
||||
export function abutSegmentRows(model: AbutModel, members: AbutMembers): string[][] {
|
||||
const circled = ['①', '②', '③', '④', '⑤', '⑥', '⑦', '⑧'];
|
||||
const bearings = [...model.bearings].sort((a, b) => a.s - b.s);
|
||||
return members.wallSegments.map((seg, k) => [
|
||||
circled[k] ?? String(k + 1),
|
||||
seg.sFrom.toFixed(3),
|
||||
seg.sTo.toFixed(3),
|
||||
seg.seatEl.toFixed(3),
|
||||
String(bearings[k]?.blockHeightMm ?? '—'),
|
||||
(seg.seatEl - members.footingTopEl).toFixed(3),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* What the 3D view draws for the selection (2026-11-03, 형상계획 ②).
|
||||
* In ② only the selected object is drawn: a selected abutment hides the piers, and a selected pier hides the abutments.
|
||||
* In ① (형상 생성) the abutments are always drawn, as before; the piers follow 전체 보기 in ①, not this rule.
|
||||
* In ③ (2026-11-04) only the selected object is drawn too, the same as ②: a selected pier hides the abutments.
|
||||
*/
|
||||
|
||||
/** True when the pier scene (and its rebar) should be built for this stage and selection. */
|
||||
export function piersShown(stage: string, abutActive: string | null): boolean {
|
||||
return !((stage === 'plan' || stage === 'rebar') && abutActive !== null);
|
||||
}
|
||||
|
||||
/** True when the abutment with this name should be drawn. */
|
||||
export function abutShown(stage: string, abutActive: string | null, name: string): boolean {
|
||||
if (stage !== 'plan' && stage !== 'rebar') return true;
|
||||
return name === abutActive;
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
/**
|
||||
* 교대 날개벽 · AA3b (2026-11-03, spec_abut §4.7): 날개벽 평면, 상단선, 단부 하단. 순수 함수.
|
||||
*
|
||||
* - 평면: 교좌면 끝(IP1 끝 s = 0 또는 IP3 끝 s = L)에서 날개벽 선(AA2)을 따라 길이 6.5 m. 두께 0.45 m는 교대 안쪽으로 둔다.
|
||||
* - 상단: 시작점 = 흉벽 상단 EL(그 끝의 s), 끝까지 날개벽 경사(i)로 직선.
|
||||
* - 하단 (일반형): 단부 하단 = 끝점 상단 − 1.5 m. 단축형: 하단 = 저판 상면 높이(평평하게).
|
||||
* - 좌(①쪽) 날개벽은 받침 ①에 가까운 끝, 우(n쪽)는 반대 끝 (spec_abut §4.7).
|
||||
* - 하단 선 (AA3c): 저판 뒷굽 끝(배면 −1.8 m)까지는 저판 상면, 그 밖은 뒷굽 끝 상단 모서리에서 단부 하단까지 직선
|
||||
* (변화단면). 뒷굽 끝까지의 날개벽 거리 = 1.8 / |sin Δ| (단축형 1.807 m와 같은 식).
|
||||
* - 헌치 300 × 300 (AA3c): 흉벽 배면(−0.5) 모서리에 위 헌치, 벽체 배면(−0.8) 모서리에 아래 헌치. 수직 삼각기둥.
|
||||
* 위 헌치는 브라켓 상면(흉벽 상단 − 0.85)부터 날개벽 상단까지, 아래 헌치는 저판 상면부터 브라켓 상면까지다.
|
||||
* - 아직 없음: 등단면·변화단면 수평 단 구분 (QA-12), 저판 측면 윤곽의 정밀 모서리.
|
||||
*
|
||||
* 수치는 spec_abut §12 A2 합성 예제(좌 46.838 → 46.714, 하단 45.214 / 우 47.183 → 47.059, 하단 45.559)와 맞춘다.
|
||||
*/
|
||||
import type { AbutModel, AbutPoint, WingForm } from './geometry';
|
||||
import type { AbutMembers } from './members';
|
||||
|
||||
export const WING_LENGTH_M = 6.5;
|
||||
export const WING_THICKNESS_M = 0.45;
|
||||
/** 단부 높이 (끝점 상단에서 단부 하단까지, m). */
|
||||
export const WING_END_HEIGHT_M = 1.5;
|
||||
|
||||
/** 날개벽 치수 입력 (측별, mm, 2026-11-04 시안 ② 날개벽 카드). 단축형 길이의 기본값은 뒷굽 끝까지의 거리다. */
|
||||
export interface WingDims {
|
||||
lengthMm: number;
|
||||
thicknessMm: number;
|
||||
endHeightMm: number;
|
||||
haunchMm: number;
|
||||
}
|
||||
|
||||
export const WING_DIMS_DEFAULT: WingDims = { lengthMm: 6500, thicknessMm: 450, endHeightMm: 1500, haunchMm: 300 };
|
||||
|
||||
/** The dims of one side: the user's values over the defaults; 단축형 without a length input runs to the 뒷굽 끝. */
|
||||
export function resolveWingDims(form: WingForm, changes: Partial<WingDims> | undefined, heelDistanceM: number): WingDims {
|
||||
const base = { ...WING_DIMS_DEFAULT, ...(changes ?? {}) };
|
||||
if (form === 'short' && changes?.lengthMm === undefined) base.lengthMm = Math.round(heelDistanceM * 1000);
|
||||
return base;
|
||||
}
|
||||
|
||||
/** Input problems of one side's dims, in Korean; empty when the wing can be built. */
|
||||
export function wingDimsIssues(d: WingDims): string[] {
|
||||
const out: string[] = [];
|
||||
if (!(d.lengthMm > 0)) out.push('날개벽 길이는 0보다 커야 합니다.');
|
||||
if (!(d.thicknessMm > 0)) out.push('날개벽 두께는 0보다 커야 합니다.');
|
||||
if (!(d.endHeightMm > 0)) out.push('날개벽 끝 높이는 0보다 커야 합니다.');
|
||||
if (!(d.haunchMm >= 0)) out.push('헌치는 0 이상이어야 합니다.');
|
||||
return out;
|
||||
}
|
||||
|
||||
export interface WingSolid {
|
||||
side: 'left' | 'right';
|
||||
form: Exclude<WingForm, 'none'>;
|
||||
/** 날개벽이 시작하는 축 끝: 0 (IP1 끝) 또는 L (IP3 끝). */
|
||||
sEnd: number;
|
||||
/** 이 측의 치수 (m): 길이, 두께, 끝 높이, 헌치 다리. */
|
||||
lengthM: number;
|
||||
thicknessM: number;
|
||||
endHeightM: number;
|
||||
haunchM: number;
|
||||
/** 평면 네 꼭짓점 (E, N), 순서: 시작, 끝, 끝 안쪽, 시작 안쪽. */
|
||||
corners: [AbutPoint, AbutPoint, AbutPoint, AbutPoint];
|
||||
topStartEl: number;
|
||||
topEndEl: number;
|
||||
/** 단부 하단 EL (일반형: 끝 상단 − 1.5; 단축형: 저판 상면). */
|
||||
bottomEndEl: number;
|
||||
/** 축 끝에서 날개벽 선을 따라 잰 뒷굽 끝까지의 거리 (m). */
|
||||
heelDistanceM: number;
|
||||
/** 상단선 (d = 축 끝에서 날개벽 방향 거리 m, EL m). */
|
||||
topLine: { dM: number; el: number }[];
|
||||
/** 하단선: 저판 상면 → 뒷굽 끝 → 단부 하단 (일반형), 또는 저판 상면 평탄 (단축형). */
|
||||
bottomLine: { dM: number; el: number }[];
|
||||
/** 헌치 300 × 300 (위 헌치, 흉벽 배면 모서리). 평면 삼각형과 높이 범위. */
|
||||
haunches: Haunch[];
|
||||
/**
|
||||
* 저판 상면 헌치 (수평, 2026-11-03): between the 저판 상면 and the 날개벽 내측면. The triangle lies in the plane EL–s,
|
||||
* with legs HAUNCH_LEG_M along the 저판 상면 and along the 내측면; it runs along the wing from dMFrom (벽체 배면) to dMTo (뒷굽).
|
||||
*/
|
||||
footingHaunch?: { dMFrom: number; dMTo: number; el: number };
|
||||
/** 수평 단 (QA-12, spec_abut §4.7): 등단면 / 변화단면1 / 변화단면2. 경계는 단부 높이 1.5 m와 교좌면 EL. */
|
||||
bands: WingBand[];
|
||||
}
|
||||
|
||||
export interface WingBand {
|
||||
name: '등단면' | '변화단면1' | '변화단면2' | '단축형 전구간';
|
||||
/** 높이 범위 (EL m). */
|
||||
zTopEl: number;
|
||||
zBottomEl: number;
|
||||
}
|
||||
|
||||
export interface Haunch {
|
||||
kind: 'upper' | 'lower';
|
||||
/** 평면 삼각형 꼭짓점 (E, N): 모서리, 날개벽 쪽 점, 벽체·흉벽 쪽 점. */
|
||||
corners: [AbutPoint, AbutPoint, AbutPoint];
|
||||
zBottomEl: number;
|
||||
zTopEl: number;
|
||||
}
|
||||
|
||||
export const HAUNCH_LEG_M = 0.3;
|
||||
|
||||
export function buildWings(
|
||||
model: AbutModel,
|
||||
members: AbutMembers,
|
||||
forms?: { left: WingForm; right: WingForm },
|
||||
dims?: { left?: Partial<WingDims>; right?: Partial<WingDims> },
|
||||
): WingSolid[] {
|
||||
if (!model.wing) return [];
|
||||
const L = model.length;
|
||||
const wings = forms ?? model.wing.wings;
|
||||
// 좌 = 받침 ① 쪽 끝 (① 에 가까운 축 끝).
|
||||
const firstS = model.bearings[0].s;
|
||||
const leftS = firstS < L / 2 ? 0 : L;
|
||||
const rightS = leftS === 0 ? L : 0;
|
||||
const slope = model.wing.slopePct / 100;
|
||||
const dirRad = (model.wing.lineDeg * Math.PI) / 180;
|
||||
const w = { e: Math.cos(dirRad), n: Math.sin(dirRad) };
|
||||
const ip1 = model.ip1;
|
||||
// 축 끝에서 안쪽 방향: s = 0이면 +u, s = L이면 −u.
|
||||
const at = (s: number) => ({ e: ip1.e + model.u.e * s, n: ip1.n + model.u.n * s });
|
||||
|
||||
// 배면 쪽으로 가는 날개벽 방향의 횡방향 성분 k = |w · (전면 방향)|: 날개벽 거리 d는 횡방향 t = −d·k.
|
||||
const frontVec = { e: model.nLeft.e * model.frontSide, n: model.nLeft.n * model.frontSide };
|
||||
const k = Math.abs(w.e * frontVec.e + w.n * frontVec.n);
|
||||
const footingTop = members.footingTopEl;
|
||||
const heelDistanceM = Math.abs(members.shape.footingBackM) / k;
|
||||
|
||||
const build = (side: 'left' | 'right', sEnd: number, topStart: number, form: Exclude<WingForm, 'none'>): WingSolid => {
|
||||
const a = sEnd === 0 ? 1 : -1;
|
||||
const dm = resolveWingDims(form, dims?.[side], heelDistanceM);
|
||||
const length = dm.lengthMm / 1000;
|
||||
const thick = dm.thicknessMm / 1000;
|
||||
const leg = dm.haunchMm / 1000;
|
||||
const p0 = at(sEnd);
|
||||
const p1 = { e: p0.e + w.e * length, n: p0.n + w.n * length };
|
||||
const inward = { e: model.u.e * a * thick, n: model.u.n * a * thick };
|
||||
const topEnd = topStart + slope * length;
|
||||
const bottomEnd = form === 'general' ? topEnd - dm.endHeightMm / 1000 : footingTop;
|
||||
const topAt = (d: number) => topStart + slope * d;
|
||||
const topLine = [
|
||||
{ dM: 0, el: topStart },
|
||||
{ dM: length, el: topEnd },
|
||||
];
|
||||
const bottomLine =
|
||||
form === 'general' && heelDistanceM < length
|
||||
? [
|
||||
{ dM: 0, el: footingTop },
|
||||
{ dM: heelDistanceM, el: footingTop },
|
||||
{ dM: length, el: bottomEnd },
|
||||
]
|
||||
: [
|
||||
{ dM: 0, el: footingTop },
|
||||
{ dM: length, el: form === 'general' ? bottomEnd : footingTop },
|
||||
];
|
||||
|
||||
// 헌치: 위 헌치는 흉벽 배면(−0.5), 아래 헌치는 벽체 배면(−0.8)의 모서리에 둔다.
|
||||
const pointAt = (d: number) => ({ e: p0.e + w.e * d, n: p0.n + w.n * d });
|
||||
const shift = (q: AbutPoint, v: AbutPoint, f: number) => ({ e: q.e + v.e * f, n: q.n + v.n * f });
|
||||
const bracketTop = topStart - members.shape.bracketHeightM;
|
||||
// The 브라켓 상면 falls along s (the 흉벽 top slopes), so the upper haunch rests on the highest point of its own footprint.
|
||||
const sAt = (q: AbutPoint) => (q.e - model.ip1.e) * model.u.e + (q.n - model.ip1.n) * model.u.n;
|
||||
const backTopAt = (s: number) => members.parapetTopStartEl + ((members.parapetTopEndEl - members.parapetTopStartEl) * s) / model.length - members.shape.bracketHeightM;
|
||||
const haunchAt = (kind: 'upper' | 'lower', t: number): Haunch => {
|
||||
const d = Math.abs(t) / k;
|
||||
// The corner lies on the 날개벽 내측면 (the face between the two wings), WING_THICKNESS_M in from the axis end.
|
||||
const inwardUnit = { e: model.u.e * a, n: model.u.n * a };
|
||||
const c = shift(pointAt(d), inwardUnit, thick);
|
||||
const corners: [AbutPoint, AbutPoint, AbutPoint] = [c, shift(c, w, leg), shift(c, inwardUnit, leg)];
|
||||
return {
|
||||
kind,
|
||||
corners,
|
||||
zBottomEl: kind === 'upper' ? Math.max(...corners.map((q) => backTopAt(sAt(q)))) : footingTop,
|
||||
zTopEl: kind === 'upper' ? topAt(d) : bracketTop,
|
||||
};
|
||||
};
|
||||
// 연직 헌치 (2026-11-03, 내측면 모서리): upper at the 흉벽 배면, lower at the 벽체 배면.
|
||||
const haunches = leg > 0 ? [haunchAt('upper', members.shape.parapetBackM), haunchAt('lower', members.shape.wallBackM)] : [];
|
||||
|
||||
// 저판 상면 헌치 (수평): from the 벽체 배면 to the 뒷굽, on the 저판 상면 next to the 날개벽 내측면.
|
||||
const footingHaunch =
|
||||
leg > 0
|
||||
? {
|
||||
dMFrom: Math.abs(members.shape.wallBackM) / k,
|
||||
dMTo: Math.min(heelDistanceM, length),
|
||||
el: footingTop,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
// 수평 단 (QA-12): 등단면 = 끝 상단 − 1.5 m 위, 변화단면1 = 그 아래 ~ 교좌면 EL, 변화단면2 = 교좌면 EL ~ 저판 상면.
|
||||
// 교좌면 EL은 날개벽 끝에 가장 가까운 받침의 교좌면이다.
|
||||
const nearest = model.bearings.reduce((best, b) => (Math.abs(b.s - sEnd) < Math.abs(best.s - sEnd) ? b : best));
|
||||
const bands: WingBand[] =
|
||||
form === 'general'
|
||||
? [
|
||||
{ name: '등단면', zTopEl: topStart, zBottomEl: bottomEnd },
|
||||
{ name: '변화단면1', zTopEl: bottomEnd, zBottomEl: nearest.seatEl },
|
||||
{ name: '변화단면2', zTopEl: nearest.seatEl, zBottomEl: footingTop },
|
||||
]
|
||||
: [{ name: '단축형 전구간', zTopEl: topStart, zBottomEl: footingTop }];
|
||||
|
||||
return {
|
||||
side,
|
||||
form,
|
||||
sEnd,
|
||||
lengthM: length,
|
||||
thicknessM: thick,
|
||||
endHeightM: dm.endHeightMm / 1000,
|
||||
haunchM: leg,
|
||||
corners: [p0, p1, { e: p1.e + inward.e, n: p1.n + inward.n }, { e: p0.e + inward.e, n: p0.n + inward.n }],
|
||||
topStartEl: topStart,
|
||||
topEndEl: topEnd,
|
||||
bottomEndEl: bottomEnd,
|
||||
heelDistanceM,
|
||||
topLine,
|
||||
bottomLine,
|
||||
haunches,
|
||||
footingHaunch,
|
||||
bands,
|
||||
};
|
||||
};
|
||||
|
||||
const out: WingSolid[] = [];
|
||||
if (wings.left !== 'none') out.push(build('left', leftS, leftS === 0 ? members.parapetTopStartEl : members.parapetTopEndEl, wings.left));
|
||||
if (wings.right !== 'none') out.push(build('right', rightS, rightS === 0 ? members.parapetTopStartEl : members.parapetTopEndEl, wings.right));
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
/**
|
||||
* 거더 짝 받침 · 종단경사 S · 솔플레이트 열 두께 (결정 2026-11-06, RAG/받침방위각_솔플레이트검토.md §3, Q-SH1·4·8·9).
|
||||
* Pure. Used by src/core/sole-plate.ts (pier solplates, 2026-11-06) through src/core/bridge-pairs.ts.
|
||||
*
|
||||
* Frame: (E, N) in m as in the model (x = E, y = N). Azimuth: the model's 진북 방위각 (deg, clockwise from north), i.e. the
|
||||
* workbook CAD angle θ_CAD converted by 90 − θ_CAD. A girder at azimuth θ runs along (sin θ, cos θ).
|
||||
*/
|
||||
|
||||
export interface PairBearing {
|
||||
no: number;
|
||||
x: number;
|
||||
y: number;
|
||||
/** z (EL m). Q-SH5: both ends on the 받침블럭 상면 basis; the pier z definition is still spec Q16. */
|
||||
z: number;
|
||||
azimuthDeg: number;
|
||||
}
|
||||
|
||||
export interface PairStructure {
|
||||
name: string;
|
||||
bearings: PairBearing[];
|
||||
}
|
||||
|
||||
/** Q-SH1 tolerances and the Q-SH4 azimuth check. */
|
||||
export const PAIR_AZIMUTH_TOL_DEG = 0.1;
|
||||
export const PAIR_OFFSET_TOL_M = 0.1;
|
||||
export const AZIMUTH_CHECK_SEC = 10;
|
||||
/** Two candidates nearer than this along the girder are ambiguous (WS04). Less than the row spacing (block 1.2 m). */
|
||||
export const PAIR_ALONG_TOL_M = 0.5;
|
||||
|
||||
export type PairWarning = 'WS01' | 'WS04';
|
||||
|
||||
export interface BearingPair {
|
||||
/** The bearing on structure k and its partner on structure k + 1 (structure order A1, P1 … Pn, A2). */
|
||||
from: { structure: string; no: number };
|
||||
to: { structure: string; no: number };
|
||||
/** Horizontal distance (m), distance off the girder line of `from` (m), azimuth difference |θ_to − θ_from| (deg). */
|
||||
distanceM: number;
|
||||
offsetM: number;
|
||||
azimuthDiffDeg: number;
|
||||
/** Direction from → to as an azimuth (deg), and its difference from each input azimuth (arc seconds). */
|
||||
directionDeg: number;
|
||||
fromCheckSec: number;
|
||||
toCheckSec: number;
|
||||
/** S = (z_to − z_from) / distance × 100 (%), sign along the structure order (− = lower towards the later structure). */
|
||||
slopePct: number;
|
||||
/** The structure on the higher end: the 오르막 쪽 where T1 sits for both bearings. */
|
||||
uphill: string;
|
||||
/** 계산 방위각 (deg, model 진북) of each end from the pair: the girder direction, or on a 현 the arc tangent. */
|
||||
fromCalcDeg: number;
|
||||
toCalcDeg: number;
|
||||
/** girder = the two ends of one girder; chord = A1 ↔ A2 of a bridge loaded without piers (현, chordPairs). */
|
||||
kind: 'girder' | 'chord';
|
||||
warnings: PairWarning[];
|
||||
}
|
||||
|
||||
const DEG = Math.PI / 180;
|
||||
const angleDiffDeg = (a: number, b: number) => Math.abs(((a - b + 540) % 360) - 180);
|
||||
/** a − b wrapped to (−180, 180]. */
|
||||
const signedDiff = (a: number, b: number) => ((a - b + 540) % 360) - 180;
|
||||
|
||||
/**
|
||||
* Girder pairs over the bridge (Q-SH1, Q-SH4). For each bearing b the candidates on the other structures lie
|
||||
* ahead along b's azimuth, within 0.1° of its azimuth and 0.1 m of its girder line. The pair is the first one reached along
|
||||
* the girder: on a straight bridge the next span's bearing (후열) lies on the same line further on, so "closest to the line"
|
||||
* alone would be a tie (2026-11-06 review). WS04 when another candidate is within 0.5 m of it along the girder.
|
||||
* WS01 when either input azimuth is more than 10″ from the pair direction.
|
||||
*/
|
||||
export function girderPairs(structures: PairStructure[]): BearingPair[] {
|
||||
const pairs: BearingPair[] = [];
|
||||
// Every other structure is searched, not only the next one in the list: with two lines (광주 · 완도) the list order
|
||||
// A1-광주, A1-완도, P1-완도 … mixes the lines, and the geometry alone tells which bearing ends the girder (2026-11-06).
|
||||
for (const here of structures) {
|
||||
const others = structures.filter((s) => s !== here).flatMap((s) => s.bearings.map((c) => ({ ...c, structure: s.name })));
|
||||
for (const b of here.bearings) {
|
||||
const u = { e: Math.sin(b.azimuthDeg * DEG), n: Math.cos(b.azimuthDeg * DEG) };
|
||||
const candidates = others.map((c) => {
|
||||
const d = { e: c.x - b.x, n: c.y - b.y };
|
||||
return { c, along: d.e * u.e + d.n * u.n, offset: Math.abs(d.e * u.n - d.n * u.e), dAz: angleDiffDeg(c.azimuthDeg, b.azimuthDeg), d };
|
||||
}).filter((t) => t.along > 0 && t.dAz <= PAIR_AZIMUTH_TOL_DEG && t.offset <= PAIR_OFFSET_TOL_M)
|
||||
.sort((p, q) => p.along - q.along);
|
||||
if (!candidates.length) continue;
|
||||
// The nearest along the girder, and among those about as near (ambiguous, WS04) the one closest to the line.
|
||||
const near = candidates.filter((t) => t.along - candidates[0].along < PAIR_ALONG_TOL_M).sort((p, q) => p.offset - q.offset);
|
||||
const best = near[0];
|
||||
const ambiguous = near.length > 1;
|
||||
const distanceM = Math.hypot(best.d.e, best.d.n);
|
||||
const directionDeg = (Math.atan2(best.d.e, best.d.n) / DEG + 360) % 360;
|
||||
const fromCheckSec = angleDiffDeg(directionDeg, b.azimuthDeg) * 3600;
|
||||
const toCheckSec = angleDiffDeg(directionDeg, best.c.azimuthDeg) * 3600;
|
||||
const slopePct = ((best.c.z - b.z) / distanceM) * 100;
|
||||
const warnings: PairWarning[] = [];
|
||||
if (fromCheckSec > AZIMUTH_CHECK_SEC || toCheckSec > AZIMUTH_CHECK_SEC) warnings.push('WS01');
|
||||
if (ambiguous) warnings.push('WS04');
|
||||
pairs.push({
|
||||
from: { structure: here.name, no: b.no }, to: { structure: best.c.structure, no: best.c.no },
|
||||
distanceM, offsetM: best.offset, azimuthDiffDeg: best.dAz, directionDeg, fromCheckSec, toCheckSec,
|
||||
slopePct, uphill: best.c.z > b.z ? best.c.structure : here.name, warnings, kind: 'girder', fromCalcDeg: directionDeg, toCalcDeg: directionDeg,
|
||||
});
|
||||
}
|
||||
}
|
||||
return pairs;
|
||||
}
|
||||
|
||||
/**
|
||||
* A1 ↔ A2 pairs when no pier is loaded (교대만, 검토 2026-11-06). Over several spans the azimuths of the two ends differ
|
||||
* (곡선, 운곡교 8°), so the girder test fails; the chord from b to c then runs along the mean of the two azimuths (exact on a
|
||||
* circular arc). The partner is the end bearing whose chord is within 0.1° of that mean (WS04 when two are), and S is the
|
||||
* whole-bridge average Δz / chord. The check value is chord − mean azimuth, for reference only (no WS01).
|
||||
*/
|
||||
export function chordPairs(starts: PairStructure[], ends: PairStructure[]): BearingPair[] {
|
||||
const pairs: BearingPair[] = [];
|
||||
for (const here of starts) for (const b of here.bearings) {
|
||||
const candidates = ends.flatMap((s) => s.bearings.map((c) => {
|
||||
const d = { e: c.x - b.x, n: c.y - b.y };
|
||||
const directionDeg = (Math.atan2(d.e, d.n) / DEG + 360) % 360;
|
||||
const mean = (b.azimuthDeg + c.azimuthDeg + (Math.abs(b.azimuthDeg - c.azimuthDeg) > 180 ? 360 : 0)) / 2 % 360;
|
||||
return { c, structure: s.name, d, directionDeg, dev: angleDiffDeg(directionDeg, mean) };
|
||||
})).filter((t) => t.dev <= PAIR_AZIMUTH_TOL_DEG).sort((p, q) => p.dev - q.dev);
|
||||
if (!candidates.length) continue;
|
||||
const best = candidates[0];
|
||||
const distanceM = Math.hypot(best.d.e, best.d.n);
|
||||
pairs.push({
|
||||
from: { structure: here.name, no: b.no }, to: { structure: best.structure, no: best.c.no },
|
||||
distanceM, offsetM: 0, azimuthDiffDeg: angleDiffDeg(best.c.azimuthDeg, b.azimuthDeg), directionDeg: best.directionDeg,
|
||||
fromCheckSec: best.dev * 3600, toCheckSec: best.dev * 3600, slopePct: ((best.c.z - b.z) / distanceM) * 100,
|
||||
uphill: best.c.z > b.z ? best.structure : here.name, warnings: candidates.length > 1 ? ['WS04'] : [], kind: 'chord',
|
||||
// Circular arc: the tangent at each end is the chord turned by half the azimuth change.
|
||||
fromCalcDeg: (best.directionDeg + signedDiff(b.azimuthDeg, best.c.azimuthDeg) / 2 + 360) % 360,
|
||||
toCalcDeg: (best.directionDeg + signedDiff(best.c.azimuthDeg, b.azimuthDeg) / 2 + 360) % 360,
|
||||
});
|
||||
}
|
||||
return pairs;
|
||||
}
|
||||
|
||||
/** The pair a bearing belongs to, on either end; undefined when it has none (then WS03 with a fallback S, not done here). */
|
||||
export function pairOf(pairs: BearingPair[], structure: string, no: number): BearingPair | undefined {
|
||||
return pairs.find((p) => (p.from.structure === structure && p.from.no === no) || (p.to.structure === structure && p.to.no === no));
|
||||
}
|
||||
|
||||
/** Smallest allowed thin-end thickness T_min (mm, Q-SH3). */
|
||||
export const SOLE_T_MIN_MM = 22;
|
||||
|
||||
/** Thickness one bearing needs: T_req = T_min + L·|S|/2 (L mm, S %). */
|
||||
export function soleRequiredT(lengthMm: number, slopePct: number, tMinMm: number = SOLE_T_MIN_MM): number {
|
||||
return tMinMm + (lengthMm * Math.abs(slopePct)) / 200;
|
||||
}
|
||||
|
||||
/**
|
||||
* Row thickness and end thicknesses (Q-SH8·9): T_row = max(T_min, ⌈max T_req⌉) over the row; per bearing
|
||||
* T1 = round(T_row + L·|S|/2) on the 오르막 쪽 and T2 = round(T_row − L·|S|/2) (≥ T_min by construction).
|
||||
*/
|
||||
export function soleRowThickness(items: { lengthMm: number; slopePct: number }[], tMinMm: number = SOLE_T_MIN_MM) {
|
||||
const T = Math.max(tMinMm, Math.ceil(Math.max(...items.map((i) => soleRequiredT(i.lengthMm, i.slopePct, tMinMm))) - 1e-9));
|
||||
return {
|
||||
T,
|
||||
ends: items.map((i) => {
|
||||
const half = (i.lengthMm * Math.abs(i.slopePct)) / 200;
|
||||
return { T1: Math.round(T + half), T2: Math.round(T - half) };
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -1,148 +0,0 @@
|
||||
import type { PierInput } from './types';
|
||||
import type { PierModel } from './geometry';
|
||||
|
||||
/**
|
||||
* 교량받침 reference tables from the P1 상세도 (도면값, 읽기 전용, 2026-10-23). They are not used in the model:
|
||||
* the model keeps the imported values (Q16). EL.X is the drawing's bearing level; E = h1 + h2 + h3 + h4.
|
||||
*/
|
||||
export interface BearingRefRow {
|
||||
no: number;
|
||||
row: '전열' | '후열';
|
||||
/** EL.X (m). */
|
||||
elx: number;
|
||||
/** 블럭 높이 h1 (mm, 도면). */
|
||||
h1: number;
|
||||
/** 모르타르 h2 (mm). */
|
||||
h2: number;
|
||||
/** 받침 h3 (mm). */
|
||||
h3: number;
|
||||
/** 솔플레이트 h4 (mm). */
|
||||
h4: number;
|
||||
/** E (mm, 도면). */
|
||||
E: number;
|
||||
A: number;
|
||||
B: number;
|
||||
C1: number;
|
||||
C2: number | null;
|
||||
C3: number | null;
|
||||
C4: number | null;
|
||||
C5: number | null;
|
||||
C6: number | null;
|
||||
S1: number;
|
||||
}
|
||||
|
||||
export interface SolplateRefRow {
|
||||
no: number;
|
||||
/** 기울기 S (%), negative as drawn. */
|
||||
s: number;
|
||||
L: number;
|
||||
W: number;
|
||||
/** 중심 두께 T (mm). The solplate tapers along the bridge axis. */
|
||||
T: number;
|
||||
/** 두께 at the 교각축 직각 (−) side (mm). */
|
||||
T1: number;
|
||||
/** 두께 at the 교각축 직각 (+) side (mm). */
|
||||
T2: number;
|
||||
}
|
||||
|
||||
const FRONT = { h1: 164, h2: 50, h3: 181, h4: 30, E: 425 };
|
||||
const BACK = { h1: 139, h2: 50, h3: 181, h4: 30, E: 400 };
|
||||
|
||||
export const BEARING_ELEVATION_REF: BearingRefRow[] = [
|
||||
{ no: 1, row: '전열', elx: 43.625, ...FRONT, A: 700, B: 640, C1: 600, C2: 1298, C3: 2500, C4: 600, C5: 600, C6: 1200, S1: 852 },
|
||||
{ no: 2, row: '전열', elx: 43.697, ...FRONT, A: 700, B: 640, C1: 600, C2: 1298, C3: 2500, C4: 600, C5: 600, C6: 1200, S1: 852 },
|
||||
{ no: 3, row: '전열', elx: 43.768, ...FRONT, A: 700, B: 790, C1: 600, C2: 1298, C3: 2500, C4: 600, C5: 600, C6: 1200, S1: 852 },
|
||||
{ no: 4, row: '전열', elx: 43.840, ...FRONT, A: 700, B: 640, C1: 600, C2: 1298, C3: 2500, C4: 600, C5: 600, C6: 1200, S1: 852 },
|
||||
{ no: 5, row: '전열', elx: 43.912, ...FRONT, A: 700, B: 640, C1: 600, C2: 1298, C3: 2500, C4: 600, C5: 600, C6: 1200, S1: 852 },
|
||||
{ no: 6, row: '후열', elx: 43.600, ...BACK, A: 700, B: 540, C1: 602, C2: null, C3: null, C4: null, C5: null, C6: null, S1: 950 },
|
||||
{ no: 7, row: '후열', elx: 43.672, ...BACK, A: 700, B: 540, C1: 602, C2: null, C3: null, C4: null, C5: null, C6: null, S1: 950 },
|
||||
{ no: 8, row: '후열', elx: 43.744, ...BACK, A: 700, B: 650, C1: 602, C2: null, C3: null, C4: null, C5: null, C6: null, S1: 950 },
|
||||
{ no: 9, row: '후열', elx: 43.816, ...BACK, A: 700, B: 540, C1: 602, C2: null, C3: null, C4: null, C5: null, C6: null, S1: 950 },
|
||||
{ no: 10, row: '후열', elx: 43.887, ...BACK, A: 700, B: 540, C1: 602, C2: null, C3: null, C4: null, C5: null, C6: null, S1: 950 },
|
||||
];
|
||||
|
||||
export const SOLPLATE_REF: SolplateRefRow[] = [
|
||||
{ no: 1, s: -1.910, L: 800, W: 740, T: 30, T1: 38, T2: 22 },
|
||||
{ no: 2, s: -1.907, L: 800, W: 740, T: 30, T1: 38, T2: 22 },
|
||||
{ no: 3, s: -1.905, L: 800, W: 890, T: 30, T1: 38, T2: 22 },
|
||||
{ no: 4, s: -1.903, L: 800, W: 740, T: 30, T1: 38, T2: 22 },
|
||||
{ no: 5, s: -1.901, L: 800, W: 740, T: 30, T1: 38, T2: 22 },
|
||||
{ no: 6, s: -1.910, L: 800, W: 640, T: 30, T1: 38, T2: 22 },
|
||||
{ no: 7, s: -1.907, L: 800, W: 640, T: 30, T1: 38, T2: 22 },
|
||||
{ no: 8, s: -1.905, L: 800, W: 750, T: 30, T1: 38, T2: 22 },
|
||||
{ no: 9, s: -1.903, L: 800, W: 640, T: 30, T1: 38, T2: 22 },
|
||||
{ no: 10, s: -1.901, L: 800, W: 640, T: 30, T1: 38, T2: 22 },
|
||||
];
|
||||
|
||||
/** 중심 두께 T of the 공통 규칙 (30 mm). T1 = T + L·|S|/2 (−측), T2 = T − L·|S|/2 (+측), so T1 + T2 = 2T. */
|
||||
export const SOLPLATE_T_RULE = 30;
|
||||
|
||||
/** T1 / T2 by the drawing's rule: T ± L·|S|/2, rounded to mm (S in %). */
|
||||
export function solplateTs(T: number, L: number, s: number): { T1: number; T2: number } {
|
||||
const half = (L * Math.abs(s)) / 200;
|
||||
return { T1: Math.round(T + half), T2: Math.round(T - half) };
|
||||
}
|
||||
|
||||
/** 공통 규칙 (2단계, 승인 2026-10-23): inputs that drive E, L, W, T1, T2. Defaults are the drawing's values. */
|
||||
export interface BearingRules {
|
||||
/** 모르타르 h2 (mm). */
|
||||
h2: number;
|
||||
/** 받침 높이 h3 (mm). */
|
||||
h3: number;
|
||||
/** 솔플레이트 중심 두께 T (mm). */
|
||||
T: number;
|
||||
/** 솔플 여유 (mm): L = A + margin, W = B + margin. */
|
||||
margin: number;
|
||||
}
|
||||
|
||||
export const BEARING_RULES_DEFAULT: BearingRules = { h2: 50, h3: 181, T: SOLPLATE_T_RULE, margin: 100 };
|
||||
|
||||
/** Values of one bearing with the 공통 규칙 applied: E = h1 + h2 + h3 + h4, L = A + margin, W = B + margin, T1/T2 from T. */
|
||||
export function applyBearingRules(row: BearingRefRow, rules: BearingRules): { E: number; L: number; W: number; T1: number; T2: number } {
|
||||
const sp = SOLPLATE_REF[row.no - 1];
|
||||
const L = row.A + rules.margin;
|
||||
const { T1, T2 } = solplateTs(rules.T, L, sp.s);
|
||||
return { E: row.h1 + rules.h2 + rules.h3 + row.h4, L, W: row.B + rules.margin, T1, T2 };
|
||||
}
|
||||
|
||||
/** 받침 기준 (Q16, 승인 2026-10-23): 'excel' = 엑셀 z (기본), 'drawing' = 도면 EL.X. Project-wide. */
|
||||
export type BearingBasis = 'excel' | 'drawing';
|
||||
|
||||
/** One bearing's drawing values entered on ② (mm for h1, h2; m for EL.X). */
|
||||
export interface DrawingBearing {
|
||||
no: number;
|
||||
elx: number;
|
||||
h1: number;
|
||||
h2: number;
|
||||
/** 받침 평면 A, B (mm) and solplate slope S (%), when read from the drawing (승인 2026-10-23). */
|
||||
A?: number;
|
||||
B?: number;
|
||||
S?: number;
|
||||
}
|
||||
|
||||
/** Tolerance for the coping top check against the drawing (승인 2026-10-23: 5 mm). */
|
||||
export const DRAWING_TOL_MM = 5;
|
||||
|
||||
/** The input with each bearing's z replaced by the drawing EL.X, for bearings that have a drawing value. */
|
||||
export function withDrawingBasis(input: PierInput, drawing: DrawingBearing[]): PierInput {
|
||||
const byNo = new Map(drawing.map((d) => [d.no, d]));
|
||||
return {
|
||||
...input,
|
||||
bearings: input.bearings.map((b) => {
|
||||
const d = byNo.get(b.no);
|
||||
return d ? { ...b, z: d.elx } : b;
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
/** Coping top implied by each drawing row (EL.X − h1 − h2) against the model's coping top; differences in mm. */
|
||||
export function drawingCopingCheck(model: PierModel, drawing: DrawingBearing[]): { no: number; diffMm: number; ok: boolean }[] {
|
||||
const front = model.blocks.filter((b) => b.row === '전열');
|
||||
const back = model.blocks.filter((b) => b.row === '후열');
|
||||
return drawing.map((d) => {
|
||||
const implied = d.elx - (d.h1 + d.h2) / 1000;
|
||||
const modelTop = d.no <= 5 ? model.segments[d.no - 1]?.top : back[d.no - 6]?.bottom;
|
||||
const diffMm = modelTop === undefined ? NaN : Math.round((modelTop - implied) * 1000 * 10) / 10;
|
||||
return { no: d.no, diffMm, ok: Number.isFinite(diffMm) && Math.abs(diffMm) <= DRAWING_TOL_MM };
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
/**
|
||||
* 교량받침 표 (2026-11-03, 모델값). Pure: the ELEVATION TABLE and the 솔플레이트 TABLE of a pier, every value from the
|
||||
* model. No drawing values are used (C안). Lengths in mm; EL.X in m.
|
||||
*
|
||||
* - EL.X: the mortar top = the Excel z (padTop, Q16).
|
||||
* - h1: block height (block top − block bottom). h2: mortar (pad top − block top). h3: bearing body (solplate rule).
|
||||
* - h4: solplate centre thickness T. E = h1 + h2 + h3 + h4.
|
||||
* - C1: bearing centre to the outer block edge along 교축. C2: between the two bearings of a pair, along 교축 (on the
|
||||
* front row). C3: block span of the pair along 교축 (front row). C4·C5: 교각축 margins (bearing-layout). C6: block depth
|
||||
* along 교각축.
|
||||
* - S1·S2: block rebar bars from the model (bearing-block): count and bar length. For 일체 the pair's bars sit on the front row.
|
||||
*/
|
||||
import type { SlopeSource } from './sole-plate';
|
||||
import type { PierModel } from './geometry';
|
||||
import { bearingEdgeDistances, pairEccentricities } from './bearing-layout';
|
||||
import { INTEGRATED_BLOCK } from './integrated-block';
|
||||
import { bearingBlockRebar, type BarRow } from './rebar/bearing-block';
|
||||
|
||||
/** Depth of a 분리 bearing block along 교각축 (mm), the same value as the block rebar. */
|
||||
export const BLOCK_DEPTH_MM = 1200;
|
||||
|
||||
export interface ElevationRow {
|
||||
no: number;
|
||||
row: '전열' | '후열';
|
||||
elx: number;
|
||||
/** 교좌면 EL (m): the block bottom. */
|
||||
seatEl: number;
|
||||
h1: number;
|
||||
h2: number;
|
||||
h3: number;
|
||||
h4: number;
|
||||
E: number;
|
||||
C1: number;
|
||||
/** Front row only; null on the back row. */
|
||||
C2: number | null;
|
||||
C3: number | null;
|
||||
C4: number;
|
||||
C5: number;
|
||||
C6: number;
|
||||
S1: BarSummary | null;
|
||||
S2: BarSummary | null;
|
||||
}
|
||||
|
||||
export interface BarSummary {
|
||||
count: number;
|
||||
length: number;
|
||||
}
|
||||
|
||||
export interface SolplateRow {
|
||||
no: number;
|
||||
/** 기울기 S (%), negative as drawn. */
|
||||
S: number;
|
||||
L: number;
|
||||
W: number;
|
||||
T: number;
|
||||
T1: number;
|
||||
T2: number;
|
||||
/** Check W08: T1 − T2 against L·|S|/100 (mm), within 1 mm (T1 and T2 are rounded to whole mm). */
|
||||
checkLhs: number;
|
||||
checkRhs: number;
|
||||
ok: boolean;
|
||||
/** 짝 받침 (같은 거더), e.g. "A1-①"; absent when none (2026-11-06). */
|
||||
pair?: string;
|
||||
/** 방위각 검사 (″): input azimuth against the pair direction; absent without a pair. */
|
||||
azimuthSec?: number;
|
||||
pairKind?: 'girder' | 'chord';
|
||||
/** 방위각 입력 (엑셀) and 계산 (짝 받침), deg model 진북; 계산 absent without a pair. */
|
||||
azimuthInputDeg: number;
|
||||
azimuthCalcDeg?: number;
|
||||
/** Where S comes from: 계산 (짝) / 입력 (종단경사) / 가정 (다른 열) / 수정됨 / 없음. */
|
||||
source: SlopeSource;
|
||||
/** T typed by the user (수정됨) rather than automatic. */
|
||||
tTyped: boolean;
|
||||
/** 받침 평면 A × B (mm). */
|
||||
A: number;
|
||||
B: number;
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
const CIRCLED_NO = ['①', '②', '③', '④', '⑤', '⑥', '⑦', '⑧', '⑨', '⑩', '⑪', '⑫', '⑬', '⑭', '⑮', '⑯', '⑰', '⑱', '⑲', '⑳'];
|
||||
|
||||
function barSummary(bars: BarRow[], name: 'S1' | 'S2'): BarSummary | null {
|
||||
const hit = bars.filter((x) => x.name === name);
|
||||
if (hit.length === 0) return null;
|
||||
return { count: hit.reduce((sum, x) => sum + x.count, 0), length: hit[0].length };
|
||||
}
|
||||
|
||||
export function elevationRows(m: PierModel): ElevationRow[] {
|
||||
const edges = bearingEdgeDistances(m);
|
||||
const rebar = bearingBlockRebar(m);
|
||||
const pairs = pairEccentricities(m.blocks);
|
||||
return [...m.blocks]
|
||||
.sort((a, b) => a.no - b.no)
|
||||
.map((b) => {
|
||||
const plan = b.plan;
|
||||
const h1 = Math.round((b.top - b.bottom) * 1000);
|
||||
const h2 = Math.round((b.padTop - b.top) * 1000);
|
||||
const edge = edges.find((e) => e.no === b.no)!;
|
||||
const ib = m.integratedBlocks.find((x) => (b.row === '전열' ? x.frontNo : x.backNo) === b.no);
|
||||
// C1: bearing centre to the outer edge of its block along 교축 (1200 block centred on the bearing, or the 2500 block).
|
||||
const C1 = ib ? Math.round(Math.abs(b.n - (b.n <= ib.n ? ib.n - INTEGRATED_BLOCK.length / 2000 : ib.n + INTEGRATED_BLOCK.length / 2000)) * 1000) : 600;
|
||||
const pair = pairs.find((p) => (b.row === '전열' ? p.frontNo : p.backNo) === b.no);
|
||||
const front = b.row === '전열';
|
||||
const C2 = front && pair ? Math.round(Math.abs(m.blocks.find((x) => x.no === pair.backNo && x.row === '후열')!.n - b.n) * 1000) : null;
|
||||
const C3 = front && pair ? (ib ? INTEGRATED_BLOCK.length : (C2 ?? 0) + BLOCK_DEPTH_MM) : null;
|
||||
const bars = rebar.find((r) => r.no === b.no && r.row === b.row)?.bars ?? [];
|
||||
return {
|
||||
no: b.no,
|
||||
row: b.row,
|
||||
elx: b.padTop,
|
||||
seatEl: b.bottom,
|
||||
h1,
|
||||
h2,
|
||||
h3: plan.h3,
|
||||
h4: plan.T,
|
||||
E: h1 + h2 + plan.h3 + plan.T,
|
||||
C1,
|
||||
C2,
|
||||
C3,
|
||||
C4: Math.round(edge.leftMm),
|
||||
C5: Math.round(edge.rightMm),
|
||||
C6: BLOCK_DEPTH_MM,
|
||||
S1: barSummary(bars, 'S1'),
|
||||
S2: barSummary(bars, 'S2'),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function solplateRows(m: PierModel): SolplateRow[] {
|
||||
return [...m.blocks]
|
||||
.sort((a, b) => a.no - b.no)
|
||||
.map((b) => {
|
||||
const p = b.plan;
|
||||
const s = m.soles.find((x) => x.no === b.no)!;
|
||||
const checkLhs = p.T1 - p.T2;
|
||||
const checkRhs = (p.L * Math.abs(p.slopePct)) / 100;
|
||||
return {
|
||||
pair: s.pair ? `${s.pair.structure}-${CIRCLED_NO[s.pair.no - 1] ?? s.pair.no}${s.pairKind === 'chord' ? ' (현)' : ''}` : undefined, pairKind: s.pairKind,
|
||||
azimuthSec: s.azimuthCheckSec, azimuthInputDeg: s.azimuthInputDeg, azimuthCalcDeg: s.azimuthCalcDeg, source: s.slopeSource, tTyped: s.tTyped, A: s.aMm, B: s.bMm, warnings: s.warnings,
|
||||
no: b.no,
|
||||
S: p.slopePct,
|
||||
L: p.L,
|
||||
W: p.W,
|
||||
T: p.T,
|
||||
T1: p.T1,
|
||||
T2: p.T2,
|
||||
checkLhs,
|
||||
checkRhs,
|
||||
ok: Math.abs(checkLhs - checkRhs) <= 1 + 1e-9,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* S1·S2 of one pair along 교축 (mm, 2026-11-03): S1 = bearing centre to the coping edge on each outer side, S2 = between
|
||||
* the two bearing centres. The coping is centred on the axis with width B (model copingB), so S1 + S2 + S1 = B.
|
||||
*/
|
||||
export interface PairCopingSpans {
|
||||
S1Low: number;
|
||||
S2: number;
|
||||
S1High: number;
|
||||
copingWidth: number;
|
||||
}
|
||||
|
||||
export function pairCopingSpans(m: PierModel, pairIndex: number): PairCopingSpans | null {
|
||||
const pair = pairEccentricities(m.blocks)[pairIndex];
|
||||
if (!pair) return null;
|
||||
const f = m.blocks.find((b) => b.no === pair.frontNo && b.row === '전열')!;
|
||||
const k = m.blocks.find((b) => b.no === pair.backNo && b.row === '후열')!;
|
||||
const lo = Math.min(f.n, k.n);
|
||||
const hi = Math.max(f.n, k.n);
|
||||
const half = m.copingB / 2;
|
||||
return {
|
||||
S1Low: Math.round((lo + half) * 1000),
|
||||
S2: Math.round((hi - lo) * 1000),
|
||||
S1High: Math.round((half - hi) * 1000),
|
||||
copingWidth: Math.round(m.copingB * 1000),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Girder pairs over the whole bridge (A1, P1 … Pn, A2 — structureOrder) for the solplate slope S (결정 2026-11-06, Q-SH1).
|
||||
* Pure. Bearing coordinates and z cannot be edited on the screens, so the pairs change only when structures are loaded.
|
||||
*/
|
||||
import type { AbutRecord } from './abut/parse';
|
||||
import { structureOrder } from './abut/order';
|
||||
import { type BearingPair, chordPairs, girderPairs, type PairStructure } from './bearing-pair';
|
||||
import type { PierInput } from './types';
|
||||
|
||||
export function bridgePairs(piers: Pick<PierInput, 'name' | 'bearings'>[], abuts: AbutRecord[]): BearingPair[] {
|
||||
const byPier = new Map(piers.map((p) => [p.name, p]));
|
||||
const byAbut = new Map(abuts.map((a) => [a.name, a]));
|
||||
const structures: PairStructure[] = structureOrder(abuts, piers.map((p) => p.name)).map((ref) => {
|
||||
if (ref.kind === 'pier') {
|
||||
const p = byPier.get(ref.name)!;
|
||||
return { name: p.name, bearings: p.bearings.filter((b) => Number.isFinite(b.azimuthDeg)).map((b) => ({ no: b.no, x: b.x, y: b.y, z: b.z, azimuthDeg: b.azimuthDeg })) };
|
||||
}
|
||||
const a = byAbut.get(ref.name)!;
|
||||
return { name: a.name, bearings: a.bearings.filter((b) => b.azimuthDeg !== undefined).map((b) => ({ no: b.no, x: b.pos.e, y: b.pos.n, z: b.z, azimuthDeg: b.azimuthDeg! })) };
|
||||
});
|
||||
const pairs = girderPairs(structures);
|
||||
// 교대만 (no pier): A1 ↔ A2 still pair along the chord (검토 2026-11-06); a single straight span already paired above.
|
||||
if (piers.length === 0) {
|
||||
const paired = new Set(pairs.flatMap((p) => [`${p.from.structure}#${p.from.no}`, `${p.to.structure}#${p.to.no}`]));
|
||||
const kind = new Map(abuts.map((a) => [a.name, a.type]));
|
||||
const free = (type: string) => structures.filter((s) => kind.get(s.name) === type)
|
||||
.map((s) => ({ ...s, bearings: s.bearings.filter((b) => !paired.has(`${s.name}#${b.no}`)) }));
|
||||
pairs.push(...chordPairs(free('A1'), free('A2')));
|
||||
}
|
||||
return pairs;
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import type { PierInput } from './types';
|
||||
|
||||
/** EL in metres, lengths in mm. User image and clarifications 2026-11-05; casing default and rock socket per 현장타설말뚝철근.md (2026-11-06). */
|
||||
export interface CastInPlaceShapeSettings {
|
||||
betaLengthMm?: number;
|
||||
weatheredRockEl?: number;
|
||||
bedrockEl?: number;
|
||||
casingOn?: boolean;
|
||||
casingTopEl?: number;
|
||||
casingBottomEl?: number;
|
||||
clayBottomEl?: number;
|
||||
/** 암반 근입부 지름 (mm, 희생강관 아래). Absent = D − 150 with a casing, D without (현장타설말뚝철근.md §1.1, Q-PL3). */
|
||||
socketDiameterMm?: number;
|
||||
}
|
||||
|
||||
/** Rock-socket diameter (mm): the input, else D − 150 when the casing is on (default on, spec §3 2026-11-06), else D. */
|
||||
export function castSocketDiameterMm(input: Pick<PierInput, 'castInPlaceShape' | 'columnDiameterMm'>): number {
|
||||
const s = input.castInPlaceShape ?? {};
|
||||
const d = input.columnDiameterMm ?? 2000;
|
||||
return s.socketDiameterMm ?? ((s.casingOn ?? true) ? d - 150 : d);
|
||||
}
|
||||
|
||||
export function castShape(input: Pick<PierInput, 'groundEl' | 'pileLengthMm' | 'castInPlaceShape' | 'columnDiameterMm'>) {
|
||||
const s = input.castInPlaceShape ?? {};
|
||||
const pileBottomEl = input.groundEl - input.pileLengthMm / 1000;
|
||||
const bedrockEl = s.bedrockEl ?? pileBottomEl + 1;
|
||||
const betaLengthMm = s.betaLengthMm ?? 9000;
|
||||
return {
|
||||
pileTopEl: input.groundEl, pileBottomEl,
|
||||
betaLengthMm, betaEl: input.groundEl - betaLengthMm / 1000,
|
||||
weatheredRockEl: s.weatheredRockEl ?? pileBottomEl + 5,
|
||||
bedrockEl,
|
||||
// 희생강관: 적용, 상단 지반고 + 2500 (그림 값, 엑셀 문구 + 3000 — Q-PL4 가정), 하단 = 기반암 EL.
|
||||
casingOn: s.casingOn ?? true,
|
||||
casingTopEl: s.casingTopEl ?? input.groundEl + 2.5,
|
||||
casingBottomEl: s.casingBottomEl ?? bedrockEl,
|
||||
clayBottomEl: s.clayBottomEl,
|
||||
socketDiameterMm: castSocketDiameterMm(input),
|
||||
};
|
||||
}
|
||||
|
||||
export function castShapeIssues(value: unknown): string[] {
|
||||
if (value === undefined) return [];
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return ['현장타설 형상 입력 형식이 맞지 않습니다.'];
|
||||
const out: string[] = [];
|
||||
for (const [key, v] of Object.entries(value)) {
|
||||
if (v === undefined) continue;
|
||||
if (key === 'casingOn') { if (typeof v !== 'boolean') out.push('희생강관 적용 여부가 맞지 않습니다.'); }
|
||||
else if (!['betaLengthMm', 'weatheredRockEl', 'bedrockEl', 'casingTopEl', 'casingBottomEl', 'clayBottomEl', 'socketDiameterMm'].includes(key) || typeof v !== 'number' || !Number.isFinite(v) || (key === 'betaLengthMm' || key === 'socketDiameterMm') && v <= 0) out.push(`현장타설 형상 ${key} 값이 맞지 않습니다.`);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** DXF note 4. Do not substitute weathered-rock EL for the independent clay-layer EL. */
|
||||
export function castLowerZone(input: Pick<PierInput, 'groundEl' | 'pileLengthMm' | 'castInPlaceShape' | 'columnDiameterMm'>) {
|
||||
const s = castShape(input);
|
||||
if (s.clayBottomEl === undefined) return null;
|
||||
const d = (input.columnDiameterMm ?? 2000) / 1000;
|
||||
const a = s.betaEl - 3 * d;
|
||||
const b = s.clayBottomEl + d;
|
||||
return { bottomEl: a, topEl: b, valid: a < b };
|
||||
}
|
||||
+3
-1
@@ -23,7 +23,7 @@ export const EDITABLE_FIELDS = [
|
||||
] as const;
|
||||
|
||||
/** Inputs tracked for overrides and pending changes: the numeric cards plus 열별 본수 (an array, compared by value). */
|
||||
export const TRACKED_KEYS = [...EDITABLE_FIELDS.map((f) => f.key), 'pileRows', 'groundPosition', 'groundWaterOn', 'groundWaterEl', 'floodLevelEl', 'scourDepthM', 'highWaterEl', 'lowWaterEl', 'strataRows', 'bearingType', 'bearingLayout', 'blockRebar', 'copingRebar', 'topSplice', 'columnRebar', 'footingRebar'] as const;
|
||||
export const TRACKED_KEYS = [...EDITABLE_FIELDS.map((f) => f.key), 'pileRows', 'groundPosition', 'groundWaterOn', 'groundWaterEl', 'floodLevelEl', 'scourDepthM', 'highWaterEl', 'lowWaterEl', 'strataRows', 'bearingType', 'bearingLayout', 'blockRebar', 'copingRebar', 'topSplice', 'columnRebar', 'footingRebar', 'castInPlaceRebar', 'castInPlaceShape'] as const;
|
||||
|
||||
export type EditableKey = (typeof TRACKED_KEYS)[number];
|
||||
|
||||
@@ -59,6 +59,8 @@ export const FIELD_LABELS: Record<EditableKey, string> = {
|
||||
columnRebar: '기둥 배근 입력',
|
||||
footingRebar: '확대기초 배근 입력',
|
||||
strataRows: '지층 행',
|
||||
castInPlaceRebar: '현장타설 기둥·말뚝 배근 입력',
|
||||
castInPlaceShape: '현장타설 암반·고정점·희생강관 입력',
|
||||
};
|
||||
|
||||
/** Keys whose values differ between two inputs. Arrays are compared by value. */
|
||||
|
||||
@@ -20,8 +20,8 @@ export const SIDE_COVER_M = 0.127;
|
||||
/**
|
||||
* Side-row levels (EL m) of C12 / C13 (코핑철근.md §6.1). z_0 = Level underside + 127 (the lowest bottom bar's Level
|
||||
* height, 41.038 on P1), z_k = z_0 + k·250. The split is by height band:
|
||||
* C12 rows lie in the taper band (Level underside … end underside, height h2);
|
||||
* C13 rows lie in the end band (end underside … top, height h1).
|
||||
* C13 rows (하단 변화구간) lie in the taper band (Level underside … end underside, height h2);
|
||||
* C12 rows (상단) lie in the end band (end underside … top, height h1). (엑셀 Pier B357–G358, 2026-11-06 결정)
|
||||
* zBreak = end underside + 127 is where the taper line starts at the end face (P1: 42.538).
|
||||
*/
|
||||
export function sideRowLevels(levelBottom: number, endBottom: number, top?: number): { zBottom: number; zBreak: number; zTop?: number } {
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
/**
|
||||
* 도면 불러오기 (승인 2026-10-23): reads the bearing drawing values from a sheet (xlsx or csv rows). Pure: the caller
|
||||
* passes the rows (arrays of cells). The header row names: No, EL.X (m), h1 (mm), h2 (mm). 열 and other columns are ignored.
|
||||
*/
|
||||
import type { DrawingBearing } from './bearing-ref';
|
||||
|
||||
export interface DrawingImportResult {
|
||||
rows: DrawingBearing[];
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
// Header names ignore case, spaces, dots and a unit in parentheses: 'EL. X (m)' → 'elx'.
|
||||
const norm = (v: unknown) => String(v ?? '').replace(/\(.*?\)/g, '').toLowerCase().replace(/[\s.]/g, '');
|
||||
|
||||
/** Column of each header name, or -1 when missing. */
|
||||
function headerIndex(header: unknown[]): { no: number; elx: number; h1: number; h2: number } {
|
||||
const find = (...names: string[]) => header.findIndex((c) => names.includes(norm(c)));
|
||||
return { no: find('no'), elx: find('elx', 'el'), h1: find('h1', '블럭높이h1'), h2: find('h2', '몰탈h2', '모르타르h2') };
|
||||
}
|
||||
|
||||
export function parseDrawingRows(rows: unknown[][]): DrawingImportResult {
|
||||
const errors: string[] = [];
|
||||
const headerAt = rows.findIndex((r) => {
|
||||
const idx = headerIndex(r);
|
||||
return idx.no >= 0 && idx.elx >= 0 && idx.h1 >= 0 && idx.h2 >= 0;
|
||||
});
|
||||
if (headerAt < 0) {
|
||||
return { rows: [], errors: ['머리글을 찾지 못했습니다. No, EL.X, h1, h2 열이 있어야 합니다.'] };
|
||||
}
|
||||
const idx = headerIndex(rows[headerAt]);
|
||||
const out: DrawingBearing[] = [];
|
||||
const seen = new Set<number>();
|
||||
rows.slice(headerAt + 1).forEach((r, i) => {
|
||||
const rowNo = headerAt + 2 + i;
|
||||
if (r.every((c) => String(c ?? '').trim() === '')) return;
|
||||
const no = Number(r[idx.no]);
|
||||
const elx = Number(r[idx.elx]);
|
||||
const h1 = Number(r[idx.h1]);
|
||||
const h2 = Number(r[idx.h2]);
|
||||
if (!Number.isInteger(no) || no < 1) {
|
||||
errors.push(`${rowNo}행: No가 올바르지 않습니다.`);
|
||||
return;
|
||||
}
|
||||
if (seen.has(no)) {
|
||||
errors.push(`${rowNo}행: No ${no}가 중복됩니다.`);
|
||||
return;
|
||||
}
|
||||
if (!Number.isFinite(elx) || elx < 30 || elx > 100) {
|
||||
errors.push(`${rowNo}행: EL.X는 m 단위로 적어 주세요 (예: 43.625). 값: ${String(r[idx.elx])}`);
|
||||
return;
|
||||
}
|
||||
if (!Number.isFinite(h1) || h1 <= 0 || h1 > 1000 || !Number.isFinite(h2) || h2 <= 0 || h2 > 1000) {
|
||||
errors.push(`${rowNo}행: h1·h2는 mm 단위 양수로 적어 주세요.`);
|
||||
return;
|
||||
}
|
||||
seen.add(no);
|
||||
out.push({ no, elx, h1, h2 });
|
||||
});
|
||||
if (out.length === 0) errors.push('불러온 행이 없습니다.');
|
||||
return { rows: out.sort((a, b) => a.no - b.no), errors };
|
||||
}
|
||||
@@ -1,149 +0,0 @@
|
||||
/**
|
||||
* 도면에서 받침 ELEVATION TABLE 읽기 (승인 2026-10-23). Reads the TEXT entities of a DXF file and places each value
|
||||
* by position: the header row names the columns (EL.X, h1 … h4, A, B, C1 …), the first column holds the bearing No.
|
||||
* Pure: works on the text entities (x, y, text) of the drawing, not on the file itself.
|
||||
*/
|
||||
import type { DrawingBearing } from './bearing-ref';
|
||||
|
||||
export interface DxfText {
|
||||
x: number;
|
||||
y: number;
|
||||
text: string;
|
||||
}
|
||||
|
||||
/** Reads TEXT and MTEXT entities (x, y, string) from DXF text. */
|
||||
export function readDxfTexts(dxf: string): DxfText[] {
|
||||
const lines = dxf.split(/\r?\n/);
|
||||
const pairs: [string, string][] = [];
|
||||
for (let i = 0; i + 1 < lines.length; i += 2) pairs.push([lines[i].trim(), lines[i + 1].trim()]);
|
||||
const out: DxfText[] = [];
|
||||
for (let i = 0; i < pairs.length; i++) {
|
||||
if (pairs[i][0] !== '0' || (pairs[i][1] !== 'TEXT' && pairs[i][1] !== 'MTEXT')) continue;
|
||||
let text: string | null = null;
|
||||
let x = NaN;
|
||||
let y = NaN;
|
||||
let j = i + 1;
|
||||
for (; j < pairs.length && pairs[j][0] !== '0'; j++) {
|
||||
const [c, v] = pairs[j];
|
||||
if (c === '1') text = v;
|
||||
if (c === '10') x = Number(v);
|
||||
if (c === '20') y = Number(v);
|
||||
}
|
||||
if (text !== null && Number.isFinite(x) && Number.isFinite(y)) out.push({ x, y, text });
|
||||
i = j - 1;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const norm = (s: string) => s.replace(/[\s.'"]/g, '').toLowerCase();
|
||||
const NUMBER = /^-?\d+(\.\d+)?$/;
|
||||
|
||||
export interface BearingTableRow {
|
||||
no: number;
|
||||
values: Record<string, number>;
|
||||
}
|
||||
|
||||
export interface BearingTableResult {
|
||||
/** Values per column as read from the drawing (keyed by header name). */
|
||||
rows: BearingTableRow[];
|
||||
/** The rows in the form the 받침 입력 uses (EL.X in m, h1 and h2 in mm). */
|
||||
drawing: DrawingBearing[];
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the bearing ELEVATION TABLE. The header is the text row with "EL.X" (as EL.'X', EL. "X"); the No column is the
|
||||
* integer column just left of the 교량받침 column; each value sits under its header (x within 150) on the same row (y within 120).
|
||||
*/
|
||||
export function parseBearingElevationTable(texts: DxfText[]): BearingTableResult {
|
||||
const errors: string[] = [];
|
||||
// The drawing has other "EL.X" labels (leader lines). The table header is the one with h1 and h2 on the same row.
|
||||
// The header is the EL.X label with the most table column names on its row, to its right (within 2500).
|
||||
const TABLE_NAMES = ['h1', 'h2', 'h3', 'h4', 'a', 'b', 'c1', 'c2', 'c3', 'c4', 'c5', 'c6', 's1', 's2'];
|
||||
const rowScore = (t: DxfText) =>
|
||||
texts.filter((u) => Math.abs(u.y - t.y) <= 60 && u.x > t.x && u.x - t.x <= 2500 && TABLE_NAMES.includes(norm(u.text))).length;
|
||||
const candidates = texts.filter((t) => norm(t.text) === 'elx');
|
||||
const best = candidates.map((t) => ({ t, score: rowScore(t) })).sort((a, b) => b.score - a.score)[0];
|
||||
if (!best || best.score < 2) return { rows: [], drawing: [], errors: ['EL.X 머리글(표의 열 이름과 같은 행)을 찾지 못했습니다.'] };
|
||||
const elHeader = best.t;
|
||||
const headerY = elHeader.y;
|
||||
// Column headers: texts on the header row (within 60 of its y) with a short name (EL.X, h1 … h4, A, B, C1 …).
|
||||
const headerRow = texts.filter((t) => Math.abs(t.y - headerY) <= 60 && /^(elx|h\d|[a-z]\d?)$/.test(norm(t.text)));
|
||||
const cols = headerRow.map((t) => ({ key: norm(t.text), x: t.x }));
|
||||
// The No header is the 'NO' label on the table's header row (the drawing has other 'NO' labels).
|
||||
const noHeader = texts.find((t) => norm(t.text) === 'no' && Math.abs(t.y - headerY) <= 200);
|
||||
// The No column: integers 1…10 below the header, within 150 of the No header (or the left-most integer column).
|
||||
const numbers = texts.filter((t) => NUMBER.test(t.text) && Number.isInteger(Number(t.text)) && Number(t.text) >= 1 && Number(t.text) <= 99 && t.y < headerY - 60);
|
||||
if (numbers.length === 0) return { rows: [], drawing: [], errors: ['받침 번호 열을 찾지 못했습니다.'] };
|
||||
const noX = noHeader ? noHeader.x : Math.min(...numbers.map((t) => t.x));
|
||||
const noCells = numbers.filter((t) => Math.abs(t.x - noX) <= 250).sort((a, b) => b.y - a.y);
|
||||
|
||||
const rows: BearingTableRow[] = [];
|
||||
for (const cell of noCells) {
|
||||
const values: Record<string, number> = {};
|
||||
for (const col of cols) {
|
||||
const hit = texts.find((t) => Math.abs(t.x - col.x) <= 150 && Math.abs(t.y - cell.y) <= 120 && NUMBER.test(t.text));
|
||||
if (hit) values[col.key] = Number(hit.text);
|
||||
}
|
||||
rows.push({ no: Number(cell.text), values });
|
||||
}
|
||||
const drawing: DrawingBearing[] = [];
|
||||
for (const r of rows) {
|
||||
const elx = r.values.elx;
|
||||
const h1 = r.values.h1;
|
||||
const h2 = r.values.h2;
|
||||
if (elx === undefined || h1 === undefined || h2 === undefined) {
|
||||
errors.push(`No ${r.no}: EL.X·h1·h2 중 값이 없습니다.`);
|
||||
continue;
|
||||
}
|
||||
drawing.push({ no: r.no, elx, h1, h2, A: r.values.a, B: r.values.b });
|
||||
}
|
||||
return { rows, drawing: drawing.sort((a, b) => a.no - b.no), errors };
|
||||
}
|
||||
|
||||
export interface SolplateTableRow {
|
||||
no: number;
|
||||
/** 기울기 S (%), negative as drawn. */
|
||||
S: number;
|
||||
L: number;
|
||||
W: number;
|
||||
/** 중심 두께 T, and the 교각축 직각 (−) and (+) side thicknesses T1, T2 (mm). */
|
||||
T: number;
|
||||
T1: number;
|
||||
T2: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 솔플레이트 TABLE (승인 2026-10-23): the header row has NO, S(%), L, W, T, T1, T2; each bearing row gives the values under
|
||||
* their headers, as in the bearing ELEVATION table.
|
||||
*/
|
||||
export function parseSolplateTable(texts: DxfText[]): { rows: SolplateTableRow[]; errors: string[] } {
|
||||
// The drawing has more than one "S(%)" label: the header is the one with L, W, T, T1 and T2 on its row.
|
||||
const SOL_NAMES = ['l', 'w', 't', 't1', 't2'];
|
||||
const solScore = (t: DxfText) => texts.filter((u) => Math.abs(u.y - t.y) <= 60 && SOL_NAMES.includes(norm(u.text))).length;
|
||||
const sCandidates = texts.filter((t) => norm(t.text) === 's(%)' || norm(t.text) === 's%');
|
||||
const sBest = sCandidates.map((t) => ({ t, score: solScore(t) })).sort((a, b) => b.score - a.score)[0];
|
||||
if (!sBest || sBest.score < 5) return { rows: [], errors: ['솔플레이트 TABLE의 머리글(NO, L, W, T, T1, T2)이 모두 있어야 합니다.'] };
|
||||
const sHeader = sBest.t;
|
||||
const headerY = sHeader.y;
|
||||
const headerRow = texts.filter((t) => Math.abs(t.y - headerY) <= 60);
|
||||
const key = (name: string) => headerRow.find((t) => norm(t.text) === name);
|
||||
const cols = { S: key('s(%)') ?? key('s%'), L: key('l'), W: key('w'), T: key('t'), T1: key('t1'), T2: key('t2') };
|
||||
const noHeader = headerRow.find((t) => norm(t.text) === 'no');
|
||||
if (!cols.L || !cols.W || !cols.T || !cols.T1 || !cols.T2 || !noHeader) {
|
||||
return { rows: [], errors: ['솔플레이트 TABLE의 머리글(NO, L, W, T, T1, T2)이 모두 있어야 합니다.'] };
|
||||
}
|
||||
const noCells = texts.filter((t) => NUMBER.test(t.text) && Number.isInteger(Number(t.text)) && Math.abs(t.x - noHeader.x) <= 250 && t.y < headerY - 60);
|
||||
const at = (col: DxfText, y: number) => {
|
||||
const hit = texts.find((t) => Math.abs(t.x - col.x) <= 150 && Math.abs(t.y - y) <= 120 && NUMBER.test(t.text));
|
||||
return hit ? Number(hit.text) : NaN;
|
||||
};
|
||||
const rows: SolplateTableRow[] = [];
|
||||
const errors: string[] = [];
|
||||
for (const cell of noCells.sort((a, b) => b.y - a.y)) {
|
||||
const r = { no: Number(cell.text), S: cols.S ? at(cols.S, cell.y) : NaN, L: at(cols.L, cell.y), W: at(cols.W, cell.y), T: at(cols.T, cell.y), T1: at(cols.T1, cell.y), T2: at(cols.T2, cell.y) };
|
||||
if (Object.values(r).some((v) => Number.isNaN(v))) errors.push(`No ${r.no}: 솔플레이트 값이 빠졌습니다.`);
|
||||
else rows.push(r);
|
||||
}
|
||||
return { rows: rows.sort((a, b) => a.no - b.no), errors };
|
||||
}
|
||||
+86
-29
@@ -1,12 +1,20 @@
|
||||
import { buildAxis, copingSpan, fromAxis, kinkArc, nearKink, toAxis } from './axis';
|
||||
import { buildIntegratedBlocks, type IntegratedBlock } from './integrated-block';
|
||||
import { type BlockRebarSettings, DEFAULT_BLOCK_REBAR } from './rebar/bearing-block';
|
||||
import { type CopingRebarSettings, DEFAULT_COPING_REBAR, DEFAULT_TOP_SPLICE, type TopSpliceSettings } from './rebar/coping-settings';
|
||||
import { type CopingRebarSettings, copingDefaults, DEFAULT_TOP_SPLICE, type TopSpliceSettings } from './rebar/coping-settings';
|
||||
import { type ColumnRebarSettings, DEFAULT_COLUMN_REBAR } from './rebar/column-settings';
|
||||
import { type FootingRebarSettings, DEFAULT_FOOTING_REBAR } from './rebar/footing-settings';
|
||||
import { layoutText, resolveRowCounts, rowLayout, type PileGeometry } from './piles';
|
||||
import type { PierInput, Row } from './types';
|
||||
import type { PierInput, Row, SolplatePlan } from './types';
|
||||
import type { BearingPair } from './bearing-pair';
|
||||
import { type BearingSole, structureSoles } from './sole-plate';
|
||||
import { type CopingGeom, P1_COPING_GEOM } from './coping-geom';
|
||||
import { castShape } from './cast-in-place-shape';
|
||||
|
||||
/** Default Level width (mm) when no input: T형 2100 (P1), 2·3주형 D + 200 (spec §3, 2주형 DWG 2026-11-06). */
|
||||
export function defaultLevelWidthMm(type: PierInput['type'], columnDiameterMm: number): number {
|
||||
return type === 'T형' ? P1_COPING_GEOM.levelWidth : columnDiameterMm + DIM.multiLevelExtra * 1000;
|
||||
}
|
||||
|
||||
/** Dimensions in m. Sources: intent.PR sheet (P1 rules) and spec.md §3 defaults. */
|
||||
export const DIM = {
|
||||
@@ -14,8 +22,14 @@ export const DIM = {
|
||||
copingH1: 1.0,
|
||||
copingH2: 1.5,
|
||||
levelWidth: 2.1,
|
||||
/** Multi-column coping: underside between columns sits this far below Level, with 45° haunches of the same size (spec §4.5, Q3 추천안). */
|
||||
haunch: 0.3,
|
||||
/**
|
||||
* Multi-column coping, 오목형 underside (spec §4.4, 3주형형상배근.md §2.2, 2주형 DWG 2026-11-06):
|
||||
* from each Level end it rises concaveRise over the horizontal haunch length concaveHaunch, stays flat, and comes back down.
|
||||
*/
|
||||
concaveRise: 0.5,
|
||||
concaveHaunch: 1.5,
|
||||
/** Multi-column Level width = D + this (spec §3, 2주형 DWG). T형 keeps levelWidth (= D + 100 for P1). */
|
||||
multiLevelExtra: 0.2,
|
||||
padThickness: 0.05,
|
||||
padSize: 1.0,
|
||||
blockSize: 1.2,
|
||||
@@ -74,8 +88,10 @@ export interface BlockModel {
|
||||
padTop: number;
|
||||
/** Skew (deg, −90…90] of the bearing row against the perpendicular of the stretch axis; the block sides follow the row. */
|
||||
rowSkewDeg: number;
|
||||
/** Solplate plan of this bearing (A, B, S, T, margin, h3), when the input gives it. */
|
||||
plan?: { A: number; B: number; S: number; T: number; margin: number; h3: number };
|
||||
/** Solplate of this bearing (2026-11-06): S from the girder pair, T per row, T1 on the 오르막 쪽. */
|
||||
plan: SolplatePlan;
|
||||
/** Block bottom from the 두부보 상면 slope (before any 교좌면 EL input), for the check against the input (2026-11-03). */
|
||||
seatCalc: number;
|
||||
}
|
||||
|
||||
/** Skew of a bearing row (deg) from the bearing azimuth and the stretch axis direction u (see {@link BlockModel.rowSkewDeg}). */
|
||||
@@ -204,6 +220,10 @@ export interface PierModel {
|
||||
blockHeightBack: number;
|
||||
frontBackDiffSpread: number;
|
||||
};
|
||||
/** Dedicated rebar source; never substitute footing-anchored column inputs. */
|
||||
castInput?: PierInput;
|
||||
/** Solplate of every bearing with its S source, pair and warnings (2026-11-06), for the ② card and checks. */
|
||||
soles: BearingSole[];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -228,7 +248,10 @@ export function solveIp4(ip1: Vec2, ip2: Vec2, ip3: Vec2, angleDeg: number): Vec
|
||||
* Each object carries its stretch k and a local s measured from that stretch's base (k = 0: BP1, k = 1: IP4).
|
||||
* A bent coping (IP4 angle ≠ 0) puts each footing in its stretch's frame; footings that would cross the kink are not combined.
|
||||
*/
|
||||
export function buildPier(input: PierInput): PierModel {
|
||||
/** Bridge context for one pier: the girder pairs over A1, P1 … Pn, A2 (girderPairs), for the solplate slope S. */
|
||||
export interface PierContext { pairs?: BearingPair[] }
|
||||
|
||||
export function buildPier(input: PierInput, ctx: PierContext = {}): PierModel {
|
||||
const ip = (name: string) => {
|
||||
const p = input.ips.find((q) => q.name === name);
|
||||
if (!p) throw new Error(`IP 누락: ${name}`);
|
||||
@@ -245,9 +268,9 @@ export function buildPier(input: PierInput): PierModel {
|
||||
// Coping heights and Level width (m): inputs, with the P1 values when absent (1단계).
|
||||
const copingH1 = (input.copingH1Mm ?? P1_COPING_GEOM.h1) / 1000;
|
||||
const copingH2 = (input.copingH2Mm ?? P1_COPING_GEOM.h2) / 1000;
|
||||
const levelW = (input.levelWidthMm ?? P1_COPING_GEOM.levelWidth) / 1000;
|
||||
// Column diameter (m): the input, with 2.0 m when absent (승인 2026-10-23).
|
||||
const colD = (input.columnDiameterMm ?? DIM.columnDiameter * 1000) / 1000;
|
||||
const levelW = (input.levelWidthMm ?? defaultLevelWidthMm(input.type, colD * 1000)) / 1000;
|
||||
const planHalfB = B / 2;
|
||||
const ip1v: Vec2 = { e: ip1.x, n: ip1.y };
|
||||
const ip2v: Vec2 = { e: ip2.x, n: ip2.y };
|
||||
@@ -327,7 +350,7 @@ export function buildPier(input: PierInput): PierModel {
|
||||
const lateralStep = stepSamples.some(s => Math.abs(segmentTop(segments,s)-segmentTop(backSegments,s)) >= DIM.lateralTolerance);
|
||||
|
||||
// Columns (spec §4.5). Positions are coping coordinates; a column must stay a Level half-width away from the kink.
|
||||
const colG = columnPositions(input.type, L);
|
||||
const colG = columnPositions(input.type, L, input.columnEndOffsetMm, input.columnSpacingMm);
|
||||
if (kinkG !== null) {
|
||||
const near = nearKink(axis, colG.map((g) => sStart + g), levelW / 2);
|
||||
if (near.length) throw new Error('미실시: 꺾임점 근처(Level 반폭 이내)에 기둥이 있습니다. 기둥 위치를 확인하세요.');
|
||||
@@ -340,7 +363,8 @@ export function buildPier(input: PierInput): PierModel {
|
||||
const levelEnd = Math.max(...colG) + levelW / 2;
|
||||
if (levelStart < 0 || levelEnd > L) throw new Error('코핑 길이가 Level 구간보다 짧습니다.');
|
||||
|
||||
// Underside from the right end to the left end. Between columns (2·3주형) it drops 300 below Level with 300 × 300 haunches (spec §4.5, Q3).
|
||||
// Underside from the right end to the left end. Between columns (2·3주형) it is 오목형: from each Level end it rises
|
||||
// h_r (500) over the horizontal haunch l_h (1500), stays flat, and comes back down (spec §4.4, 3주형형상배근.md §2.2).
|
||||
const underside: [number, number][] = [[L, endBottom]];
|
||||
const levelZones = [...colG].sort((a, b) => b - a).map((s) => [s - levelW / 2, s + levelW / 2] as const);
|
||||
levelZones.forEach(([zoneStart, zoneEnd], k) => {
|
||||
@@ -348,9 +372,12 @@ export function buildPier(input: PierInput): PierModel {
|
||||
const next = levelZones[k + 1];
|
||||
if (!next) return;
|
||||
const gap = zoneStart - next[1];
|
||||
if (gap <= 2 * DIM.haunch) throw new Error('기둥 사이 간격이 헌치(300 × 2)보다 좁아 코핑 하면을 만들 수 없습니다.');
|
||||
const lowerEl = levelBottom - DIM.haunch;
|
||||
underside.push([zoneStart - DIM.haunch, lowerEl], [next[1] + DIM.haunch, lowerEl], [next[1], levelBottom]);
|
||||
// RB-M01: no flat part left between the two haunches.
|
||||
if (gap <= 2 * DIM.concaveHaunch + 1e-9) {
|
||||
throw new Error(`기둥 사이 간격 ${Math.round(gap * 1000)} mm가 오목형 헌치(${DIM.concaveHaunch * 1000} × 2)보다 짧아 코핑 하면을 만들 수 없습니다 (RB-M01).`);
|
||||
}
|
||||
const raisedEl = levelBottom + DIM.concaveRise;
|
||||
underside.push([zoneStart - DIM.concaveHaunch, raisedEl], [next[1] + DIM.concaveHaunch, raisedEl], [next[1], levelBottom]);
|
||||
});
|
||||
|
||||
// Elevation outline in coping coordinates for one row of tops, closed by the shared underside.
|
||||
@@ -391,7 +418,9 @@ export function buildPier(input: PierInput): PierModel {
|
||||
}
|
||||
}
|
||||
|
||||
const footingTop = input.foundationType === '현장타설말뚝' ? input.footingBottomEl : input.footingBottomEl + fd.H;
|
||||
const cast = castShape(input);
|
||||
if (input.foundationType === '현장타설말뚝' && (!Number.isFinite(cast.pileTopEl) || cast.pileTopEl >= levelBottom || !Number.isFinite(input.pileLengthMm) || input.pileLengthMm <= 0)) throw new Error('현장타설말뚝은 지반고가 코핑 Level 하면보다 낮고 말뚝 길이가 양수여야 합니다.');
|
||||
const footingTop = input.foundationType === '현장타설말뚝' ? cast.pileTopEl : input.footingBottomEl + fd.H;
|
||||
const columns: ColumnModel[] = colG.map((g, i) => ({
|
||||
s: g - base[colK[i]],
|
||||
k: colK[i],
|
||||
@@ -404,11 +433,11 @@ export function buildPier(input: PierInput): PierModel {
|
||||
const blindings: BlindingModel[] = [];
|
||||
const piles: PileModel[] = [];
|
||||
const pileLength = input.pileLengthMm / 1000;
|
||||
const pileTip = input.footingBottomEl - pileLength;
|
||||
const pileTip = input.foundationType === '현장타설말뚝' ? cast.pileBottomEl : input.footingBottomEl - pileLength;
|
||||
|
||||
if (input.foundationType === '현장타설말뚝') {
|
||||
columns.forEach((c) => {
|
||||
piles.push({ s: c.s, k: c.k, n: 0, top: input.footingBottomEl, bottom: pileTip, diameter: colD });
|
||||
piles.push({ s: c.s, k: c.k, n: 0, top: cast.pileTopEl, bottom: pileTip, diameter: colD });
|
||||
});
|
||||
} else {
|
||||
for (const f of footingSets) {
|
||||
@@ -430,21 +459,37 @@ export function buildPier(input: PierInput): PierModel {
|
||||
}
|
||||
|
||||
const blocks: BlockModel[] = [];
|
||||
const bearingAz = new Map(input.bearings.map((r) => [r.no, r.azimuthDeg]));
|
||||
// 계산 방위각을 적용한 받침 (2026-11-06): that value turns the block, bearing and solplate; the workbook value stays in input.
|
||||
const azimuthOf = (r: { no: number; azimuthDeg: number }) => input.azimuthApplied?.[String(r.no)] ?? r.azimuthDeg;
|
||||
const bearingAz = new Map(input.bearings.map((r) => [r.no, azimuthOf(r)]));
|
||||
const skewOf = (no: number, k: number) => rowSkewDeg(bearingAz.get(no) ?? NaN, axis.segments[k].u);
|
||||
// Solplate plans from the input (승인 2026-10-23); the 공통 규칙 default is T 30, margin 100, h3 181.
|
||||
const rules = input.solplate ?? { T: 30, margin: 100, h3: 181 };
|
||||
const planOf = (no: number) => {
|
||||
const p = input.bearingPlans?.find((q) => q.no === no);
|
||||
return p ? { A: p.A, B: p.B, S: p.S, T: rules.T, margin: rules.margin, h3: rules.h3 } : undefined;
|
||||
// Solplate of every bearing (결정 2026-11-06): S from the girder pair (ctx.pairs over the whole bridge), else the
|
||||
// 종단경사 input, else the other row; T per row; the girder underside stays, so the block top drops by T − T_ref.
|
||||
const soles = new Map(structureSoles(input.name, input.bearings.map((b) => ({ no: b.no, row: b.row, azimuthDeg: azimuthOf(b), inputAzimuthDeg: b.azimuthDeg })),
|
||||
input.sole, ctx.pairs ?? [], input.longitudinalSlope === undefined ? undefined : input.longitudinalSlope * 100).map((s) => [s.no, s]));
|
||||
const planOf = (no: number, k: number, skewDeg: number): SolplatePlan => {
|
||||
const s = soles.get(no)!;
|
||||
// The block's c axis in (E, N): (−sin d)·u + (cos d)·nrm; T1 goes to the end of c that points uphill.
|
||||
const { u, nrm } = axis.segments[k];
|
||||
const d = (skewDeg * Math.PI) / 180;
|
||||
const c = { e: -Math.sin(d) * u.e + Math.cos(d) * nrm.e, n: -Math.sin(d) * u.n + Math.cos(d) * nrm.n };
|
||||
const t1Side = s.uphill && c.e * s.uphill.e + c.n * s.uphill.n > 0 ? 1 : -1;
|
||||
return { L: s.L, W: s.W, slopePct: s.slopePct, T: s.T, T1: s.T1, T2: s.T2, h3: s.h3, t1Side };
|
||||
};
|
||||
const dropOf = (no: number) => soles.get(no)!.dropMm / 1000;
|
||||
// 교좌면 EL (2026-11-03): when the input gives it, the block bottom is that EL; otherwise the 두부보 상면 slope.
|
||||
const seatOf = new Map(input.bearings.map((r) => [r.no, r.seatEl]));
|
||||
frontPlaced.forEach((b) => {
|
||||
const bottom = segmentTop(segments, b.g);
|
||||
blocks.push({ no: b.no, row: '전열', k: b.k, s: b.s, g: b.g, n: b.n, bottom, top: b.z - DIM.padThickness, padTop: b.z, rowSkewDeg: skewOf(b.no, b.k), plan: planOf(b.no) });
|
||||
const seatCalc = segmentTop(segments, b.g);
|
||||
const bottom = seatOf.get(b.no) ?? seatCalc;
|
||||
const skew = skewOf(b.no, b.k), drop = dropOf(b.no);
|
||||
blocks.push({ no: b.no, row: '전열', k: b.k, s: b.s, g: b.g, n: b.n, bottom, top: b.z - DIM.padThickness - drop, padTop: b.z - drop, rowSkewDeg: skew, plan: planOf(b.no, b.k, skew), seatCalc });
|
||||
});
|
||||
backPlaced.forEach((b) => {
|
||||
const bottom = b.z - h2;
|
||||
blocks.push({ no: b.no, row: '후열', k: b.k, s: b.s, g: b.g, n: b.n, bottom, top: b.z - DIM.padThickness, padTop: b.z, rowSkewDeg: skewOf(b.no, b.k), plan: planOf(b.no) });
|
||||
const seatCalc = b.z - h2;
|
||||
const bottom = seatOf.get(b.no) ?? seatCalc;
|
||||
const skew = skewOf(b.no, b.k), drop = dropOf(b.no);
|
||||
blocks.push({ no: b.no, row: '후열', k: b.k, s: b.s, g: b.g, n: b.n, bottom, top: b.z - DIM.padThickness - drop, padTop: b.z - drop, rowSkewDeg: skew, plan: planOf(b.no, b.k, skew), seatCalc });
|
||||
});
|
||||
|
||||
const blockHeightFront = blocks.find((b) => b.row === '전열')!.top - blocks.find((b) => b.row === '전열')!.bottom;
|
||||
@@ -484,7 +529,7 @@ export function buildPier(input: PierInput): PierModel {
|
||||
blocks,
|
||||
integratedBlocks: buildIntegratedBlocks(blocks, input.bearingLayout === '일체'),
|
||||
blockRebar: { ...DEFAULT_BLOCK_REBAR, ...input.blockRebar },
|
||||
copingRebar: { ...DEFAULT_COPING_REBAR, ...input.copingRebar },
|
||||
copingRebar: { ...copingDefaults(columns.length), ...input.copingRebar },
|
||||
topSplice: { ...DEFAULT_TOP_SPLICE, ...input.topSplice },
|
||||
columnRebar: { ...DEFAULT_COLUMN_REBAR, ...input.columnRebar },
|
||||
footingRebar: { ...DEFAULT_FOOTING_REBAR, ...input.footingRebar },
|
||||
@@ -495,7 +540,7 @@ export function buildPier(input: PierInput): PierModel {
|
||||
groundEl: input.groundEl,
|
||||
derived: {
|
||||
columnHeight: levelBottom - columns[0].bottom,
|
||||
soilCover: input.groundEl - (input.foundationType === '현장타설말뚝' ? input.footingBottomEl : footingTop),
|
||||
soilCover: input.foundationType === '현장타설말뚝' ? 0 : input.groundEl - footingTop,
|
||||
pileCount: piles.length,
|
||||
pileLayout: input.foundationType === '말뚝기초' ? layoutText(resolveRowCounts(input.pileRows, pileGeometry(footings[0].length, fd.W)), pileGeometry(footings[0].length, fd.W)) : null,
|
||||
pileTipEl: piles.length ? pileTip : null,
|
||||
@@ -503,11 +548,23 @@ export function buildPier(input: PierInput): PierModel {
|
||||
blockHeightBack,
|
||||
frontBackDiffSpread,
|
||||
},
|
||||
soles: [...soles.values()],
|
||||
...(input.foundationType === '현장타설말뚝' ? { castInput: { ...input } } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function columnPositions(type: PierInput['type'], L: number): number[] {
|
||||
/**
|
||||
* Column centres along the coping (m). spec §4.5: L/5 · (L/2) · 4L/5. For 2·3주형 the 끝 여유 e_c and 간격 s_c may be typed
|
||||
* (Q-M10 미결, 3주형형상배근.md §2.1): e_c + k·s_c, s_c defaulting to the even spacing between e_c and L − e_c.
|
||||
*/
|
||||
export function columnPositions(type: PierInput['type'], L: number, endOffsetMm?: number, spacingMm?: number): number[] {
|
||||
if (type === 'T형') return [L / 2];
|
||||
const n = type === '2주형' ? 2 : 3;
|
||||
if (endOffsetMm !== undefined) {
|
||||
const e = endOffsetMm / 1000;
|
||||
const step = spacingMm !== undefined ? spacingMm / 1000 : (L - 2 * e) / (n - 1);
|
||||
return Array.from({ length: n }, (_, k) => e + k * step);
|
||||
}
|
||||
if (type === '2주형') return [L / 5, L - L / 5];
|
||||
return [L / 5, L / 2, L - L / 5];
|
||||
}
|
||||
|
||||
+14
-6
@@ -1,6 +1,14 @@
|
||||
import type { PierModel } from './geometry';
|
||||
import type { Issue, PierInput } from './types';
|
||||
|
||||
/**
|
||||
* The values the 지반·지층 checks read (2026-11-04: shared by the pier and the abutment card). A pier passes its input;
|
||||
* an abutment passes its record's 지반 values with its 지반고 and 저판 하면.
|
||||
*/
|
||||
export type GroundCheckInput = Pick<
|
||||
PierInput,
|
||||
'name' | 'groundEl' | 'footingBottomEl' | 'groundPosition' | 'groundWaterOn' | 'groundWaterEl' | 'floodLevelEl' | 'scourDepthM' | 'highWaterEl' | 'lowWaterEl' | 'strataRows' | 'pileThicknessMm'
|
||||
>;
|
||||
|
||||
/** 위치 choices of the 지반·지층 card (시안 2026-10-26). */
|
||||
export const GROUND_POSITIONS = ['육상', '하상', '해상'] as const;
|
||||
|
||||
@@ -28,7 +36,7 @@ export const CORROSION_ALLOWANCE_MM = 1;
|
||||
* G03: 층 이름은 지층 목록에 있어야 함. 하상: G07 DHWL 없음, G08 DHWL이 지반고보다 낮음, G09 세굴선 확인.
|
||||
* Ground values are inputs only; they do not change the 3D model.
|
||||
*/
|
||||
export function validateGround(input: PierInput, issues: Issue[]): void {
|
||||
export function validateGround(input: GroundCheckInput, issues: Issue[]): void {
|
||||
const rows = input.strataRows ?? [];
|
||||
let upper = input.groundEl;
|
||||
rows.forEach((row, i) => {
|
||||
@@ -70,7 +78,7 @@ export function validateGround(input: PierInput, issues: Issue[]): void {
|
||||
* G04: a tip above that limit. G05 (info): piles but no 연암 row, so the embedment is not checked.
|
||||
* The 연암 상면 is the bottom of the row above the 연암 row (ground EL for the first row).
|
||||
*/
|
||||
export function validatePileEmbedment(input: PierInput, model: PierModel, issues: Issue[]): void {
|
||||
export function validatePileEmbedment(input: GroundCheckInput, model: { piles: { bottom: number; diameter: number }[] }, issues: Issue[]): void {
|
||||
if (model.piles.length === 0) return;
|
||||
const rows = input.strataRows ?? [];
|
||||
const k = rows.findIndex((r) => r.name === SUPPORT_STRATUM);
|
||||
@@ -123,7 +131,7 @@ export function parseNValue(text: string | undefined): NValue {
|
||||
* G06 (경고): an N value that is not a blow count or a refusal in the accepted range. Empty is allowed.
|
||||
* The values are not used in the calculation yet; they will feed the 지지력 and 액상화 checks, so the format is strict.
|
||||
*/
|
||||
export function validateStrataNValues(input: PierInput, issues: Issue[]): void {
|
||||
export function validateStrataNValues(input: GroundCheckInput, issues: Issue[]): void {
|
||||
(input.strataRows ?? []).forEach((row, i) => {
|
||||
if (parseNValue(row.nValue).kind === 'invalid') {
|
||||
issues.push({
|
||||
@@ -137,7 +145,7 @@ export function validateStrataNValues(input: PierInput, issues: Issue[]): void {
|
||||
}
|
||||
|
||||
/** G09: the 세굴 깊이 is not negative, and the scour line stays above the footing bottom (하상·해상). */
|
||||
function validateScour(input: PierInput, issues: Issue[]): void {
|
||||
function validateScour(input: GroundCheckInput, issues: Issue[]): void {
|
||||
const scour = input.scourDepthM;
|
||||
if (scour === undefined) return;
|
||||
if (!(scour >= 0)) {
|
||||
@@ -152,7 +160,7 @@ function validateScour(input: PierInput, issues: Issue[]): void {
|
||||
* 저조위 is below 고조위. G13 missing; G10 해저면이 고조위보다 낮지 않음; G11 저조위가 고조위보다 낮지 않음;
|
||||
* G14 강관 두께가 부식 여유 기준 이하. 파랑과 선박 하중은 이번 범위에서 제외.
|
||||
*/
|
||||
function validateSea(input: PierInput, issues: Issue[]): void {
|
||||
function validateSea(input: GroundCheckInput, issues: Issue[]): void {
|
||||
const hw = input.highWaterEl;
|
||||
const lw = input.lowWaterEl;
|
||||
if (hw === undefined || lw === undefined || !Number.isFinite(hw) || !Number.isFinite(lw)) {
|
||||
|
||||
@@ -186,5 +186,7 @@ function toBearing(c: Cell[]): Bearing {
|
||||
z: Number(c[4]),
|
||||
azimuthDeg: swapXYAzimuth(parseDms(String(c[5] ?? '')) ?? NaN),
|
||||
row: String(c[6]) as Row,
|
||||
// 교좌면_EL (m), optional: blank = not given, so the model keeps the 두부보 상면 slope.
|
||||
seatEl: c[7] === undefined || c[7] === null || c[7] === '' ? undefined : Number(c[7]),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* 프로젝트명 · 교량명 from the input workbook (2026-11-06). Pure.
|
||||
*
|
||||
* The new input form (운곡교AA.xlsx, 운곡교(완도,광주)데이터) has them at the top of '형상정보': B1 '프로젝트명:' → C1,
|
||||
* B2 '교량명:' → C2. The label is searched in the first 20 rows × 10 columns of every sheet (form layouts move), and the
|
||||
* value is the text after the colon in the same cell, else the first non-empty cell to its right (up to 4 columns).
|
||||
* Workbooks without the labels (운곡교P1입력.xlsx) give nothing, and the dialog keeps what the user typed.
|
||||
*/
|
||||
import * as XLSX from 'xlsx';
|
||||
|
||||
export interface ProjectInfo {
|
||||
projectName?: string;
|
||||
bridgeName?: string;
|
||||
/** Where the values were read, e.g. "형상정보!C1 · C2". */
|
||||
source?: string;
|
||||
}
|
||||
|
||||
const LABELS: { key: 'projectName' | 'bridgeName'; re: RegExp }[] = [
|
||||
{ key: 'projectName', re: /^\s*프로젝트\s*명\s*[::]?\s*(.*)$/ },
|
||||
{ key: 'bridgeName', re: /^\s*교량\s*명\s*[::]?\s*(.*)$/ },
|
||||
];
|
||||
const ROWS = 20, COLS = 10, LOOK_RIGHT = 4;
|
||||
|
||||
export function readProjectInfo(wb: XLSX.WorkBook): ProjectInfo {
|
||||
const info: ProjectInfo = {};
|
||||
const where: string[] = [];
|
||||
for (const sheetName of wb.SheetNames) {
|
||||
const ws = wb.Sheets[sheetName];
|
||||
if (!ws) continue;
|
||||
const at = (r: number, c: number) => {
|
||||
const cell = ws[XLSX.utils.encode_cell({ r, c })];
|
||||
const v = cell?.v;
|
||||
return v === undefined || v === null ? '' : String(v).trim();
|
||||
};
|
||||
for (let r = 0; r < ROWS; r++) for (let c = 0; c < COLS; c++) {
|
||||
const text = at(r, c);
|
||||
if (!text) continue;
|
||||
for (const { key, re } of LABELS) {
|
||||
if (info[key] !== undefined) continue;
|
||||
const m = re.exec(text);
|
||||
if (!m) continue;
|
||||
let value = m[1].trim(), col = c;
|
||||
for (let k = 1; !value && k <= LOOK_RIGHT; k++) { value = at(r, c + k); col = c + k; }
|
||||
// A neighbouring label is not a value (e.g. an empty form row).
|
||||
if (!value || LABELS.some((l) => l.re.test(value))) continue;
|
||||
info[key] = value;
|
||||
where.push(`${sheetName}!${XLSX.utils.encode_cell({ r, c: col })}`);
|
||||
}
|
||||
}
|
||||
if (info.projectName !== undefined && info.bridgeName !== undefined) break;
|
||||
}
|
||||
if (where.length) info.source = where.join(' · ');
|
||||
return info;
|
||||
}
|
||||
+113
-5
@@ -1,7 +1,11 @@
|
||||
import type { FckByMember, Material } from './rebar/common';
|
||||
import { checkStoredGroundModel, type GroundModelFile } from './ground-model';
|
||||
import type { PierInput } from './types';
|
||||
import type { AbutRecord } from './abut/parse';
|
||||
import { abutNameError } from './abut/name';
|
||||
import { normalizeBearingAzimuths } from './coordinate-frame';
|
||||
import { castSettingsIssues } from './rebar/cast-in-place';
|
||||
import { castShapeIssues } from './cast-in-place-shape';
|
||||
|
||||
/** Project file schema (spec §8). Bump only with a migration. */
|
||||
export const SCHEMA_VERSION = 1;
|
||||
@@ -33,14 +37,14 @@ export interface ProjectFile {
|
||||
history: { version: string; note: string; at: string }[];
|
||||
/** ③ 공통 설정 (fck · fy). Absent in older files: the defaults are used. */
|
||||
common?: Material;
|
||||
/** 받침 기준 (project-wide): 'excel' (default) or 'drawing'. Absent = 'excel'. */
|
||||
bearingBasis?: 'excel' | 'drawing';
|
||||
/** 도면 받침 값 per pier name (EL.X in m, h1 and h2 in mm). */
|
||||
bearingDrawing?: Record<string, { no: number; elx: number; h1: number; h2: number }[]>;
|
||||
/** ② 부재별 fck (MPa). Absent in older files: every member uses the 공통 fck. */
|
||||
fckByMember?: FckByMember;
|
||||
/** 지반 모델 (ground_model.json)의 교각별 지반고. Absent = 불러오지 않음. Applied to a pier only by the user. */
|
||||
groundModel?: GroundModelFile;
|
||||
/** 교대 (2026-11-03, AB): 교대 입력 기록. Absent in older files: no abutments. */
|
||||
abutments?: AbutRecord[];
|
||||
/** 교대 기본값 (가져온 그대로): 기본값 복원에 쓴다. Absent = 교대별 현재값이 기본값. */
|
||||
abutDefaults?: AbutRecord[];
|
||||
}
|
||||
|
||||
/** Serialises a project to the pretty-printed JSON stored in <교량명>_교각설계.pzr.json. */
|
||||
@@ -67,12 +71,15 @@ export function parseProject(text: string): ProjectFile {
|
||||
if (!Array.isArray(obj.piers)) throw new Error('piers 목록이 없습니다.');
|
||||
if (obj.fckByMember !== undefined) {
|
||||
for (const [member, value] of Object.entries(obj.fckByMember)) {
|
||||
if (!['column', 'coping', 'footing'].includes(member) || typeof value !== 'number' || !(value > 0)) {
|
||||
if (!['column', 'coping', 'footing', 'abutFooting', 'abutBody'].includes(member) || typeof value !== 'number' || !(value > 0)) {
|
||||
throw new Error(`부재별 fck 값이 맞지 않습니다 (${member}: ${String(value)}).`);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (obj.groundModel !== undefined) checkStoredGroundModel(obj.groundModel);
|
||||
const pierNames = obj.piers.map((p) => p?.input?.name).filter((name): name is string => typeof name === 'string');
|
||||
if (obj.abutments !== undefined) checkStoredAbutments(obj.abutments, pierNames);
|
||||
if (obj.abutDefaults !== undefined) checkStoredAbutments(obj.abutDefaults, pierNames);
|
||||
obj.piers.forEach((p, i) => {
|
||||
if (!p || typeof p.id !== 'string' || !p.input) throw new Error(`교각 ${i + 1}: id 또는 input이 없습니다.`);
|
||||
const input = p.input;
|
||||
@@ -90,6 +97,10 @@ export function parseProject(text: string): ProjectFile {
|
||||
}
|
||||
}
|
||||
for (const values of [p.input, p.defaults, p.draft]) {
|
||||
const shapeIssues = castShapeIssues(values?.castInPlaceShape);
|
||||
if (shapeIssues.length) throw new Error(`교각 ${input.name}: ${shapeIssues.join(' · ')}`);
|
||||
const castIssues = castSettingsIssues(values?.castInPlaceRebar);
|
||||
if (castIssues.length) throw new Error(`교각 ${input.name}: 현장타설 배근 · ${castIssues.join(' · ')}`);
|
||||
if (values?.bearingAzimuthFrame !== undefined && values.bearingAzimuthFrame !== 'model-xy') {
|
||||
throw new Error(`교각 ${input.name}: 받침 방위각 좌표계 표기가 맞지 않습니다.`);
|
||||
}
|
||||
@@ -103,3 +114,100 @@ export function parseProject(text: string): ProjectFile {
|
||||
});
|
||||
return obj as ProjectFile;
|
||||
}
|
||||
|
||||
const finite = (v: unknown) => typeof v === 'number' && Number.isFinite(v);
|
||||
const isPoint = (v: unknown) => !!v && typeof v === 'object' && finite((v as { e?: unknown }).e) && finite((v as { n?: unknown }).n);
|
||||
|
||||
/** 교대 기록 check (AB): refuses a damaged abutment record with a message that names it. */
|
||||
export function checkStoredAbutments(list: unknown, reservedNames: readonly string[] = []): void {
|
||||
if (!Array.isArray(list)) throw new Error('교대 목록 형식이 맞지 않습니다.');
|
||||
const names = new Set(reservedNames.map((name) => name.trim()));
|
||||
list.forEach((a: Partial<AbutRecord>, i) => {
|
||||
const who = `교대 ${i + 1}(${String(a?.name)})`;
|
||||
const nameError = abutNameError(a?.name);
|
||||
if (nameError) throw new Error(`${who}: ${nameError}`);
|
||||
const name = a.name!.trim();
|
||||
if (names.has(name)) throw new Error(`${who}: 구조물명 "${name}"이 중복되었습니다.`);
|
||||
names.add(name);
|
||||
a.name = name;
|
||||
if (a.type !== 'A1' && a.type !== 'A2') throw new Error(`${who}: 형식은 A1 또는 A2여야 합니다.`);
|
||||
for (const key of ['seatEl', 'foundationBottomEl', 'groundEl', 'pileLengthMm', 'skewDeg', 'slopeI'] as const) {
|
||||
if (!finite(a[key])) throw new Error(`${who}: ${key} 값이 맞지 않습니다.`);
|
||||
}
|
||||
for (const key of ['ip1', 'ip2', 'ip3'] as const) {
|
||||
if (!isPoint(a[key])) throw new Error(`${who}: ${key} 좌표가 맞지 않습니다.`);
|
||||
}
|
||||
if (!Array.isArray(a.bearings) || a.bearings.length < 2) throw new Error(`${who}: 받침이 2개 이상 필요합니다.`);
|
||||
if (a.shape !== undefined) {
|
||||
for (const [key, v] of Object.entries(a.shape)) {
|
||||
if (!finite(v)) throw new Error(`${who}: 형상 값 ${key}이(가) 숫자가 아닙니다.`);
|
||||
}
|
||||
}
|
||||
if (a.bearingRule !== undefined) {
|
||||
for (const [key, v] of Object.entries(a.bearingRule)) {
|
||||
// 'kind' (받침 종류) was stored by an earlier version and is ignored now (2026-11-03).
|
||||
if (key === 'kind' ? typeof v !== 'string' : !finite(v)) throw new Error(`${who}: 받침 규칙 ${key}이(가) 맞지 않습니다.`);
|
||||
}
|
||||
}
|
||||
if (a.wingDims !== undefined) {
|
||||
for (const sideDims of [a.wingDims.left, a.wingDims.right]) {
|
||||
for (const [key, v] of Object.entries(sideDims ?? {})) {
|
||||
if (!finite(v)) throw new Error(`${who}: 날개벽 치수 ${key}이(가) 숫자가 아닙니다.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (a.slab !== undefined) {
|
||||
for (const [key, v] of Object.entries(a.slab)) {
|
||||
// 'slope' was stored by an earlier version (2026-11-04 오전) and is ignored now: the form sets the slope.
|
||||
const x: unknown = v;
|
||||
const ok = key === 'form' ? x === 'buried' || x === 'exposed' : key === 'slope' ? x === 'down4' || x === 'grade' : finite(x);
|
||||
if (!ok) throw new Error(`${who}: 접속슬래브 값 ${key}이(가) 맞지 않습니다.`);
|
||||
}
|
||||
}
|
||||
if (a.ground !== undefined) {
|
||||
const g = a.ground as Record<string, unknown>;
|
||||
for (const [key, v] of Object.entries(g)) {
|
||||
const ok =
|
||||
key === 'groundPosition' ? v === '육상' || v === '하상' || v === '해상'
|
||||
: key === 'groundWaterOn' ? typeof v === 'boolean'
|
||||
: key === 'strataRows'
|
||||
? Array.isArray(v) && v.every((r) => r && typeof r.name === 'string' && finite(r.bottomEl) && (r.nValue === undefined || typeof r.nValue === 'string'))
|
||||
: finite(v);
|
||||
if (!ok) throw new Error(`${who}: 지반 값 ${key}이(가) 맞지 않습니다.`);
|
||||
}
|
||||
}
|
||||
if (a.rebar !== undefined) {
|
||||
for (const [key, v] of Object.entries(a.rebar.footing ?? {})) {
|
||||
const x: unknown = v;
|
||||
const ok = key === 'dir' ? x === 'skew' || x === 'perp' : finite(x);
|
||||
if (!ok) throw new Error(`${who}: 저판 배근 값 ${key}이(가) 맞지 않습니다.`);
|
||||
}
|
||||
for (const [key, v] of Object.entries(a.rebar.wall ?? {})) {
|
||||
if (!finite(v)) throw new Error(`${who}: 벽체 배근 값 ${key}이(가) 맞지 않습니다.`);
|
||||
}
|
||||
for (const [key, v] of Object.entries(a.rebar.parapet ?? {})) {
|
||||
if (!finite(v)) throw new Error(`${who}: 흉벽 배근 값 ${key}이(가) 맞지 않습니다.`);
|
||||
}
|
||||
for (const [key, v] of Object.entries(a.rebar.wing ?? {})) {
|
||||
if (!finite(v)) throw new Error(`${who}: 날개벽 배근 값 ${key}이(가) 맞지 않습니다.`);
|
||||
}
|
||||
for (const [key, v] of Object.entries(a.rebar.slab ?? {})) {
|
||||
if (!finite(v)) throw new Error(`${who}: 접속슬래브 배근 값 ${key}이(가) 맞지 않습니다.`);
|
||||
}
|
||||
for (const [key, v] of Object.entries(a.rebar.block ?? {})) {
|
||||
if (!finite(v)) throw new Error(`${who}: 받침블럭 배근 값 ${key}이(가) 맞지 않습니다.`);
|
||||
}
|
||||
}
|
||||
if (a.wingsLinked !== undefined && typeof a.wingsLinked !== 'boolean') throw new Error(`${who}: 좌우 같게 값이 맞지 않습니다.`);
|
||||
if (a.wings !== undefined) {
|
||||
const forms = ['general', 'short', 'none'];
|
||||
if (!forms.includes(a.wings.left) || !forms.includes(a.wings.right)) throw new Error(`${who}: 날개벽 형식이 맞지 않습니다.`);
|
||||
}
|
||||
for (const b of a.bearings) {
|
||||
if (!b || !finite(b.no) || !isPoint(b.pos) || !finite(b.z) || (b.seatEl !== undefined && !finite(b.seatEl)) || (b.azimuthDeg !== undefined && !finite(b.azimuthDeg))) {
|
||||
throw new Error(`${who}: 받침 ${String(b?.no)}의 값이 맞지 않습니다.`);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
import type { PierInput } from '../types';
|
||||
import { DEFAULT_COLUMN_REBAR } from './column-settings';
|
||||
import { NOMINAL_DIAMETER, DEFAULT_MATERIAL, type Material, spliceLength } from './common';
|
||||
import { castSocketDiameterMm } from '../cast-in-place-shape';
|
||||
|
||||
/** Dedicated settings: round-column defaults were authorised by the user on 2026-11-05. */
|
||||
export interface CastRebarPart {
|
||||
mainDiameterMm?: number;
|
||||
mainCount?: number;
|
||||
mainGradeMpa?: number;
|
||||
coverMm?: number;
|
||||
concreteStrengthMpa?: number;
|
||||
tieDiameterMm?: number;
|
||||
tieGradeMpa?: number;
|
||||
tieSpacingMm?: number;
|
||||
endZoneLengthMm?: number;
|
||||
endZoneSpacingMm?: number;
|
||||
cageLengthMm?: number;
|
||||
endCoverMm?: number;
|
||||
topEmbedmentMm?: number;
|
||||
tieKind?: 'hoop' | 'spiral';
|
||||
coverReference?: 'clear' | 'main-center';
|
||||
innerMainDiameterMm?: number;
|
||||
innerMainCount?: number;
|
||||
}
|
||||
|
||||
export interface CastInPlaceRebar {
|
||||
column?: CastRebarPart;
|
||||
pile?: CastRebarPart;
|
||||
connection?: {
|
||||
mode?: 'continuous' | 'lap' | 'coupler';
|
||||
lapLengthMm?: number;
|
||||
couplerLengthMm?: number;
|
||||
};
|
||||
}
|
||||
|
||||
export const CAST_PART_FIELDS: { key: Exclude<keyof CastRebarPart, 'tieKind' | 'coverReference'>; label: string; only?: 'column' | 'pile' }[] = [
|
||||
{ key: 'mainDiameterMm', label: '외측 주철근 공칭지름 (mm)' },
|
||||
{ key: 'mainCount', label: '외측 주철근 본수 (본)' },
|
||||
{ key: 'mainGradeMpa', label: '주철근 강도 fy (MPa)' },
|
||||
{ key: 'coverMm', label: '측면 피복 (아래 기준 선택, mm)' },
|
||||
{ key: 'concreteStrengthMpa', label: '콘크리트 강도 fck (MPa)' },
|
||||
{ key: 'tieDiameterMm', label: '띠·나선철근 직경 (mm)' },
|
||||
{ key: 'tieGradeMpa', label: '띠·나선철근 강도 fy (MPa)' },
|
||||
{ key: 'tieSpacingMm', label: '일반구간 띠 간격·나선 피치 (mm)' },
|
||||
{ key: 'endZoneLengthMm', label: '단부 보강구간 길이 (mm)' },
|
||||
{ key: 'endZoneSpacingMm', label: '단부 보강구간 간격 (mm)' },
|
||||
{ key: 'cageLengthMm', label: '말뚝 철근망 길이 (mm)', only: 'pile' },
|
||||
{ key: 'endCoverMm', label: '말뚝 선단 순피복 (mm)', only: 'pile' },
|
||||
{ key: 'topEmbedmentMm', label: '코핑 내부 주철근 연장 길이 (mm)', only: 'column' },
|
||||
{ key: 'innerMainDiameterMm', label: '내측 주철근 공칭지름 (mm)' },
|
||||
{ key: 'innerMainCount', label: '내측 주철근 본수 (본)' },
|
||||
];
|
||||
|
||||
/**
|
||||
* Column–pile connection when none is saved (현장타설말뚝철근.md §5, Q-PL3·Q-PL6 결정 2026-11-06): the pile cage follows the
|
||||
* rock socket, so with a socket (or a pile diameter other than D) the two cages differ and they are joined by a lap of
|
||||
* l_s = 1.3·l_d of the main bar; otherwise by a coupler at ground. A saved mode is kept.
|
||||
*/
|
||||
export function castAutoConnection(input: Pick<PierInput, 'castInPlaceShape' | 'columnDiameterMm'>, part: CastRebarPart): NonNullable<CastInPlaceRebar['connection']> {
|
||||
const d = input.columnDiameterMm ?? 2000;
|
||||
return castSocketDiameterMm(input) !== d
|
||||
? { mode: 'lap', lapLengthMm: Math.round(spliceLength(part.mainDiameterMm!, part.mainGradeMpa!, part.concreteStrengthMpa!)) }
|
||||
: { mode: 'coupler' };
|
||||
}
|
||||
|
||||
export function castRebarDefaults(input: Pick<PierInput, 'columnRebar' | 'castInPlaceRebar' | 'castInPlaceShape' | 'columnDiameterMm'>, material: Material = DEFAULT_MATERIAL): CastInPlaceRebar {
|
||||
const c = { ...DEFAULT_COLUMN_REBAR, ...input.columnRebar };
|
||||
const saved = input.castInPlaceRebar ?? {};
|
||||
const part = (key: 'column' | 'pile'): CastRebarPart => ({
|
||||
mainDiameterMm: NOMINAL_DIAMETER.S32, mainCount: c.nDiv, mainGradeMpa: material.grade.S,
|
||||
innerMainDiameterMm: NOMINAL_DIAMETER.S32, innerMainCount: c.nDiv,
|
||||
coverMm: c.c, coverReference: saved[key]?.coverMm !== undefined && saved[key]?.coverReference === undefined ? 'clear' : 'main-center',
|
||||
concreteStrengthMpa: material.fck,
|
||||
tieDiameterMm: NOMINAL_DIAMETER.H19, tieGradeMpa: material.grade.H, tieSpacingMm: c.ctcP3,
|
||||
endZoneSpacingMm: c.ctcP5, tieKind: 'hoop',
|
||||
...(key === 'pile' ? { endCoverMm: 250 } : {}),
|
||||
...saved[key],
|
||||
});
|
||||
const column = part('column');
|
||||
const auto = castAutoConnection(input, column);
|
||||
const connection = { couplerLengthMm: 150, ...auto, ...saved.connection };
|
||||
// A lap saved without a length gets the l_s of the main bar.
|
||||
if (connection.mode === 'lap' && connection.lapLengthMm === undefined) connection.lapLengthMm = Math.round(spliceLength(column.mainDiameterMm!, column.mainGradeMpa!, column.concreteStrengthMpa!));
|
||||
return { column, pile: part('pile'), connection };
|
||||
}
|
||||
|
||||
export const CAST_PENDING = ['현장타설 기둥 배근 상세 미확정', '현장타설말뚝 배근 상세 미확정', '기둥·말뚝 접합부 배근 상세 미확정'];
|
||||
|
||||
/** Storage and input validation; domain-specific spacing/anchorage rules remain unconfirmed. */
|
||||
export function castSettingsIssues(value: unknown, pileLengthMm?: number, diameterMm?: number): string[] {
|
||||
if (value === undefined) return [];
|
||||
const issues: string[] = [];
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return ['현장타설 배근 입력 형식이 맞지 않습니다.'];
|
||||
const record = value as Record<string, unknown>;
|
||||
if (Object.keys(record).some(k => !['column', 'pile', 'connection'].includes(k))) issues.push('알 수 없는 현장타설 배근 항목입니다.');
|
||||
for (const part of ['column', 'pile', 'connection'] as const) {
|
||||
const values = record[part];
|
||||
if (values === undefined) continue;
|
||||
if (!values || typeof values !== 'object' || Array.isArray(values)) { issues.push(`${part}: 입력 형식이 맞지 않습니다.`); continue; }
|
||||
for (const [key, v] of Object.entries(values)) {
|
||||
if (v === undefined) continue;
|
||||
if (part === 'connection' && key === 'mode') {
|
||||
if (typeof v !== 'string' || !['continuous', 'lap', 'coupler'].includes(v)) issues.push('접합 방식이 맞지 않습니다.');
|
||||
} else if (part !== 'connection' && key === 'tieKind') {
|
||||
if (typeof v !== 'string' || !['hoop', 'spiral'].includes(v)) issues.push('횡철근 형식이 맞지 않습니다.');
|
||||
} else if (part !== 'connection' && key === 'coverReference') {
|
||||
if (typeof v !== 'string' || !['clear', 'main-center'].includes(v)) issues.push('피복 기준이 맞지 않습니다.');
|
||||
} else {
|
||||
const allowed = part === 'connection' ? ['lapLengthMm', 'couplerLengthMm'] : CAST_PART_FIELDS.filter(f => !f.only || f.only === part).map(f => f.key);
|
||||
if (!allowed.includes(key) || typeof v !== 'number' || !Number.isFinite(v) || v <= 0 || (key.endsWith('Count') && !Number.isInteger(v))) issues.push(`${part}.${key}: 양수${key.endsWith('Count') ? ' 정수' : ''}로 입력하세요.`);
|
||||
}
|
||||
}
|
||||
if (part === 'pile') {
|
||||
const p = values as CastRebarPart;
|
||||
if (pileLengthMm !== undefined && p.cageLengthMm !== undefined && p.cageLengthMm > pileLengthMm) issues.push('말뚝 철근망 길이가 말뚝 길이보다 큽니다.');
|
||||
}
|
||||
if (part !== 'connection') {
|
||||
const p = values as CastRebarPart;
|
||||
if (diameterMm !== undefined && p.coverMm !== undefined && p.coverMm * 2 >= diameterMm) issues.push(`${part}: 피복이 단면 반지름 이상입니다.`);
|
||||
}
|
||||
}
|
||||
return issues;
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
import type { PierModel } from '../geometry';
|
||||
import { castLowerZone, castSocketDiameterMm } from '../cast-in-place-shape';
|
||||
import { castRebarDefaults, castSettingsIssues, type CastRebarPart } from './cast-in-place';
|
||||
import { DEFAULT_MATERIAL, developmentLength, hookLength, spliceLength, type Material } from './common';
|
||||
|
||||
export type CastPoint = [number, number, number];
|
||||
/** One physical piece. Coordinates use the existing rebar scene frame, in metres. */
|
||||
export interface CastBar {
|
||||
name: string;
|
||||
member: 'column' | 'pile';
|
||||
kind: 'main' | 'tie' | 'crosstie' | 'coupler';
|
||||
k: number;
|
||||
pts: CastPoint[];
|
||||
diameterMm: number;
|
||||
gradeMpa: number;
|
||||
lengthMm: number;
|
||||
note: string;
|
||||
columnIndex: number;
|
||||
}
|
||||
export interface CastLayout { bars: CastBar[]; missing: string[] }
|
||||
|
||||
/** Shared by the renderer, drawings and quantities; no meshes or UI state here. */
|
||||
export function castRebarLayout(model: PierModel, material: Material = DEFAULT_MATERIAL): CastLayout {
|
||||
const bars: CastBar[] = [];
|
||||
const missing = new Set<string>();
|
||||
if (!model.castInput) return { bars, missing: [] };
|
||||
const input = model.castInput;
|
||||
const saved = castRebarDefaults(input, material);
|
||||
const issues = castSettingsIssues(saved, input.pileLengthMm, model.columnDiameterMm);
|
||||
if (issues.length) return { bars, missing: issues };
|
||||
const parts = ['column', 'pile'] as const;
|
||||
const names = { column: '기둥', pile: '말뚝' };
|
||||
const joint = model.groundEl;
|
||||
const D = model.columnDiameterMm;
|
||||
const pitch = model.columnRebar.ctcCoupler;
|
||||
const stagger = model.columnRebar.dzCp2;
|
||||
const mode = saved.connection!.mode;
|
||||
const couplingLength = saved.connection!.couplerLengthMm;
|
||||
const lap = saved.connection!.lapLengthMm;
|
||||
let owner = 0;
|
||||
const add = (bar: Omit<CastBar, 'columnIndex'>) => {
|
||||
if (bar.lengthMm > 0 && Number.isFinite(bar.lengthMm)) bars.push({ ...bar, columnIndex: owner });
|
||||
};
|
||||
// The pile cage follows the rock socket D_s (현장타설말뚝철근.md §5, Q-PL3 결정 2026-11-06); the column cage follows D.
|
||||
const section = { column: D, pile: castSocketDiameterMm({ ...input, columnDiameterMm: D }) };
|
||||
const radii = (p: CastRebarPart, diameter: number) => {
|
||||
const outer = diameter / 2 - p.coverMm! - (p.coverReference === 'clear' ? p.tieDiameterMm! + p.mainDiameterMm! / 2 : 0);
|
||||
return { outer, inner: outer - (p.mainDiameterMm! + p.innerMainDiameterMm!) / 2, tie: outer + (p.mainDiameterMm! + p.tieDiameterMm!) / 2 };
|
||||
};
|
||||
const r = { column: radii(saved.column!, section.column), pile: radii(saved.pile!, section.pile) };
|
||||
const aligned = (['outer', 'inner'] as const).every((layer, i) => {
|
||||
const count = i ? 'innerMainCount' : 'mainCount';
|
||||
const diameter = i ? 'innerMainDiameterMm' : 'mainDiameterMm';
|
||||
return saved.column![count] === saved.pile![count] && saved.column![diameter] === saved.pile![diameter]
|
||||
&& saved.column!.mainGradeMpa === saved.pile!.mainGradeMpa && Math.abs(r.column[layer] - r.pile[layer]) < 0.01;
|
||||
});
|
||||
// A lap joins cages of different radius (비접촉 겹침, RB-PL03); a coupler or a continuous bar needs the same bars on one circle.
|
||||
if (!aligned && mode !== 'lap') missing.add('접합부 주철근 본수·규격·강도·축 위치 불일치 · 연결 미계산');
|
||||
const connected = mode === 'lap' ? !!lap : aligned && (mode === 'continuous' || mode === 'coupler' && !!couplingLength);
|
||||
if (!mode || mode === 'coupler' && !couplingLength || mode === 'lap' && !lap) missing.add('접합부 연결 방식 또는 연결 길이 미입력');
|
||||
if (connected && mode === 'lap') {
|
||||
// RB-PL07: the two circles of a non-contact lap at most min(l_s/5, 150) apart.
|
||||
const gap = Math.abs(r.column.outer - r.pile.outer);
|
||||
if (gap > Math.min(lap! / 5, 150) + 1e-6) missing.add(`RB-PL07 기둥–말뚝 겹침 두 철근 원 간격 ${Math.round(gap)} mm > min(l_s/5, 150) · 비접촉 겹침 한계 초과`);
|
||||
}
|
||||
missing.add('말뚝 저부 P6 검토안 미적용');
|
||||
|
||||
model.columns.forEach((column, columnIndex) => {
|
||||
owner = columnIndex;
|
||||
const pile = model.piles[columnIndex];
|
||||
if (!pile) { missing.add('기둥에 대응하는 현장타설말뚝 없음'); return; }
|
||||
const g = column.s + model.stretches[column.k].base;
|
||||
const segment = model.segments.find(s => g >= s.s0 && g <= s.s1) ?? model.segments[0];
|
||||
const c3 = model.copingRefTop - 0.127 - 2 * model.copingRebar.mainLayerGap / 1000;
|
||||
const point = (radius: number, angle: number, height: number): CastPoint => [column.s + radius / 1000 * Math.cos(angle), height, radius / 1000 * Math.sin(angle)];
|
||||
const bottomCover = saved.pile!.endCoverMm!;
|
||||
const mainRadius = Math.max(saved.pile!.mainDiameterMm!, saved.pile!.innerMainDiameterMm!) / 2;
|
||||
const cageBottom = saved.pile!.cageLengthMm === undefined ? pile.bottom + (bottomCover + mainRadius) / 1000 : joint - saved.pile!.cageLengthMm / 1000;
|
||||
if (cageBottom < pile.bottom + (bottomCover + mainRadius) / 1000 - 1e-8 || cageBottom >= joint) {
|
||||
missing.add('말뚝 철근망 하단이 선단 피복 또는 말뚝 범위를 벗어남');
|
||||
}
|
||||
const pileValid = cageBottom >= pile.bottom + (bottomCover + mainRadius) / 1000 - 1e-8 && cageBottom < joint;
|
||||
const ext = saved.column!.topEmbedmentMm;
|
||||
const tops = [ext === undefined ? c3 - 0.1 : column.top + ext / 1000,
|
||||
column.top + (ext ?? developmentLength(saved.column!.innerMainDiameterMm!, saved.column!.mainGradeMpa!, saved.column!.concreteStrengthMpa!)) / 1000];
|
||||
const validRadii = (part: 'column' | 'pile') => {
|
||||
const p = saved[part]!;
|
||||
return r[part].inner > p.innerMainDiameterMm! / 2 && r[part].tie + p.tieDiameterMm! / 2 <= section[part] / 2 + 1e-8;
|
||||
};
|
||||
for (const part of parts) {
|
||||
if (!validRadii(part)) { missing.add(`${names[part]} 철근 단면·피복 범위 오류`); continue; }
|
||||
if (part === 'pile' && !pileValid) continue;
|
||||
const p = saved[part]!;
|
||||
if (p.mainCount! > 2000 || p.innerMainCount! > 2000) { missing.add(`${names[part]} 주철근 본수 표시 한도 초과`); continue; }
|
||||
for (const [layerIndex, layer] of (['outer', 'inner'] as const).entries()) {
|
||||
const diameter = layerIndex ? p.innerMainDiameterMm! : p.mainDiameterMm!;
|
||||
const count = layerIndex ? p.innerMainCount! : p.mainCount!;
|
||||
const top = tops[layerIndex];
|
||||
if (part === 'column' && (top <= joint || top + diameter / 2000 > segment.top)) {
|
||||
missing.add(`${names[part]} P${layerIndex + 1} 상단 정착이 코핑 범위를 벗어남`); continue;
|
||||
}
|
||||
const offset = layerIndex ? stagger / 1000 : 0;
|
||||
const boundary = connected && mode === 'coupler' ? joint + offset : joint;
|
||||
// Lap: the column bars run down past ground by l_s into the pile (현장타설말뚝철근.md §5).
|
||||
const start = part === 'column' ? (connected && mode === 'lap' ? joint - lap! / 1000 : boundary) : cageBottom;
|
||||
const end = part === 'column' ? top : boundary;
|
||||
const combined = connected && mode === 'continuous' && pileValid && validRadii('pile');
|
||||
if (combined) missing.add('연속 주철근 제작·시공 이음 상세 미확정');
|
||||
if (combined && part === 'pile') continue;
|
||||
const lo = combined ? cageBottom : start;
|
||||
if (end <= lo) { missing.add(`${names[part]} 주철근 유효 길이 없음`); continue; }
|
||||
const cuts: number[] = [];
|
||||
// Periodic column joints reuse the existing approved pitch and stagger; no footing reference.
|
||||
if ((mode === 'coupler' || mode === 'lap') && couplingLength && pitch > 0) {
|
||||
for (let h = joint + offset + pitch / 1000; h < end - 2.5; h += pitch / 1000) if (h > lo) cuts.push(h);
|
||||
for (let h = joint + offset - pitch / 1000; h > lo + couplingLength / 1000; h -= pitch / 1000) if (h < end) cuts.push(h);
|
||||
}
|
||||
const levels = [lo, ...cuts.sort((a, b) => a - b), end];
|
||||
for (let i = 0; i < count; i++) {
|
||||
const angle = 2 * Math.PI * i / count;
|
||||
const name = `${combined ? '기둥·말뚝' : names[part]}-P${layerIndex + 1}`;
|
||||
levels.slice(1).forEach((h, j) => add({ name, member: part, kind: 'main', k: column.k,
|
||||
pts: [point(r[part][layer], angle, levels[j]), point(r[part][layer], angle, h)], diameterMm: diameter,
|
||||
gradeMpa: p.mainGradeMpa!, lengthMm: (h - levels[j]) * 1000, note: combined ? '연속 주철근 · 기초 갈고리 없음' : '현장타설 전용 주철근 · 마디별 집계' }));
|
||||
const coupler = (h: number, label: string) => {
|
||||
if (couplingLength! > pitch || h - couplingLength! / 2000 < cageBottom || h + couplingLength! / 2000 > top) {
|
||||
missing.add('주철근 커플러 길이가 배치 가능 범위를 벗어남'); return;
|
||||
}
|
||||
if (Math.abs(diameter - 31.8) > 0.05) missing.add('주철근 커플러 외경은 표시 가정 · 실제 규격 확인 필요');
|
||||
add({ name: label, member: part, kind: 'coupler', k: column.k,
|
||||
pts: [point(r[part][layer], angle, h - couplingLength! / 2000), point(r[part][layer], angle, h + couplingLength! / 2000)],
|
||||
diameterMm: diameter * 60 / 31.8, gradeMpa: p.mainGradeMpa!, lengthMm: couplingLength!, note: '주철근 중심 맞댐 · 외경은 기존 D32용 비율의 표시 가정' });
|
||||
};
|
||||
for (const h of cuts) coupler(h, `${names[part]}-P${layerIndex + 1} 커플러`);
|
||||
if (part === 'column' && connected && mode === 'coupler' && pileValid && validRadii('pile')) coupler(boundary, `접합-P${layerIndex + 1} 커플러`);
|
||||
}
|
||||
}
|
||||
const upper = part === 'column' ? (column.top + segment.top) / 2 : joint;
|
||||
const lower = part === 'column' ? joint : cageBottom + p.tieDiameterMm! / 2000;
|
||||
let zone: [number, number] | undefined;
|
||||
if (part === 'column' && p.endZoneLengthMm !== undefined) zone = [Math.max(lower, column.top - p.endZoneLengthMm / 1000), upper];
|
||||
if (part === 'pile') {
|
||||
const geo = castLowerZone(input);
|
||||
const bottom = joint - (input.castInPlaceShape?.betaLengthMm ?? 9000) / 1000 - 3 * D / 1000;
|
||||
if (p.endZoneLengthMm !== undefined) zone = [bottom, bottom + p.endZoneLengthMm / 1000];
|
||||
else if (geo?.valid) zone = [geo.bottomEl, geo.topEl];
|
||||
else if (geo) missing.add('말뚝 하단 단부구역 경계 역전');
|
||||
}
|
||||
if (!zone) missing.add(`${names[part]} 단부구간 경계 미입력 · 일반 횡철근만 계산`);
|
||||
if (zone && (zone[0] < lower || zone[1] > upper || zone[1] <= zone[0])) {
|
||||
missing.add(`${names[part]} 단부구간이 철근망 범위를 벗어남`); zone = undefined;
|
||||
}
|
||||
const radius = r[part].tie;
|
||||
const tieNote = '일반/단부 경계에서 구간별 반간격 시작 · 겹침 원형 이음';
|
||||
// Pile hoops inside the lap wrap the outer column bars (현장타설말뚝철근.md §6).
|
||||
const lapBottom = part === 'pile' && connected && mode === 'lap' ? joint - lap! / 1000 : Infinity;
|
||||
const tieRadius = (h: number) => (h >= lapBottom - 1e-9 ? r.column.tie : radius);
|
||||
const intervals: { from: number; to: number; step: number; mark: string }[] = [];
|
||||
const ordinary = (from: number, to: number) => { if (to > from) intervals.push({ from, to, step: p.tieSpacingMm!, mark: 'P3' }); };
|
||||
const zones = zone ? [zone] : [];
|
||||
// A column upper-end region that crosses ground continues on the pile side, with pile-section properties.
|
||||
const columnEnd = saved.column!.endZoneLengthMm;
|
||||
if (part === 'pile' && columnEnd !== undefined && column.top - columnEnd / 1000 < joint) {
|
||||
zones.push([Math.max(lower, column.top - columnEnd / 1000), upper]);
|
||||
}
|
||||
zones.sort((a, b) => a[0] - b[0]);
|
||||
const merged: [number, number][] = [];
|
||||
for (const next of zones) {
|
||||
const last = merged.at(-1);
|
||||
if (last && next[0] <= last[1]) last[1] = Math.max(last[1], next[1]);
|
||||
else merged.push([...next]);
|
||||
}
|
||||
let cursor = lower;
|
||||
for (const dense of merged) {
|
||||
ordinary(cursor, dense[0]); intervals.push({ from: dense[0], to: dense[1], step: p.endZoneSpacingMm!, mark: 'P5' }); cursor = dense[1];
|
||||
}
|
||||
ordinary(cursor, upper);
|
||||
for (const interval of intervals) {
|
||||
const { from, to, step, mark } = interval;
|
||||
if (step <= 0 || !Number.isFinite(step)) continue;
|
||||
if ((to - from) * 1000 / step > 5000) { missing.add(`${names[part]} 횡철근 개수 과다 · 간격 확인 필요`); continue; }
|
||||
const lapLength = mark === 'P5' ? 0 : spliceLength(p.tieDiameterMm!, p.tieGradeMpa!, p.concreteStrengthMpa!);
|
||||
if (lapLength >= 2 * Math.PI * radius) { missing.add(`${names[part]} 횡철근 겹침길이가 원주 이상 · 재료 입력 확인 필요`); continue; }
|
||||
const makeTie = (pts: CastPoint[], lengthMm: number, note: string) => add({ name: `${names[part]}-${mark}${p.tieKind === 'spiral' ? ' 나선' : ''}`,
|
||||
member: part, kind: 'tie', k: column.k, pts, diameterMm: p.tieDiameterMm!, gradeMpa: p.tieGradeMpa!, lengthMm, note });
|
||||
const a = from + step / 2000;
|
||||
const b = to - p.tieDiameterMm! / 2000;
|
||||
if (b <= a) { missing.add(`${names[part]} 횡철근 구간 길이 부족`); continue; }
|
||||
if (p.tieKind === 'spiral') {
|
||||
const turns = (b - a) * 1000 / step;
|
||||
const n = Math.ceil(turns * 32);
|
||||
const pts = Array.from({ length: n + 1 }, (_, j) => point(radius, 2 * Math.PI * turns * j / n, a + (b - a) * j / n));
|
||||
makeTie(pts, Math.hypot(2 * Math.PI * radius * turns, (b - a) * 1000), '나선 중심선 · 단부 정착·구간 전이 상세 미확정');
|
||||
missing.add(`${names[part]} 나선철근 단부 정착·구간 전이 미확정`);
|
||||
} else {
|
||||
for (let h = a, index = 0; h <= b + 1e-9; h += step / 1000, index++) {
|
||||
const rad = tieRadius(h);
|
||||
const angleSpan = 2 * Math.PI + lapLength / rad;
|
||||
const pts = Array.from({ length: 65 }, (_, j) => point(rad, index % 2 * Math.PI + angleSpan * j / 64, h));
|
||||
makeTie(pts, 2 * Math.PI * rad + lapLength, mark === 'P5' ? '단부 원형 띠 · 기존 H19용 커플러 폐합' : rad !== radius ? `${tieNote} · 겹침이음 구간, 바깥 기둥 주철근을 감쌈` : tieNote);
|
||||
if (mark === 'P5') {
|
||||
const angle = index % 2 * Math.PI + Math.PI / p.mainCount!;
|
||||
const half = 50 / rad;
|
||||
add({ name: `${names[part]}-P5 띠철근 커플러`, member: part, kind: 'coupler', k: column.k,
|
||||
pts: [point(rad, angle - half, h), point(rad, angle + half, h)], diameterMm: 40,
|
||||
gradeMpa: p.tieGradeMpa!, lengthMm: 100, note: '기존 H19용 L100·Ø40 폐합 상세' });
|
||||
if (Math.abs(p.tieDiameterMm! - 19.1) > 0.05) missing.add(`${names[part]} 단부 띠철근 커플러 규격 확인 필요`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Only the column crossties are reused. Pile-bottom U bars remain a separate pending design.
|
||||
if (part === 'column') {
|
||||
if (p.mainCount! < 8 || p.mainCount! % 2) { missing.add('기둥 P6 대칭쌍 배치에는 8본 이상 짝수 외측 주철근이 필요'); continue; }
|
||||
const diameter = 15.9;
|
||||
const hook = Math.round(hookLength('tie90', diameter));
|
||||
for (let h = lower + Math.max(p.tieSpacingMm! / 2, hook + diameter / 2) / 1000; h + 3.5 * diameter / 1000 <= upper; h += model.columnRebar.ctcP6 / 1000) {
|
||||
for (let j = 0; j < 4; j++) {
|
||||
const index = Math.round(j * p.mainCount! / 8);
|
||||
const angle = index * 2 * Math.PI / p.mainCount!;
|
||||
const y = h + j * diameter / 1000;
|
||||
const a = point(radius, angle, y), b = point(radius, angle + Math.PI, y);
|
||||
add({ name: '기둥-P6 크로스타이', member: part, kind: 'crosstie', k: column.k,
|
||||
pts: [[a[0], a[1] - hook / 1000, a[2]], a, b, [b[0], b[1] - hook / 1000, b[2]]],
|
||||
diameterMm: diameter, gradeMpa: material.grade.H, lengthMm: 2 * radius + 2 * hook,
|
||||
note: '기둥 크로스타이 @기존 P6간격 · 말뚝 저부 P6와 별개' });
|
||||
}
|
||||
}
|
||||
missing.add('기둥 P6 시작단·적층·굽힘 상세 확인 필요');
|
||||
}
|
||||
}
|
||||
});
|
||||
return { bars, missing: [...missing] };
|
||||
}
|
||||
@@ -5,6 +5,7 @@ export type RebarMemberKey = 'bearing' | 'coping' | 'column' | 'footing' | 'othe
|
||||
|
||||
/** Member of a bar from its 철근표 name (C = coping, P = column, B = footing, S = bearing block, 커플러 = column). */
|
||||
export function memberOfBar(name: string): RebarMemberKey {
|
||||
if (/^(기둥|말뚝|접합)/.test(name)) return 'column';
|
||||
if (name.includes('커플러') || /^P\d/.test(name)) return 'column';
|
||||
if (/^C\d/.test(name)) return 'coping';
|
||||
if (/^B\d/.test(name)) return 'footing';
|
||||
|
||||
@@ -39,6 +39,8 @@ export interface ColumnInput {
|
||||
/** P4 구간 깊이 비율 r_P4. */
|
||||
rP4: number;
|
||||
ctcP6: number;
|
||||
/** 다주형 삽도의 상·하단부 합집합. 번호는 기존 역할 대응을 유지한다. */
|
||||
bothEndZones?: boolean;
|
||||
}
|
||||
|
||||
/** 예제 운곡교 P1 (기둥철근.md §1.2 기본값). */
|
||||
@@ -47,18 +49,23 @@ export interface ColumnInput {
|
||||
* h_cop = 계단 상면(기둥 중심 구간의 상면) − Level 하면 (2.644 on P1, 43.555 − 40.911). Only these two follow the model;
|
||||
* the other values stay as in COLUMN_P1.
|
||||
*/
|
||||
export function columnInputFor(model: PierModel, base: ColumnInput = COLUMN_P1): ColumnInput {
|
||||
const col = model.columns[0];
|
||||
const seg = model.segments.find((g) => col.s >= g.s0 - 1e-9 && col.s <= g.s1 + 1e-9) ?? model.segments[0];
|
||||
const Hc = Math.round((model.levelBottom - (model.footings[0]?.top ?? col.bottom)) * 1000 * 10) / 10;
|
||||
const hcop = Math.round((seg.top - model.levelBottom) * 1000 * 10) / 10;
|
||||
export function columnInputFor(model: PierModel, base: ColumnInput = COLUMN_P1, columnIndex = 0): ColumnInput {
|
||||
// No footing (현장타설말뚝, merged 2026-11-06 with origin's guard): Hc still runs to the column bottom (= 지반고) and Hf is 0.
|
||||
// The cast-in-place rebar itself comes from cast-layout.ts; callers only use the footing rules when a footing exists.
|
||||
const col = model.columns[columnIndex];
|
||||
const globalS = col.s + model.stretches[col.k].base;
|
||||
const seg = model.segments.find((g) => globalS >= g.s0 - 1e-9 && globalS <= g.s1 + 1e-9) ?? model.segments[0];
|
||||
const footing = model.footings.find(f => f.k === col.k && Math.abs(f.s - col.s) <= f.length / 2);
|
||||
const Hc = Math.round((col.top - col.bottom) * 1000 * 10) / 10;
|
||||
const hcop = Math.round((seg.top - col.top) * 1000 * 10) / 10;
|
||||
// C3 (상면 3단) = the lowest coping top −127 (C1 cover) −2·s_lay (상면 주철근 단 간격 input); P1 was 43.170 at 57.2.
|
||||
// P1 top = C3 − 100 (계산 2026-10-23), so e_P1 = step top − (C3 − 0.1) = 485 on P1.
|
||||
const c3 = model.copingRefTop - 0.127 - 2 * (model.copingRebar.mainLayerGap / 1000);
|
||||
const eP1 = Math.round((seg.top - (c3 - 0.1)) * 1000);
|
||||
// e_top: the coping middle level (Level 하면 + 계단 상면)/2 is the top of P3 and P6 (승인 2026-10-23).
|
||||
const eTop = Math.round((seg.top - (model.levelBottom + seg.top) / 2) * 1000);
|
||||
return { ...base, ...model.columnRebar, footing: model.footingRebar, Hc, hcop, eP1, eTop, D: model.columnDiameterMm };
|
||||
const eTop = Math.round((seg.top - (col.top + seg.top) / 2) * 10000) / 10;
|
||||
return { ...base, ...model.columnRebar, footing: model.footingRebar, Hc, hcop, eP1, eTop, D: model.columnDiameterMm,
|
||||
Hf: footing ? (footing.top - footing.bottom) * 1000 : 0, bothEndZones: model.columns.length > 1 };
|
||||
}
|
||||
|
||||
export const COLUMN_P1: ColumnInput = {
|
||||
@@ -142,9 +149,17 @@ export function columnRebar(x: ColumnInput = COLUMN_P1, m: Material = DEFAULT_MA
|
||||
const lEmin = Math.max(x.D, zTopT / 6, 450);
|
||||
const n5 = Math.floor(lEmin / x.ctcP5);
|
||||
const lE = Math.max(lEmin, n5 * x.ctcP5 + x.ctcP5 / 2);
|
||||
const p3 = Array.from({ length: Math.floor((zTopT - lE) / x.ctcP3) + 1 }, (_, k) => lE + k * x.ctcP3);
|
||||
let p3 = Array.from({ length: Math.max(0, Math.floor((zTopT - lE) / x.ctcP3) + 1) }, (_, k) => lE + k * x.ctcP3);
|
||||
const n5all = Math.floor(lE / x.ctcP5);
|
||||
const p5 = Array.from({ length: n5all }, (_, j) => lE - (j + 1) * x.ctcP5);
|
||||
let p5 = Array.from({ length: n5all }, (_, j) => lE - (j + 1) * x.ctcP5);
|
||||
if (x.bothEndZones) {
|
||||
// Drawing priority: at least D below the coping, plus half the coping height above it.
|
||||
// Existing lower-end minimum is retained. Overlapping ends become one dense interval.
|
||||
const upperStart = Math.max(0, x.Hc - lE);
|
||||
const intervals = upperStart <= lE ? [[0, zTopT]] : [[0, lE], [upperStart, zTopT]];
|
||||
p5 = intervals.flatMap(([a, b]) => Array.from({ length: Math.max(0, Math.floor((b - a) / x.ctcP5)) }, (_, i) => a + x.ctcP5 / 2 + i * x.ctcP5));
|
||||
p3 = upperStart <= lE ? [] : Array.from({ length: Math.max(0, Math.floor((upperStart - lE) / x.ctcP3)) }, (_, i) => lE + x.ctcP3 / 2 + i * x.ctcP3);
|
||||
}
|
||||
const z5last = lE - n5all * x.ctcP5;
|
||||
const m4 = Math.floor((z5last + x.rP4 * x.Hf) / x.ctcP4);
|
||||
const p4All = Array.from({ length: m4 }, (_, j) => z5last - (j + 1) * x.ctcP4);
|
||||
|
||||
@@ -84,15 +84,15 @@ export function materialFrom(raw: unknown): Material {
|
||||
}
|
||||
|
||||
/** Members that can take their own fck on ② (부재 카드). Other members always use the 공통 fck. */
|
||||
export type FckMember = 'column' | 'coping' | 'footing';
|
||||
export type FckMember = 'column' | 'coping' | 'footing' | 'abutFooting' | 'abutBody';
|
||||
/** The members of {@link FckMember}, for the ② cards. */
|
||||
export const FCK_MEMBERS: readonly FckMember[] = ['column', 'coping', 'footing'];
|
||||
export const FCK_MEMBERS: readonly FckMember[] = ['column', 'coping', 'footing', 'abutFooting', 'abutBody'];
|
||||
|
||||
/** Per-member fck (MPa). A member without an entry uses the ③ 공통 설정 fck. */
|
||||
export type FckByMember = Partial<Record<FckMember, number>>;
|
||||
|
||||
/** 교각도 기준 부재별 기본 fck (MPa). 되돌리기는 이 값으로 돌아가고, 이 값과 다르면 주황색으로 표시합니다. */
|
||||
export const IMPORTED_FCK: Record<FckMember, number> = { column: 40, coping: 40, footing: 27 };
|
||||
/** 교각도 · 교대도 기준 부재별 기본 fck (MPa). 되돌리기는 이 값으로 돌아가고, 이 값과 다르면 주황색으로 표시합니다. 교대 저판 27, 교대 벽체 · 흉벽 · 날개벽 · 접속슬래브 35 (spec_abut §6, 2026-11-03). */
|
||||
export const IMPORTED_FCK: Record<FckMember, number> = { column: 40, coping: 40, footing: 27, abutFooting: 27, abutBody: 35 };
|
||||
|
||||
/** True when the member's fck is set and differs from its imported default. An unset member is not changed. */
|
||||
export function fckChanged(byMember: FckByMember, member: FckMember): boolean {
|
||||
@@ -137,10 +137,16 @@ export function spliceLength(db: number, fy: number, fck: number, lambda = MATER
|
||||
/** Nominal diameters d (mm) of the bar sizes used in the documents (코핑철근.md, 기둥철근.md). */
|
||||
export const NOMINAL_DIAMETER = {
|
||||
D13: 12.7,
|
||||
/** 교대 벽체 W7 · W8 (교대벽체철근.md §1.2, 2026-11-04). */
|
||||
H13: 12.7,
|
||||
H16: 15.9,
|
||||
H19: 19.1,
|
||||
D22: 22.2,
|
||||
/** 교대 저판 B2·B4 (교대확대기초철근.md §1.3, 2026-11-04). */
|
||||
H22: 22.2,
|
||||
H25: 25.4,
|
||||
/** 교대 흉벽 다웰 P10 (교대흉벽철근.md §1.2, 2026-11-04). */
|
||||
D25: 25.4,
|
||||
H29: 28.6,
|
||||
S32: 31.8,
|
||||
D35: 34.9,
|
||||
|
||||
@@ -44,6 +44,13 @@ export const DEFAULT_COPING_REBAR: CopingRebarSettings = {
|
||||
c14LowerSpacing: 2000,
|
||||
};
|
||||
|
||||
/** 1106 ‘2주형 3주형’ 전용 삽도 우선(사용자 선택). 명시 입력은 이 기본값보다 우선한다. */
|
||||
export function copingDefaults(columnCount: number): CopingRebarSettings {
|
||||
// 다주형 (3주형형상배근.md §1.2): 피복 120 (Q-M6), 전단 @300 · 다리 8개 (Q-M3), 측면 @200, 크로스타이 연직 600.
|
||||
return columnCount > 1 ? { ...DEFAULT_COPING_REBAR, cover: 120, shearLegs: 8, spacingS: 300, spacingL: 300,
|
||||
sideSpacing: 200, c14UpperSpacing: 600, c14LowerSpacing: 600 } : { ...DEFAULT_COPING_REBAR };
|
||||
}
|
||||
|
||||
/** Allowed range per input (inclusive, mm). The shear legs are checked for evenness separately (CP02). */
|
||||
export const COPING_REBAR_RANGES: Record<keyof CopingRebarSettings, [number, number]> = {
|
||||
cover: [80, 150],
|
||||
@@ -56,8 +63,8 @@ export const COPING_REBAR_RANGES: Record<keyof CopingRebarSettings, [number, num
|
||||
spacingL: [200, 350],
|
||||
sideSpacing: [200, 300],
|
||||
sideEndGap: [300, 500],
|
||||
c14UpperSpacing: [800, 1200],
|
||||
c14LowerSpacing: [1500, 2500],
|
||||
c14UpperSpacing: [600, 1200],
|
||||
c14LowerSpacing: [600, 2500],
|
||||
};
|
||||
|
||||
/** 상면 주철근(C1–C6) 이음 (승인 2026-10-26): ignore = 이음 무시 (사양 기본), apply = 정척 초과 시 2조각 겹침. */
|
||||
|
||||
@@ -60,16 +60,17 @@ export function shearBars(coping: number, xc = coping / 2, g: CopingGeom = P1_CO
|
||||
|
||||
/**
|
||||
* Side horizontal bars (코핑철근.md §6): seven rows from the bottom at 250 mm.
|
||||
* Rows below the sloped-bottom break (z_7,end) are C12, the rows at and above it are C13.
|
||||
* C12 = 상단 (rows at and above the sloped-bottom break z_7,end, to the end face), C13 = 하단 변화구간 (rows below it,
|
||||
* cut by the sloped bottom) — 엑셀 Pier B357–G358, 2026-11-06 결정 (was the other way round).
|
||||
*/
|
||||
/**
|
||||
* Side rows on the 250 grid from zBottom (코핑철근.md §6.1). With zTop the grid runs up to zTop (상면 여유 400 mm);
|
||||
* without it the old seven rows are used. Rows below zBreak are C12 (taper band), the rest C13 (end band).
|
||||
* without it the old seven rows are used. Rows below zBreak are C13 (taper band, 하단 변화구간), the rest C12 (end band, 상단).
|
||||
*/
|
||||
export function sideRows(zBottom: number, zBreak: number, zTop?: number, rows = 7): { c12: number[]; c13: number[] } {
|
||||
const count = zTop === undefined ? rows : Math.floor((zTop - zBottom + 1e-6) / (COPING_REBAR.spacingSide / 1000)) + 1;
|
||||
const z = Array.from({ length: Math.max(0, count) }, (_, i) => Math.round((zBottom + i * COPING_REBAR.spacingSide / 1000) * 1000) / 1000);
|
||||
return { c12: z.filter((v) => v < zBreak - 1e-9), c13: z.filter((v) => v >= zBreak - 1e-9) };
|
||||
return { c12: z.filter((v) => v >= zBreak - 1e-9), c13: z.filter((v) => v < zBreak - 1e-9) };
|
||||
}
|
||||
|
||||
/** C14 counts (코핑철근.md §8): 2 upper lines of 11 plus the lower rows 5 / 6 / 3 / 2 = 38. */
|
||||
@@ -103,8 +104,8 @@ export function shearULength(x: number, coping: number, g: CopingGeom = P1_COPIN
|
||||
return SHEAR_U.flat + 2 * shearUHeight(x, coping, g);
|
||||
}
|
||||
|
||||
/** C13 sits 127 in from each end face (코핑철근.md §6.2). */
|
||||
export const SIDE_C13_END = 127;
|
||||
/** The upper rows (C12, 상단) sit 127 in from each end face (코핑철근.md §6.2). */
|
||||
export const SIDE_C12_END = 127;
|
||||
|
||||
/** Leg of each ㄷ형 bar along the side, 1.3·l_d = 1525 (Q-K9). */
|
||||
export const SIDE_HOOK_LEG = 1525;
|
||||
@@ -120,8 +121,8 @@ export interface SideBar {
|
||||
|
||||
/**
|
||||
* C12 and C13 rows for a coping of length L (mm), T형 (코핑철근.md §6.2).
|
||||
* C12 meets the sloped bottom at x_w = c + (z_break − z_k)·(x_L0 − c)/h2, with x_L0 = L/2 − W_L/2.
|
||||
* C13 starts at x_w = 127. Each row has two 일자 bars (side faces) and two ㄷ bars (one at each end).
|
||||
* C13 (하단 변화구간) meets the sloped bottom at x_w = c + (z_break − z_k)·(x_L0 − c)/h2, with x_L0 = L/2 − W_L/2.
|
||||
* C12 (상단) starts at x_w = 127. Each row has two 일자 bars (side faces) and two ㄷ bars (one at each end).
|
||||
*/
|
||||
export function copingSideBars(L: number, zBottom: number, zBreak: number, g: CopingGeom = P1_COPING_GEOM, zTop?: number): { c12: SideBar[]; c13: SideBar[] } {
|
||||
const c = COPING_REBAR.cover;
|
||||
@@ -129,8 +130,8 @@ export function copingSideBars(L: number, zBottom: number, zBreak: number, g: Co
|
||||
const bar = (z: number, xw: number): SideBar => ({ z, xw, straight: L - 2 * xw });
|
||||
const rows = sideRows(zBottom, zBreak, zTop);
|
||||
return {
|
||||
c12: rows.c12.map((z) => bar(z, c + ((zBreak - z) * 1000 * (xL0 - c)) / g.h2)),
|
||||
c13: rows.c13.map((z) => bar(z, SIDE_C13_END)),
|
||||
c12: rows.c12.map((z) => bar(z, SIDE_C12_END)),
|
||||
c13: rows.c13.map((z) => bar(z, c + ((zBreak - z) * 1000 * (xL0 - c)) / g.h2)),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,308 @@
|
||||
/**
|
||||
* 다주형(2·3주형) 코핑 철근 — RAG/3주형형상배근.md §4–§8 (2026-11-06, 2주형 DWG 실측 규칙, 번호 = 역할 Q-M1).
|
||||
* Pure. One physical piece per entry; 3D, 삽도, 철근표, 재료표 share these pieces. T형 keeps its own generator.
|
||||
*
|
||||
* Coordinates: x along the coping from BP1 (m), EL (m), y across the width from the axis (m, the scene's third axis).
|
||||
* Rules still open (Q-M2 밀집 구간, Q-M3 다리 8개, Q-M4 3주형 확장, Q-M5 측면 형상, Q-M6 피복, Q-M7 계단부, Q-M12 이음)
|
||||
* use the document's current assumptions and are flagged in the summary.
|
||||
*/
|
||||
import type { PierModel } from '../geometry';
|
||||
import { DEFAULT_MATERIAL, developmentLength, fyOf, hookLength, type Material, NOMINAL_DIAMETER, spliceLength } from './common';
|
||||
|
||||
export type CopingPoint = [number, number, number];
|
||||
export interface CopingBar { name: string; size: string; diameterMm: number; pts: CopingPoint[]; length: number }
|
||||
|
||||
/** Actual concrete lower envelope, including every haunch vertex. Coordinates are m, EL m. */
|
||||
export function copingEnvelope(model: PierModel) {
|
||||
const polygons = model.copingParts.filter(p => p.k === 0).map(p => p.profile);
|
||||
const xs = [...new Set(polygons.flatMap(p => p.map(v => v[0])))].sort((a, b) => a - b);
|
||||
const under = (x: number) => Math.max(...polygons.map(p => {
|
||||
const hits = p.flatMap((a, i) => {
|
||||
const b = p[(i + 1) % p.length];
|
||||
if (Math.abs(b[0] - a[0]) < 1e-9 || x < Math.min(a[0], b[0]) - 1e-9 || x > Math.max(a[0], b[0]) + 1e-9) return [];
|
||||
return [a[1] + (b[1] - a[1]) * (x - a[0]) / (b[0] - a[0])];
|
||||
});
|
||||
return Math.min(...hits);
|
||||
}));
|
||||
const slopes = xs.slice(1).map((x, i) => (under(x) - under(xs[i])) / (x - xs[i]));
|
||||
// Conservative vertical inset at a corner: both adjoining faces retain the requested normal cover.
|
||||
const inset = (x: number, cover: number) => under(x) + cover * Math.max(1, ...slopes.filter((_, i) => x >= xs[i] - 1e-9 && x <= xs[i + 1] + 1e-9).map(s => Math.hypot(1, s)));
|
||||
return { xs, under, inset };
|
||||
}
|
||||
|
||||
export function copingPathLength(pts: CopingPoint[]): number {
|
||||
return pts.slice(1).reduce((sum, p, i) => sum + Math.hypot(...p.map((v, j) => v - pts[i][j])), 0) * 1000;
|
||||
}
|
||||
|
||||
/** Document values that are not screen inputs yet (3주형형상배근.md §1.2, m unless noted). */
|
||||
export const MULTI_REBAR = {
|
||||
/** 밀집 간격 s_d (mm) and counts: outer (end side) / inner (span side) of a column zone (Q-M2). */
|
||||
denseSpacingMm: 150, denseOuter: 14, denseInner: 16,
|
||||
/** Column zone around the centre: outer columns a_o … a_i (span side +), middle columns ± a_m (Q-M2). */
|
||||
zoneOuter: 1.15, zoneInner: 1.25, zoneMiddle: 1.2,
|
||||
/** Gap between the two leg groups (Q-M3). */
|
||||
legGroupGap: 0.25,
|
||||
/** 상면 2단 끊는 위치 l_cut and C8 끝 l_8 from the column centre. */
|
||||
topCut: 4.45, c8End: 2.23,
|
||||
/** 정척 for the 상면 1단 이음 (Q-M9, mm). */
|
||||
standardMm: 8000,
|
||||
/** C14: vertical pitch, pitch along the axis, stagger between rows. */
|
||||
c14Vertical: 0.6, c14Along: 1.2, c14Stagger: 0.6,
|
||||
/** h_16: a step at least this much above the end top gets C15 (m). */
|
||||
stepMin: 0.1,
|
||||
} as const;
|
||||
|
||||
export interface MulticolInfo { bars: CopingBar[]; warnings: { code: string; message: string }[] }
|
||||
|
||||
/**
|
||||
* Shear positions along the coping (§4.1): end, dense, column zone, dense, span … (sorted, m). Positions outside the cover
|
||||
* lines are dropped; `short` lists spans where the dense zones meet or overlap (RB-M01) and `tight` spans whose equal bay
|
||||
* is below s_d (RB-M02).
|
||||
*/
|
||||
export function multicolShearLayout(L: number, columns: number[], c: number, s: number, sd: number): { positions: number[]; short: number[]; tight: number[] } {
|
||||
const xs = [...columns].sort((a, b) => a - b);
|
||||
const n = xs.length;
|
||||
const out: number[] = [];
|
||||
const run = (from: number, to: number, count: number) => { for (let j = 0; j <= count; j++) out.push(from + (to - from) * j / count); };
|
||||
const groups = xs.map((x, k) => {
|
||||
const zone: [number, number] = n === 1 ? [x - MULTI_REBAR.zoneMiddle, x + MULTI_REBAR.zoneMiddle]
|
||||
: k === 0 ? [x - MULTI_REBAR.zoneOuter, x + MULTI_REBAR.zoneInner]
|
||||
: k === n - 1 ? [x - MULTI_REBAR.zoneInner, x + MULTI_REBAR.zoneOuter]
|
||||
: [x - MULTI_REBAR.zoneMiddle, x + MULTI_REBAR.zoneMiddle];
|
||||
const left = k === 0 ? MULTI_REBAR.denseOuter : MULTI_REBAR.denseInner;
|
||||
const right = k === n - 1 ? MULTI_REBAR.denseOuter : MULTI_REBAR.denseInner;
|
||||
const start = zone[0] - left * sd, end = zone[1] + right * sd;
|
||||
run(start, zone[0], left);
|
||||
run(zone[0], zone[1], Math.max(1, Math.round((zone[1] - zone[0]) / s)));
|
||||
run(zone[1], end, right);
|
||||
return [start, end] as const;
|
||||
});
|
||||
// Ends: the bar at the cover line, then @s from the dense start; the remainder is the first space (§4.1).
|
||||
out.push(c, L - c);
|
||||
for (let x = groups[0][0] - s; x > c + 1e-6; x -= s) out.push(x);
|
||||
for (let x = groups[n - 1][1] + s; x < L - c - 1e-6; x += s) out.push(x);
|
||||
// Spans between dense zones: m = ceil(l / s) equal bays.
|
||||
const short: number[] = [], tight: number[] = [];
|
||||
for (let k = 1; k < n; k++) {
|
||||
const a = groups[k - 1][1], b = groups[k][0];
|
||||
if (b - a < 1e-6) { short.push(k); continue; }
|
||||
const m = Math.ceil((b - a) / s - 1e-9);
|
||||
if ((b - a) / m < sd - 1e-9) tight.push(k);
|
||||
for (let j = 1; j < m; j++) out.push(a + (b - a) * j / m);
|
||||
}
|
||||
if (groups[0][0] < c - 1e-9 || groups[n - 1][1] > L - c + 1e-9) short.push(0);
|
||||
const inside = out.filter(x => x >= c - 1e-9 && x <= L - c + 1e-9);
|
||||
return { positions: [...new Map(inside.map(x => [Math.round(x * 1e6), x])).values()].sort((a, b) => a - b), short, tight };
|
||||
}
|
||||
|
||||
/** Shear positions only (see multicolShearLayout). */
|
||||
export function multicolShearPositions(L: number, columns: number[], c: number, s: number, sd: number): number[] {
|
||||
return multicolShearLayout(L, columns, c, s, sd).positions;
|
||||
}
|
||||
|
||||
/** Leg positions across the width (§4.3): two groups of four, 250 apart; otherwise equal spacing. Closures by index. */
|
||||
export function multicolLegs(B: number, c: number, legs: number): { y: number[]; closures: [number, number][] } {
|
||||
if (legs === 8) {
|
||||
const p = (B - 2 * c - MULTI_REBAR.legGroupGap) / 6;
|
||||
const y = [0, 1, 2, 3].map(i => -B / 2 + c + i * p);
|
||||
y.push(...[0, 1, 2, 3].map(i => -B / 2 + c + 3 * p + MULTI_REBAR.legGroupGap + i * p));
|
||||
return { y, closures: [[0, 2], [1, 4], [3, 6], [5, 7]] };
|
||||
}
|
||||
const pitch = (B - 2 * c) / (legs - 1);
|
||||
const y = Array.from({ length: legs }, (_, i) => -B / 2 + c + i * pitch);
|
||||
return { y, closures: Array.from({ length: legs / 2 }, (_, i) => [i, i + legs / 2] as [number, number]) };
|
||||
}
|
||||
|
||||
/** Bars across the width: the edge bars at the cover line and `spaces` bays of `pitch` between, leftover split (§5.1). */
|
||||
function widthBars(B: number, c: number, pitch: number): number[] {
|
||||
const spaces = Math.floor((B - 2 * c) / pitch + 1e-9) - 2;
|
||||
const g = (B - 2 * c - spaces * pitch) / 2;
|
||||
return [-B / 2 + c, ...Array.from({ length: spaces + 1 }, (_, i) => -B / 2 + c + g + i * pitch), B / 2 - c];
|
||||
}
|
||||
|
||||
/** Shared physical pieces for multi-column 3D and quantities, with the RB-M warnings of §11. */
|
||||
export function multicolCopingInfo(model: PierModel, material: Material = DEFAULT_MATERIAL): MulticolInfo {
|
||||
const bars: CopingBar[] = [];
|
||||
const warnings: MulticolInfo['warnings'] = [];
|
||||
const L = model.copingL, B = model.copingB;
|
||||
const settings = model.copingRebar;
|
||||
if ([settings.spacingS, settings.sideSpacing, settings.mainSpacing, settings.mainLayerGap, settings.cover].some(v => !Number.isFinite(v) || v <= 0)
|
||||
|| !Number.isInteger(settings.shearLegs) || settings.shearLegs < 2 || settings.shearLegs > 8 || settings.shearLegs % 2) {
|
||||
throw new Error('다주형 배근 간격은 양수, 전단 다리 수는 2~8의 짝수여야 합니다.');
|
||||
}
|
||||
const c = settings.cover / 1000, s = settings.spacingS / 1000, sd = MULTI_REBAR.denseSpacingMm / 1000;
|
||||
const envelope = copingEnvelope(model);
|
||||
const add = (name: string, size: keyof typeof NOMINAL_DIAMETER, pts: CopingPoint[]) => {
|
||||
if (pts.length < 2) return;
|
||||
bars.push({ name, size, diameterMm: NOMINAL_DIAMETER[size], pts, length: copingPathLength(pts) });
|
||||
};
|
||||
const columns = model.columns.map(col => col.s).sort((a, b) => a - b);
|
||||
const lw = model.copingGeom.levelWidth / 1000;
|
||||
const levelBottom = model.levelBottom;
|
||||
const hr = Math.max(0, ...envelope.xs.map(x => envelope.under(x) - levelBottom).filter(v => v < (model.endBottom - levelBottom) - 1e-6));
|
||||
const steps = [...model.segments, ...(model.backSegments ?? [])];
|
||||
const stepTop = (x: number) => Math.min(...steps.filter(g => x >= g.s0 - 1e-9 && x <= g.s1 + 1e-9).map(g => g.top));
|
||||
const d = (size: keyof typeof NOMINAL_DIAMETER) => NOMINAL_DIAMETER[size];
|
||||
const fy = (size: string) => fyOf(material, size);
|
||||
|
||||
// --- 전단 C9 · C10 · C11 (§4) -------------------------------------------------------------------
|
||||
const legs = multicolLegs(B, c, settings.shearLegs);
|
||||
const ds = d('H22') / 1000;
|
||||
const shearName = (x: number) => {
|
||||
const rel = envelope.under(x) - levelBottom;
|
||||
if (Math.abs(rel) < 1e-6) return 'C10';
|
||||
if (hr > 0 && Math.abs(rel - hr) < 1e-6) return 'C11';
|
||||
return 'C9';
|
||||
};
|
||||
const layout = multicolShearLayout(L, columns, c, s, sd);
|
||||
if (layout.short.length) warnings.push({ code: 'RB-M01', message: `코핑이 짧아 기둥 옆 밀집 구간(바깥 ${MULTI_REBAR.denseOuter} · 안쪽 ${MULTI_REBAR.denseInner}칸 @${MULTI_REBAR.denseSpacingMm})이 ${layout.short.includes(0) ? '코핑 끝을 넘거나 ' : ''}서로 겹칩니다. 겹친 위치를 합쳐 표시했습니다 — 3주형형상배근.md는 오류로 정합니다 (Q-M2 밀집 구간 규칙 확인).` });
|
||||
if (layout.tight.length) warnings.push({ code: 'RB-M02', message: `기둥 사이 등분 간격이 밀집 간격 ${MULTI_REBAR.denseSpacingMm}보다 작습니다 (경간 ${layout.tight.join(', ')}).` });
|
||||
for (const x of layout.positions) {
|
||||
const name = shearName(x);
|
||||
// The closures (U + cap each) sit side by side, centred on the position so the end-line bars stay inside the coping.
|
||||
const span = (legs.closures.length * 2 - 1) * ds;
|
||||
legs.closures.forEach(([i, j], q) => {
|
||||
const xu = x - span / 2 + q * 2 * ds, xc = xu + ds;
|
||||
const yTop = stepTop(x) - c;
|
||||
const bottom = envelope.inset(xu, c);
|
||||
const capBottom = envelope.inset(xc, c) + 0.1;
|
||||
add(name, 'H22', [[xu, yTop, legs.y[i]], [xu, bottom, legs.y[i]], [xu, bottom, legs.y[j]], [xu, yTop, legs.y[j]]]);
|
||||
add(name, 'H22', [[xc, capBottom, legs.y[i]], [xc, yTop, legs.y[i]], [xc, yTop, legs.y[j]], [xc, capBottom, legs.y[j]]]);
|
||||
});
|
||||
}
|
||||
|
||||
// --- 상면 C1 · C4 (1단, 8 m 이음) · C3 · C2 · C3-1 · C2-1 (2단) (§5.2) -------------------------------
|
||||
const top = widthBars(B, c, settings.mainSpacing / 1000);
|
||||
const z1 = model.copingRefTop - c, z2 = z1 - settings.mainLayerGap / 1000;
|
||||
const h25 = hookLength('main90', d('H25')) / 1000;
|
||||
const ls25 = spliceLength(d('H25'), fy('H25'), material.fck) / 1000;
|
||||
const a = c, b = L - c;
|
||||
// Smallest n with every piece ≤ 8 m (hooks included), joints at the n-section points ∓ l_s (C1 −, C4 +).
|
||||
const pieces = (shift: number, hooks: boolean) => {
|
||||
for (let n = 1; n < 100; n++) {
|
||||
const cuts = Array.from({ length: n - 1 }, (_, j) => a + (b - a) * (j + 1) / n + shift);
|
||||
const ends = [a, ...cuts, b];
|
||||
const spans = ends.slice(1).map((e, i) => [i === 0 ? ends[0] : ends[i] - ls25 / 2, i === n - 1 ? e : e + ls25 / 2] as [number, number]);
|
||||
const lengths = spans.map(([p, q], i) => (q - p) + (hooks && i === 0 ? h25 : 0) + (hooks && i === n - 1 ? h25 : 0));
|
||||
if (lengths.every(l => l <= MULTI_REBAR.standardMm / 1000 + 1e-9)) return { spans, cuts };
|
||||
}
|
||||
throw new Error('상면 1단 이음 조각을 정할 수 없습니다.');
|
||||
};
|
||||
const c1 = pieces(-ls25, true), c4 = pieces(ls25, false);
|
||||
top.forEach((y, i) => {
|
||||
const cycle1 = i % 2 === 0;
|
||||
const plan = cycle1 ? c1 : c4;
|
||||
plan.spans.forEach(([p, q], k) => {
|
||||
const pts: CopingPoint[] = [[p, z1, y], [q, z1, y]];
|
||||
if (cycle1 && k === 0) pts.unshift([p, z1 - h25, y]);
|
||||
if (cycle1 && k === plan.spans.length - 1) pts.push([q, z1 - h25, y]);
|
||||
add(cycle1 ? 'C1' : 'C4', 'H25', pts);
|
||||
});
|
||||
// 2단: 끝단 쪽은 기둥 중심 + l_cut까지 (C3 끝 갈고리 / C2 직선), 가운데 기둥 위는 ± l_cut (C3-1 / C2-1).
|
||||
const xFirst = columns[0], xLast = columns.at(-1)!;
|
||||
const outerName = cycle1 ? 'C3' : 'C2', midName = cycle1 ? 'C3-1' : 'C2-1';
|
||||
add(outerName, 'H25', [...(cycle1 ? [[a, z2 - h25, y] as CopingPoint] : []), [a, z2, y], [xFirst + MULTI_REBAR.topCut, z2, y]]);
|
||||
add(outerName, 'H25', [[xLast - MULTI_REBAR.topCut, z2, y], [b, z2, y], ...(cycle1 ? [[b, z2 - h25, y] as CopingPoint] : [])]);
|
||||
for (const x of columns.slice(1, -1)) add(midName, 'H25', [[x - MULTI_REBAR.topCut, z2, y], [x + MULTI_REBAR.topCut, z2, y]]);
|
||||
});
|
||||
// RB-M05: 상면 1단 이음 중심이 기둥 구간 안 (정보).
|
||||
const zoneOf = (x: number) => columns.some((col, k) => {
|
||||
const n = columns.length;
|
||||
const [lo, hi] = k === 0 ? [col - MULTI_REBAR.zoneOuter, col + MULTI_REBAR.zoneInner] : k === n - 1 ? [col - MULTI_REBAR.zoneInner, col + MULTI_REBAR.zoneOuter] : [col - MULTI_REBAR.zoneMiddle, col + MULTI_REBAR.zoneMiddle];
|
||||
return x >= lo - 1e-9 && x <= hi + 1e-9;
|
||||
});
|
||||
const inZone = [...c1.cuts.map(x => ['C1', x] as const), ...c4.cuts.map(x => ['C4', x] as const)].filter(([, x]) => zoneOf(x));
|
||||
if (inZone.length) warnings.push({ code: 'RB-M05', message: `상면 1단 8 m 이음 중심 ${inZone.length}곳이 기둥 구간 안입니다 (${inZone.map(([nm, x]) => `${nm} ${x.toFixed(3)}`).join(' · ')}). 정보.` });
|
||||
|
||||
// --- 하면 C7 (전 길이, Level 꺾임점 겹침) · C8 (경간마다) (§5.3–5.4) ---------------------------------
|
||||
const bottomBars = widthBars(B, c, 2 * settings.mainSpacing / 1000);
|
||||
const h29 = hookLength('main90', d('H29')) / 1000;
|
||||
const ls29 = spliceLength(d('H29'), fy('H29'), material.fck) / 1000;
|
||||
const path = (from: number, to: number, y: number): CopingPoint[] =>
|
||||
[from, ...envelope.xs.filter(x => x > from + 1e-9 && x < to - 1e-9), to].map(x => [x, envelope.inset(x, c), y]);
|
||||
// The lap runs l_s/2 along the bar on each side of a Level kink (not l_s/2 horizontally, which is longer on a haunch).
|
||||
const walk = (x0: number, dist: number, dir: 1 | -1) => {
|
||||
const stops = [...envelope.xs.filter(x => dir > 0 ? x > x0 + 1e-9 : x < x0 - 1e-9).sort((p, q) => dir * (p - q)), dir > 0 ? b : a];
|
||||
let x = x0, left = dist;
|
||||
for (const next of stops) {
|
||||
const seg = Math.hypot(next - x, envelope.under(next) - envelope.under(x));
|
||||
if (seg >= left - 1e-12) return x + (next - x) * left / seg;
|
||||
left -= seg; x = next;
|
||||
}
|
||||
return x;
|
||||
};
|
||||
const kinks = columns.flatMap(x => [x - lw / 2, x + lw / 2]).slice(1, -1);
|
||||
const c7Ends = [a, ...kinks, b];
|
||||
for (const y of bottomBars) {
|
||||
for (let k = 1; k < c7Ends.length; k++) {
|
||||
const from = k === 1 ? a : walk(c7Ends[k - 1], ls29 / 2, -1), to = k === c7Ends.length - 1 ? b : walk(c7Ends[k], ls29 / 2, 1);
|
||||
const pts = path(from, to, y);
|
||||
if (k === 1) pts.unshift([a, pts[0][1] + h29, y]);
|
||||
if (k === c7Ends.length - 1) pts.push([b, pts.at(-1)![1] + h29, y]);
|
||||
add('C7', 'H29', pts);
|
||||
}
|
||||
}
|
||||
const c8y = bottomBars.slice(1).map((y, i) => (y + bottomBars[i]) / 2);
|
||||
for (let k = 1; k < columns.length; k++) {
|
||||
const from = columns[k - 1] + MULTI_REBAR.c8End, to = columns[k] - MULTI_REBAR.c8End;
|
||||
for (const y of c8y) add('C8', 'H25', [[from, levelBottom + hr + c, y], [to, levelBottom + hr + c, y]]);
|
||||
}
|
||||
|
||||
// --- 측면 수평 C12 (상단, 전 길이) · C13 (하단 변화구간, 하면에서 끊김), 다리 위치마다 (§6) -----------------
|
||||
const zBreak = model.endBottom + c;
|
||||
const sideTop = z2 - (d('H25') + d('H16')) / 2000;
|
||||
const sideRows: { z: number; intervals: [number, number][] }[] = [];
|
||||
for (let z = levelBottom + c + settings.sideSpacing / 1000; z <= sideTop + 1e-9; z += settings.sideSpacing / 1000) {
|
||||
// Where the bar clears the underside by c (vertical): one interval per stretch between the walls of the 오목.
|
||||
const xsAll = [a, ...envelope.xs.filter(x => x > a && x < b), b];
|
||||
const intervals: [number, number][] = [];
|
||||
for (let i = 1; i < xsAll.length; i++) {
|
||||
let p = xsAll[i - 1], q = xsAll[i];
|
||||
const zp = envelope.under(p) + c, zq = envelope.under(q) + c;
|
||||
if (Math.min(zp, zq) > z + 1e-9) continue;
|
||||
if (zp > z) p += (q - p) * (zp - z) / (zp - zq);
|
||||
else if (zq > z) q = p + (q - p) * (z - zp) / (zq - zp);
|
||||
const last = intervals.at(-1);
|
||||
if (last && Math.abs(last[1] - p) < 1e-8) last[1] = q; else intervals.push([p, q]);
|
||||
}
|
||||
sideRows.push({ z, intervals });
|
||||
const name = z < zBreak - 1e-9 ? 'C13' : 'C12';
|
||||
for (const [p, q] of intervals) for (const y of legs.y) add(name, 'H16', [[p, z, y], [q, z, y]]);
|
||||
}
|
||||
|
||||
// --- C14 크로스타이 (§7): 연직 600, 교각축 1200, 단마다 600 엇갈림 ---------------------------------------
|
||||
const tie = hookLength('tie90', d('H16')) / 1000;
|
||||
const yEdge = B / 2 - c;
|
||||
const c14Rows: number[] = [];
|
||||
for (let z = levelBottom + c + MULTI_REBAR.c14Vertical; z < sideTop - 1e-9; z += MULTI_REBAR.c14Vertical) c14Rows.push(z);
|
||||
c14Rows.push(sideTop);
|
||||
c14Rows.forEach((z, r) => {
|
||||
for (let x = MULTI_REBAR.c14Along / 2 + (r % 2) * MULTI_REBAR.c14Stagger; x <= L - MULTI_REBAR.c14Along / 2 + 1e-9; x += MULTI_REBAR.c14Along) {
|
||||
if (z < envelope.under(x) + c + tie) continue;
|
||||
add('C14', 'H16', [[x, z - tie, -yEdge], [x, z, -yEdge], [x, z, yEdge], [x, z - tie, yEdge]]);
|
||||
}
|
||||
});
|
||||
|
||||
// --- C15 계단부 (§8): 끝단보다 h_16 이상 높은 계단 전체에 ⊓ 하나 ------------------------------------------
|
||||
const front = model.segments;
|
||||
const endTop = Math.min(front[0].top, front.at(-1)!.top);
|
||||
const high = front.filter(g => g.top - endTop >= MULTI_REBAR.stepMin - 1e-9);
|
||||
if (high.length) {
|
||||
const from = high[0].s0 + c, to = high.at(-1)!.s1 - c;
|
||||
const z15 = Math.min(...high.map(g => g.top)) - c;
|
||||
const ld = developmentLength(d('H19'), fy('H19'), material.fck) / 1000;
|
||||
top.filter((_, i) => i % 2 === 0).forEach(y => add('C15', 'H19', [[from, z15 - ld, y], [from, z15, y], [to, z15, y], [to, z15 - ld, y]]));
|
||||
const above = Math.max(...front.map(g => g.top)) - (z15 + c);
|
||||
if (above > MULTI_REBAR.stepMin + 1e-9) warnings.push({ code: 'RB-M04', message: `가장 높은 계단 상면이 C15보다 ${Math.round(above * 1000)} mm 높습니다 (h_16 ${MULTI_REBAR.stepMin * 1000} 초과). C16 병행은 미정 (Q-M7).` });
|
||||
}
|
||||
|
||||
// RB-M03: 상면 1단 외 철근이 12 m를 넘으면 이음 미정 (Q-M12).
|
||||
const long = [...new Set(bars.filter(x => !['C1', 'C4'].includes(x.name) && x.length > 12000).map(x => x.name))];
|
||||
if (long.length) warnings.push({ code: 'RB-M03', message: `${long.join(' · ')} 길이가 12000을 넘습니다. 상면 1단 외 철근의 8 m 이음 적용은 미정입니다 (Q-M12).` });
|
||||
return { bars, warnings };
|
||||
}
|
||||
|
||||
/** Shared physical pieces for multi-column 3D and quantities (3주형형상배근.md §4–§8). */
|
||||
export function multicolCopingBars(model: PierModel, material: Material = DEFAULT_MATERIAL): CopingBar[] {
|
||||
return multicolCopingInfo(model, material).bars;
|
||||
}
|
||||
@@ -60,25 +60,25 @@ export interface Quantity {
|
||||
ton: number;
|
||||
}
|
||||
|
||||
export function buildQuantity(rows: RebarRow[], material: Material): Quantity {
|
||||
export function buildQuantity(rows: RebarRow[], material: Material, incomplete: readonly string[] = []): Quantity {
|
||||
const byGrade = new Map<number, Map<string, QuantityDiameter>>();
|
||||
const couplers: { name: string; count: number }[] = [];
|
||||
const missing: string[] = [];
|
||||
const missing: string[] = [...incomplete];
|
||||
|
||||
for (const r of rows) {
|
||||
if (r.name.includes('커플러')) {
|
||||
couplers.push({ name: r.name, count: r.count });
|
||||
continue;
|
||||
}
|
||||
if (!isBarSize(r.size)) continue;
|
||||
if (!isBarSize(r.size) && r.diameterMm === undefined) continue;
|
||||
// A row with several lengths carries pieces; a row with one length is one piece (count bars of that length).
|
||||
const pieces = r.pieces ?? (r.length === null ? null : [{ length: r.length, count: r.count }]);
|
||||
if (!pieces) {
|
||||
missing.push(r.name);
|
||||
continue;
|
||||
}
|
||||
const grade = fyOf(material, r.size);
|
||||
const dMm = NOMINAL_DIAMETER[r.size];
|
||||
const grade = r.gradeMpa ?? fyOf(material, r.size as BarSize);
|
||||
const dMm = r.diameterMm ?? NOMINAL_DIAMETER[r.size as BarSize];
|
||||
const kgPerM = unitWeight(dMm);
|
||||
const pieceCount = pieces.reduce((s, p) => s + p.count, 0);
|
||||
const totalM = pieces.reduce((s, p) => s + (p.count * p.length) / 1000, 0);
|
||||
@@ -87,11 +87,12 @@ export function buildQuantity(rows: RebarRow[], material: Material): Quantity {
|
||||
length: pieces.length === 1 ? pieces[0].length : null, pieces, totalM, kgPerM, kg: totalM * kgPerM, shape: r.shape,
|
||||
};
|
||||
const diameters = byGrade.get(grade) ?? new Map<string, QuantityDiameter>();
|
||||
const d = diameters.get(r.size) ?? { size: r.size, dMm, lines: [], totalM: 0, kg: 0 };
|
||||
const sameDiameter = [...diameters.values()].find(d => Math.abs(d.dMm - dMm) < 1e-8);
|
||||
const d = sameDiameter ?? { size: r.size, dMm, lines: [], totalM: 0, kg: 0 };
|
||||
d.lines.push(line);
|
||||
d.totalM += totalM;
|
||||
d.kg += line.kg;
|
||||
diameters.set(r.size, d);
|
||||
diameters.set(d.size, d);
|
||||
byGrade.set(grade, diameters);
|
||||
}
|
||||
|
||||
@@ -119,10 +120,11 @@ export function quantityRows(q: Quantity): (string | number)[][] {
|
||||
}
|
||||
out.push([`SD${g.grade} 소계`, '', '', '', '', '', '', '', round(g.kg)]);
|
||||
}
|
||||
out.push(['합계', '', '', '', '', '', '', '', round(q.kg)]);
|
||||
out.push([q.missing.length ? '계산된 부재 소계 · 전체 수량 미완료' : '합계', '', '', '', '', '', '', '', round(q.kg)]);
|
||||
out.push([`할증 ${q.wastePercent}% 포함`, '', '', '', '', '', '', '', round(q.kgWithWaste)]);
|
||||
out.push(['총중량 (ton)', '', '', '', '', '', '', '', round(q.ton, 3)]);
|
||||
for (const c of q.couplers) out.push(['커플러 (개수만)', c.name, '', '', c.count, '', '', '', '']);
|
||||
for (const name of q.missing) out.push(['미계산 · 합계 미완료', name]);
|
||||
return out;
|
||||
}
|
||||
|
||||
|
||||
+108
-21
@@ -1,11 +1,14 @@
|
||||
import type { PierModel } from '../geometry';
|
||||
import { barLength, bearingBlockRebar, DEFAULT_BLOCK_REBAR, totals } from './bearing-block';
|
||||
import { DEFAULT_COPING_REBAR } from './coping-settings';
|
||||
import { copingDefaults } from './coping-settings';
|
||||
import { DEFAULT_COLUMN_REBAR } from './column-settings';
|
||||
import { DEFAULT_FOOTING_REBAR, type FootingRebarSettings } from './footing-settings';
|
||||
import { bottomPiece, topBarLength, widthLayout, topSplicePieces } from './coping-main';
|
||||
import { shearBars, shearBarsMulti, copingSideBars, SIDE_HOOK_LEG, c14Bars, c14Length, stairBars, stepDevLength, undersideAt, shearULength, capLength, PIECES_PER_POSITION } from './coping';
|
||||
import { sideRowLevels } from '../coping-geom';
|
||||
import { CAST_PENDING } from './cast-in-place';
|
||||
import { castRebarLayout } from './cast-layout';
|
||||
import { multicolCopingInfo } from './multicol-coping';
|
||||
import { columnRebar, columnInputFor } from './column';
|
||||
import { bottomBars, crossBars, sideBars, assemblyBars, shearB9 } from './footing';
|
||||
import { DEFAULT_MATERIAL, FckByMember, fyOf, isAssumedMaterial, Material, materialFor, splitPieces, STANDARD_BAR } from './common';
|
||||
@@ -15,7 +18,7 @@ export type Basis = 'input' | 'default' | 'calc' | 'assumed';
|
||||
|
||||
/** One line of a 철근표. length is null where the document gives no length yet. */
|
||||
export interface RebarRow {
|
||||
member: 'bearing' | 'coping' | 'column' | 'footing';
|
||||
member: 'bearing' | 'coping' | 'column' | 'footing' | 'pile';
|
||||
name: string;
|
||||
size: string;
|
||||
count: number;
|
||||
@@ -31,6 +34,9 @@ export interface RebarRow {
|
||||
direction: string;
|
||||
shape: string;
|
||||
basis: Basis;
|
||||
/** Explicit dedicated-section properties; absent for legacy rows. */
|
||||
diameterMm?: number;
|
||||
gradeMpa?: number;
|
||||
}
|
||||
|
||||
export interface RebarWarning {
|
||||
@@ -42,6 +48,17 @@ export interface RebarWarning {
|
||||
bar?: string;
|
||||
}
|
||||
|
||||
/** 다주형 코핑 번호별 위치 (3주형형상배근.md §3, 번호 = 역할). */
|
||||
const MULTI_NOTE: Record<string, string> = {
|
||||
C1: '상면 1단 cycle-1, 양끝 갈고리, 8 m 이음', C4: '상면 1단 cycle-2, 직선, 8 m 이음 (± l_s 엇갈림)',
|
||||
C3: '상면 2단 cycle-1, 끝단 쪽 (기둥 중심 + 4450까지)', C2: '상면 2단 cycle-2, 끝단 쪽',
|
||||
'C3-1': '상면 2단 cycle-1, 가운데 기둥 위 ± 4450', 'C2-1': '상면 2단 cycle-2, 가운데 기둥 위',
|
||||
C7: '하면 전 길이, Level 꺾임점 겹침, 양끝 위로 갈고리', C8: '경간마다 오목 하면, 기둥 중심 ± 2230 안쪽',
|
||||
C9: '전단 · 테이퍼·헌치', C10: '전단 · 기둥 Level', C11: '전단 · 기둥 사이 오목',
|
||||
C12: '측면 상단 (전 길이), 다리 위치마다', C13: '측면 하단 변화구간 (하면에서 끊김), 다리 위치마다',
|
||||
C14: '크로스타이 연직 600 · 교각축 1200 엇갈림', C15: '계단부 ⊓ (끝단보다 100 이상 높은 계단 전체)',
|
||||
};
|
||||
|
||||
/** Table metadata per bar (코핑철근.md, 확대기초철근 (1).md, 기둥철근 (1).md, 받침블럭철근.md). */
|
||||
const META: Record<string, Pick<RebarRow, 'spacing' | 'actual' | 'position' | 'direction' | 'shape' | 'basis'>> = {
|
||||
S1: { spacing: '125', actual: '125', position: '블럭 상면 − 69.1', direction: '교각축 직각', shape: '양끝 다리 꺾임', basis: 'calc' },
|
||||
@@ -57,8 +74,8 @@ const META: Record<string, Pick<RebarRow, 'spacing' | 'actual' | 'position' | 'd
|
||||
C9: { spacing: '250', actual: '250', position: '전단 좌 경사', direction: '연직', shape: '폐합 (하부 U + 상부 삿갓)', basis: 'assumed' },
|
||||
C10: { spacing: '300', actual: '300', position: '전단 Level', direction: '연직', shape: '폐합 (하부 U + 상부 삿갓)', basis: 'assumed' },
|
||||
C11: { spacing: '250', actual: '250', position: '전단 우 경사', direction: '연직', shape: '폐합 (하부 U + 상부 삿갓)', basis: 'assumed' },
|
||||
C12: { spacing: '250', actual: '250', position: '측면 하단 (경사 하면에서 끊김)', direction: '교각축', shape: '일자 + 좌우 ㄷ형', basis: 'calc' },
|
||||
C13: { spacing: '250', actual: '250', position: '측면 상단 (끝면까지)', direction: '교각축', shape: '일자 + 좌우 ㄷ형', basis: 'calc' },
|
||||
C12: { spacing: '250', actual: '250', position: '측면 상단 (끝면까지)', direction: '교각축', shape: '일자 + 좌우 ㄷ형', basis: 'calc' },
|
||||
C13: { spacing: '250', actual: '250', position: '측면 하단 변화구간 (경사 하면에서 끊김)', direction: '교각축', shape: '일자 + 좌우 ㄷ형', basis: 'calc' },
|
||||
C14: { spacing: '1000 / 2000', actual: '1000', position: '직각 수평 (위 2줄 · 아래 엇갈림)', direction: '교각축 직각', shape: '⊓ (폭 방향)', basis: 'calc' },
|
||||
C15: { spacing: '250', actual: '250', position: '계단부 가장 높은 5구간', direction: '교각축', shape: '거꾸로 된 U', basis: 'calc' },
|
||||
C16: { spacing: '250', actual: '250', position: '계단부 3·4구간', direction: '교각축', shape: 'L형', basis: 'calc' },
|
||||
@@ -128,7 +145,7 @@ const MAIN_BARS = ['C1', 'C2', 'C3', 'C4', 'C5', 'C6', 'C7', 'C8'];
|
||||
*/
|
||||
export function linkedSpacing(name: string, model: PierModel): Pick<RebarRow, 'spacing' | 'actual' | 'basis'> | undefined {
|
||||
const c = model.copingRebar;
|
||||
const d = DEFAULT_COPING_REBAR;
|
||||
const d = copingDefaults(model.columns.length);
|
||||
const b = model.blockRebar;
|
||||
const bd = DEFAULT_BLOCK_REBAR;
|
||||
const basis = (changed: boolean): Basis => (changed ? 'input' : 'default');
|
||||
@@ -186,11 +203,12 @@ export function rebarSummary(
|
||||
model: PierModel,
|
||||
common: Material = DEFAULT_MATERIAL,
|
||||
byMember: FckByMember = {},
|
||||
): { rows: RebarRow[]; warnings: RebarWarning[] } {
|
||||
): { rows: RebarRow[]; warnings: RebarWarning[]; incomplete: string[] } {
|
||||
const rows: RawRow[] = [];
|
||||
const warnings: RebarWarning[] = [];
|
||||
const mCoping = materialFor(common, byMember, 'coping');
|
||||
const mColumn = materialFor(common, byMember, 'column');
|
||||
const cast = model.castInput ? castRebarLayout(model, mColumn) : undefined;
|
||||
const mFooting = materialFor(common, byMember, 'footing');
|
||||
// The I02 note stays while the common values are the assumed ones, unless every fck-bearing member has its own fck.
|
||||
const ownFck = (['column', 'coping', 'footing'] as const).every((k) => byMember[k] !== undefined);
|
||||
@@ -217,6 +235,9 @@ export function rebarSummary(
|
||||
rows.push({ member: 'bearing', name: 'S2', size: 'H19', count: blk.S2, length: barLength(1200, model.blockRebar), note: `블럭 배치 분리, 블럭당 8본 · 묻힘 ${range(emb2)} (블럭 상면 − 50)` });
|
||||
|
||||
// Coping (코핑철근.md §5–§8)
|
||||
const colS = model.columns.map((c) => c.s * 1000);
|
||||
const nCol = Math.max(1, colS.length);
|
||||
if (nCol <= 1 || model.kink !== null) {
|
||||
const width = widthLayout(B, model.copingRebar);
|
||||
const perRow = width.count / 2; // cycle split, 12 per row
|
||||
// CP04 (승인 2026-10-26): the cycle split needs an even count (n + 3), so n must be odd. Warning only; the input is kept.
|
||||
@@ -236,8 +257,6 @@ export function rebarSummary(
|
||||
const bottom = bottomPiece(mCoping);
|
||||
rows.push({ member: 'coping', name: 'C7', size: 'H29', count: perRow, length: bottom.piece, note: '하면, 2조각 겹침이음' });
|
||||
rows.push({ member: 'coping', name: 'C8', size: 'H29', count: perRow, length: bottom.piece, note: '하면, 2조각 겹침이음' });
|
||||
const colS = model.columns.map((c) => c.s * 1000);
|
||||
const nCol = Math.max(1, colS.length);
|
||||
const geom = model.copingGeom;
|
||||
const shear = nCol > 1 ? shearBarsMulti(L, colS, geom) : { ...shearBars(L, undefined, geom), between: [] as number[] };
|
||||
const uRange = (xs: number[]) => {
|
||||
@@ -262,41 +281,86 @@ export function rebarSummary(
|
||||
rows.push({ member: 'coping', name: 'C9·C11 사이', size: 'H25', count: shear.between.length, length: null, pieces: loopPieces(shear.between), note: `기둥 사이, 양쪽 C10에서 안쪽으로 (추천안, Q-K2), 조각 ${shear.between.length * PIECES_PER_POSITION}` });
|
||||
}
|
||||
if (nCol > 1) {
|
||||
warnings.push({ code: 'RB-M01', message: `다주형 ${nCol}기둥: C14는 T형 기준으로 두었습니다 (기둥마다 반복 규칙 미정, Q-K11).`, target: 'coping' });
|
||||
warnings.push({ code: 'RB-M01', message: `다주형 ${nCol}기둥: C14는 전용 삽도의 H16·기본600 간격과 실제 하면을 사용합니다. 기둥별 반복·번호 대응 상세는 확인이 필요합니다.`, target: 'coping' });
|
||||
}
|
||||
// Per row: two 일자 (side faces) and two ㄷ (one at each end), so 4 bars per row (코핑철근.md §6.2).
|
||||
const levels = sideRowLevels(model.levelBottom, model.endBottom, model.copingRefTop);
|
||||
const side = copingSideBars(model.copingL * 1000, levels.zBottom, levels.zBreak, model.copingGeom, levels.zTop);
|
||||
const hook = model.copingB * 1000 - 2 * 127 + 2 * SIDE_HOOK_LEG; // ㄷ body across the width + two legs
|
||||
const c12Range = side.c12.length ? `${Math.round(side.c12[0].straight)}–${Math.round(side.c12[side.c12.length - 1].straight)}` : '—';
|
||||
const c13Range = side.c13.length ? `${Math.round(side.c13[0].straight)}–${Math.round(side.c13[side.c13.length - 1].straight)}` : '—';
|
||||
// Per bar: 2 일자 (straight) and 2 ㄷ (hook body + legs), so 4 bars per row position.
|
||||
const sidePieces = (bars: { straight: number }[]) => bars.flatMap((b) => [{ length: b.straight, count: 2 }, { length: hook, count: 2 }]);
|
||||
rows.push({ member: 'coping', name: 'C12', size: 'H25', count: 4 * side.c12.length, length: null, pieces: sidePieces(side.c12), note: `측면, 경사 하면에서 끊김, 일자 ${c12Range} (${2 * side.c12.length}) · ㄷ ${Math.round(hook)} (${2 * side.c12.length}) · 도면 6단 중 하면 단(EL ${(model.levelBottom + 0.127).toFixed(3)})은 하면 주철근 C7·C8로 계산 (문서 §6.1)` });
|
||||
rows.push({ member: 'coping', name: 'C13', size: 'H25', count: 4 * side.c13.length, length: null, pieces: sidePieces(side.c13), note: `측면, 끝면까지, 일자 (${2 * side.c13.length}) · ㄷ ${Math.round(hook)} (${2 * side.c13.length})` });
|
||||
rows.push({ member: 'coping', name: 'C12', size: 'H25', count: 4 * side.c12.length, length: null, pieces: sidePieces(side.c12), note: `측면 상단, 끝면까지, 일자 (${2 * side.c12.length}) · ㄷ ${Math.round(hook)} (${2 * side.c12.length})` });
|
||||
rows.push({ member: 'coping', name: 'C13', size: 'H25', count: 4 * side.c13.length, length: null, pieces: sidePieces(side.c13), note: `측면 하단 변화구간, 경사 하면에서 끊김, 일자 ${c13Range} (${2 * side.c13.length}) · ㄷ ${Math.round(hook)} (${2 * side.c13.length}) · 도면 하단 단 중 하면 단(EL ${(model.levelBottom + 0.127).toFixed(3)})은 하면 주철근 C7·C8로 계산 (문서 §6.1)` });
|
||||
// C14 / C15 / C16 are derived from the model (코핑철근.md §7–8); the document's 38 / 11 / 22 and 3852 / 4070 / 4069 are checked in the tests.
|
||||
const L14 = model.copingL * 1000;
|
||||
const c14 = c14Bars(L14, model.copingRefTop, [...side.c12, ...side.c13], (x) => undersideAt(L14, x, model.endBottom, model.levelBottom, model.copingGeom.levelWidth), model.copingRebar.mainLayerGap / 1000);
|
||||
const c14 = c14Bars(L14, model.copingRefTop, [...side.c13, ...side.c12], (x) => undersideAt(L14, x, model.endBottom, model.levelBottom, model.copingGeom.levelWidth), model.copingRebar.mainLayerGap / 1000);
|
||||
const lineCount = (line: number) => c14.filter((b) => b.line === line).length;
|
||||
const omitted = c14.filter((b) => !b.legs).length;
|
||||
rows.push({ member: 'coping', name: 'C14', size: 'H19', count: c14.length, length: c14Length(model.copingB * 1000), note: `위 2 줄 ${lineCount(1)} · ${lineCount(2)}본씩, 아래 ${[3, 4, 5, 6].map(lineCount).join(' · ')} (합 ${c14.length}) · 연직 발 생략 ${omitted}개 (하면 밖)` });
|
||||
const stairs = stairBars(model.copingL * 1000, model.copingB * 1000, model.copingRefTop, mCoping, 2 * model.copingRebar.mainSpacing);
|
||||
rows.push({ member: 'coping', name: 'C15', size: 'H19', count: stairs.c15.length, length: stairs.c15[0].length, note: '계단부, 가장 높은 구간 (거꾸로 U)' });
|
||||
rows.push({ member: 'coping', name: 'C16', size: 'H19', count: stairs.c16.length, length: stairs.c16[0].length, note: `계단부, 3·4구간 (${Math.round(stairs.c16[0].length)} / ${Math.round(stairs.c16[stairs.c16.length - 1].length)})` });
|
||||
}
|
||||
|
||||
// Column (기둥철근.md · plan C4), P1 defaults
|
||||
const col = columnRebar(columnInputFor(model), mColumn);
|
||||
rows.push({ member: 'column', name: 'P1', size: 'S32', count: col.mainP1, length: null, pieces: col.p1Pieces.map((length) => ({ length, count: col.mainP1 })), note: `외측 r ${col.r1}, 마디 ${col.p1Pieces.join(' / ')} (첫 마디에 갈고리 ${col.p1Hook}, 기둥철근.md §3.2)` });
|
||||
rows.push({ member: 'column', name: 'P2', size: 'S32', count: col.mainP2, length: null, pieces: col.p2Pieces.map((length) => ({ length, count: col.mainP2 })), note: `내측 r ${col.r2}, 마디 ${col.p2Pieces.join(' / ')}` });
|
||||
if (model.footings.length > 0) {
|
||||
const columnRows: RawRow[] = [];
|
||||
for (let columnIndex = 0; columnIndex < model.columns.length; columnIndex++) {
|
||||
const first = rows.length;
|
||||
const input = columnInputFor(model, undefined, columnIndex);
|
||||
const col = columnRebar(input, mColumn);
|
||||
rows.push({ member: 'column', name: 'P1', size: 'S32', count: col.mainP1, length: null, pieces: col.p1Pieces.map((length) => ({ length, count: input.nDiv })), note: `외측 r ${col.r1}, 마디 ${col.p1Pieces.join(' / ')} (첫 마디에 갈고리 ${col.p1Hook}, 기둥철근.md §3.2)` });
|
||||
rows.push({ member: 'column', name: 'P2', size: 'S32', count: col.mainP2, length: null, pieces: col.p2Pieces.map((length) => ({ length, count: input.nDiv })), note: `내측 r ${col.r2}, 마디 ${col.p2Pieces.join(' / ')}` });
|
||||
rows.push({ member: 'column', name: '주철근 커플러', size: 'D32용 (L150 Ø60)', count: col.couplers, length: 150, note: `@${model.columnRebar.ctcCoupler}, P2는 +${model.columnRebar.dzCp2} mm 상향 엇갈림` });
|
||||
const tieCouplerCount = col.p5Count + col.p4Count * 2;
|
||||
rows.push({ member: 'column', name: '띠철근 커플러', size: 'D19용 (L100 Ø40)', count: tieCouplerCount, length: 100, note: `단부 P5 1개(${col.p5Count}) · 기초안 P4 2개(${col.p4Count * 2}) 폐합용` });
|
||||
rows.push({ member: 'column', name: 'P3', size: 'H19', count: col.p3Count, length: col.p3Length, note: `일반 구간 띠철근 @${model.columnRebar.ctcP3}, 겹침 ${col.lapP3} (추천안, Q-C6)` });
|
||||
rows.push({ member: 'column', name: 'P5', size: 'H19', count: col.p5Count, length: col.p5Length, note: `단부 띠철근 @${model.columnRebar.ctcP5}, 커플러 1개` });
|
||||
rows.push({ member: 'column', name: 'P4', size: 'H19', count: col.p4Count, length: col.p4SemicircleLength * 2, note: '확대기초 안, 반원 2개 (길이는 반원 2개 합) · B11 간섭 무시' });
|
||||
rows.push({ member: 'column', name: 'P6', size: 'H16', count: col.p6Count, length: col.p6Length, note: '크로스타이, 8단 × 4' });
|
||||
rows.push({ member: 'column', name: 'P6', size: 'H16', count: col.p6Count, length: col.p6Length, note: `크로스타이, ${col.p6Levels.length}단 × 4` });
|
||||
|
||||
// Column bars and couplers repeat for each column (기둥철근 is per column; 다주형은 기둥마다).
|
||||
for (const r of rows) if (r.member === 'column') r.count *= nCol;
|
||||
columnRows.push(...rows.splice(first));
|
||||
}
|
||||
for (const r of columnRows) {
|
||||
const existing = rows.find(v => v.member === 'column' && v.name === r.name);
|
||||
if (!existing) rows.push({ ...r, pieces: r.pieces ? [...r.pieces] : undefined });
|
||||
else {
|
||||
if (existing.pieces || r.pieces || existing.length !== r.length) {
|
||||
existing.pieces ??= [{ length: existing.length!, count: existing.count }];
|
||||
existing.pieces.push(...(r.pieces ?? [{ length: r.length!, count: r.count }]));
|
||||
existing.length = null;
|
||||
}
|
||||
existing.count += r.count;
|
||||
existing.note = '기둥별 실제 배치·길이 합산 · 마디별 수량은 각 기둥의 원주 등분 수';
|
||||
}
|
||||
}
|
||||
} else if (model.castInput) {
|
||||
const layout = cast!;
|
||||
const grouped = new Map<string, typeof layout.bars>();
|
||||
for (const bar of layout.bars) {
|
||||
const key = `${bar.name}|${bar.diameterMm}|${bar.gradeMpa}`;
|
||||
const group = grouped.get(key) ?? [];
|
||||
group.push(bar); grouped.set(key, group);
|
||||
}
|
||||
for (const group of grouped.values()) {
|
||||
const first = group[0];
|
||||
const byLength = new Map<number, { length: number; count: number }>();
|
||||
for (const bar of group) {
|
||||
const key = Math.round(bar.lengthMm * 1e6);
|
||||
const piece = byLength.get(key) ?? { length: bar.lengthMm, count: 0 };
|
||||
piece.count++; byLength.set(key, piece);
|
||||
}
|
||||
const pieces = [...byLength.values()];
|
||||
rows.push({ member: first.member, name: first.name, size: first.kind === 'coupler' ? `커플러 Ø${first.diameterMm.toFixed(1)}` : `Ø${first.diameterMm}`,
|
||||
count: group.length, length: pieces.length === 1 ? pieces[0].length : null, pieces,
|
||||
diameterMm: first.diameterMm, gradeMpa: first.gradeMpa, note: first.note });
|
||||
}
|
||||
layout.missing.forEach(message => warnings.push({ code: 'RB-CIP01', message, target: 'column' }));
|
||||
} else {
|
||||
warnings.push({ code: 'RB-CIP01', message: CAST_PENDING.join(' · ') + ' — 전용 입력을 저장할 수 있으며 3D·철근표·재료표 계산은 상세 확정 후 제공합니다.', target: 'column' });
|
||||
warnings.push({ code: 'RB-CIP02', message: '현장타설말뚝 철근 수량은 미계산입니다. 지원되는 다른 부재만 집계합니다.', target: 'pile' });
|
||||
}
|
||||
// Footing (확대기초철근 (1).md · plan C5). Only when the footing exists (not 현장타설말뚝).
|
||||
if (model.footings.length > 0) {
|
||||
// Each footing (분리 or 일체) gives the same bar set. Several footings add their counts per bar name;
|
||||
@@ -327,10 +391,33 @@ export function rebarSummary(
|
||||
} else {
|
||||
warnings.push({ code: 'RB-F00', message: '현장타설말뚝은 확대기초가 없어 확대기초 철근을 만들지 않습니다.', target: 'footing' });
|
||||
}
|
||||
if (nCol > 1 && model.kink === null) {
|
||||
const info = multicolCopingInfo(model, mCoping);
|
||||
const grouped = new Map<string, typeof info.bars>();
|
||||
for (const b of info.bars) {
|
||||
const group = grouped.get(b.name) ?? [];
|
||||
group.push(b); grouped.set(b.name, group);
|
||||
}
|
||||
for (const [name, bars] of grouped) rows.push({ member: 'coping', name, size: bars[0].size,
|
||||
count: bars.length, length: null, pieces: bars.map(b => ({ length: b.length, count: 1 })),
|
||||
note: `다주형 (3주형형상배근.md) · ${MULTI_NOTE[name] ?? ''} · 조각별 중심선 길이` });
|
||||
for (const w of info.warnings) warnings.push({ code: w.code, target: 'coping', message: w.message });
|
||||
warnings.push({ code: 'RB-M06', target: 'coping', message: '다주형 전단 다리와 코핑 안 기둥 주철근의 간섭은 검토하지 않았습니다 (T형 Q-K3과 같음). 밀집 구간·다리 8개·측면 형상·피복 120은 가정값입니다 (Q-M2·Q-M3·Q-M5·Q-M6).' });
|
||||
warnings.push({ code: 'RB-M08', target: 'column', message: '다주형 기둥 단부는 지름 이상·코핑 반높이 연장을 적용했습니다 (그림 D, Pier A337 1.5D — Q-M8 미결).' });
|
||||
}
|
||||
// Table metadata per bar (구분·간격·실간격·위치·방향·형상·근거). The spacing rows take their values from the inputs (승인 2026-10-26).
|
||||
const withMeta = rows.map((r) => {
|
||||
if (r.diameterMm !== undefined) return { ...r, spacing: '입력 구간별', actual: '중심선 계산',
|
||||
position: r.name.startsWith('기둥·말뚝') ? '기둥~말뚝 연속' : r.member === 'pile' ? '지중 말뚝' : '지상 기둥·코핑', direction: '배근 경로',
|
||||
shape: r.name.includes('커플러') ? '커플러' : r.name.includes('나선') ? '나선' : r.name.includes('크로스타이') ? '양끝 갈고리' : /P[35]/.test(r.name) ? '원형' : '직선', basis: 'calc' as const };
|
||||
const linked = linkedSpacing(r.name, model);
|
||||
return { ...r, ...(META[r.name] ?? FALLBACK), ...(linked ?? {}) };
|
||||
const multi = nCol > 1 && r.member === 'coping';
|
||||
return { ...r, ...(META[r.name] ?? FALLBACK), ...(linked ?? {}), ...(nCol > 1 && r.member === 'column' && r.name === 'P5' ? { position: '상·하 단부 · 코핑 반높이까지 (번호 확인 중)' } : {}), ...(multi ? {
|
||||
shape: '다주형 조각별 경로 (검토용)', position: '다주형 전용 삽도 · 실제 하면',
|
||||
...(r.name === 'C9·C11 사이' ? { spacing: String(model.copingRebar.spacingS), actual: String(model.copingRebar.spacingS) } : {}) } : {}) };
|
||||
});
|
||||
return { rows: withMeta, warnings: warnings.map((w) => ({ ...w, target: w.target ?? 'common' })) };
|
||||
return { rows: withMeta, warnings: warnings.map((w) => ({ ...w, target: w.target ?? 'common' })), incomplete: [
|
||||
...(cast ? cast.missing : model.footings.length ? [] : CAST_PENDING),
|
||||
...(nCol > 1 ? ['다주형 철근 번호 대응·cycle별 이음 위치·수평철근 갈고리 상세 확인 필요'] : []),
|
||||
] };
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
/**
|
||||
* 솔플레이트 (결정 2026-11-06, RAG/받침방위각_솔플레이트검토.md §3, spec.md §5.3, 시안 Version 110–113). Pure.
|
||||
* Replaces the 2026-11-03 C안 (받침높이 H 고정, h3 = H − T, S 입력) by the user's decision to follow the 시안:
|
||||
*
|
||||
* - 평면: L = A + 여유 (거더 방향 = 방위각), W = B + 여유 (여유 100). A × B per bearing; absent = 700 × 640 (P1 전열 값, 가정).
|
||||
* - S: 같은 거더 양끝 받침의 z 차 / 수평거리 (girderPairs). 없으면 교각 종단경사 입력 → 같은 구조물 다른 열의 같은 순번 값
|
||||
* ("가정", WS03) → 0 % (WS03). 받침별 직접 수정 = "수정됨".
|
||||
* - T: 열마다 max(T_min, ⌈max(T_min + L·|S|/2)⌉). 직접 고치면 "수정됨", T2 < T_min이면 WS02.
|
||||
* T1 = round(T + L·|S|/2) 오르막 쪽, T2 = round(T − L·|S|/2).
|
||||
* - 높이: 거더 하면 고정 — 블럭 상면 = z − (T − T_ref); 받침 본체 h3 고정 (181, P1 상세도).
|
||||
*/
|
||||
import { type BearingPair, pairOf } from './bearing-pair';
|
||||
|
||||
export interface SoleSettings {
|
||||
/** 받침 본체 높이 h3 (mm). */
|
||||
h3Mm?: number;
|
||||
/** T_ref: the solplate thickness the workbook z assumed (mm). */
|
||||
tRefMm?: number;
|
||||
/** T_min: the thin-end minimum (mm). */
|
||||
tMinMm?: number;
|
||||
/** 솔플 여유 L − A = W − B (mm). */
|
||||
marginMm?: number;
|
||||
/** 받침 평면 A (거더 방향) × B per bearing number (mm). */
|
||||
plates?: Record<string, { aMm: number; bMm: number }>;
|
||||
/** T per row, when typed (mm). Absent = 자동. */
|
||||
tByRow?: Partial<Record<string, number>>;
|
||||
/** S per bearing number, when typed (%). */
|
||||
slopePct?: Record<string, number>;
|
||||
}
|
||||
|
||||
export const SOLE_DEFAULTS = { h3Mm: 181, tRefMm: 30, tMinMm: 22, marginMm: 100, aMm: 700, bMm: 640 } as const;
|
||||
|
||||
export type SlopeSource = '계산' | '입력' | '가정' | '수정됨' | '없음';
|
||||
|
||||
export interface BearingSole {
|
||||
no: number;
|
||||
row: string;
|
||||
pair?: { structure: string; no: number };
|
||||
/** 방위각 검사 (″): this bearing's input azimuth against the direction to its pair (Q-SH4, WS01 > 10″). Absent without a pair. */
|
||||
azimuthCheckSec?: number;
|
||||
/** 입력 방위각 and the one computed from the pair (deg, model 진북); the second is absent without a pair. */
|
||||
azimuthInputDeg: number;
|
||||
azimuthCalcDeg?: number;
|
||||
/** girder (같은 거더) or chord (교대만, A1 ↔ A2 현). */
|
||||
pairKind?: 'girder' | 'chord';
|
||||
slopePct: number;
|
||||
slopeSource: SlopeSource;
|
||||
aMm: number;
|
||||
bMm: number;
|
||||
/** L along the girder, W across it (mm). */
|
||||
L: number;
|
||||
W: number;
|
||||
/** Row thickness T and whether it was typed; T1 on the 오르막 쪽, T2 on the other end (mm). */
|
||||
T: number;
|
||||
tTyped: boolean;
|
||||
T1: number;
|
||||
T2: number;
|
||||
/** This bearing's requirement T_min + L·|S|/2 (mm). */
|
||||
tReq: number;
|
||||
h3: number;
|
||||
/** Block top drop T − T_ref (mm, + = the block gets lower). */
|
||||
dropMm: number;
|
||||
/** Unit vector (E, N) towards the 오르막 쪽, or null when S = 0. */
|
||||
uphill: { e: number; n: number } | null;
|
||||
warnings: ('WS01' | 'WS02' | 'WS03' | 'WS04')[];
|
||||
}
|
||||
|
||||
export interface SoleBearing {
|
||||
no: number;
|
||||
row: string;
|
||||
/** The azimuth in use (an applied 계산 방위각 when chosen) — for the 오르막 direction. */
|
||||
azimuthDeg: number;
|
||||
/** The workbook azimuth, when it differs from the one in use. */
|
||||
inputAzimuthDeg?: number;
|
||||
}
|
||||
|
||||
const DEG = Math.PI / 180;
|
||||
|
||||
/**
|
||||
* Solplates of one structure. `pairs` are the girder pairs of the whole bridge (girderPairs over A1, P1 … Pn, A2);
|
||||
* `fallbackSlopePct` is the structure's 종단경사 input (%), when given.
|
||||
*/
|
||||
export function structureSoles(structure: string, bearings: SoleBearing[], settings: SoleSettings = {}, pairs: BearingPair[] = [], fallbackSlopePct?: number): BearingSole[] {
|
||||
const s = { ...SOLE_DEFAULTS, ...settings };
|
||||
const rows = [...new Set(bearings.map((b) => b.row))];
|
||||
const order = (b: SoleBearing) => bearings.filter((q) => q.row === b.row).indexOf(b);
|
||||
const paired = new Map(bearings.map((b) => [b.no, pairOf(pairs, structure, b.no)]));
|
||||
const pairSlope = (b: SoleBearing) => paired.get(b.no)?.slopePct;
|
||||
|
||||
const slopes = bearings.map((b) => {
|
||||
const typed = settings.slopePct?.[String(b.no)];
|
||||
if (typed !== undefined) return { slopePct: typed, source: '수정됨' as SlopeSource };
|
||||
const own = pairSlope(b);
|
||||
if (own !== undefined) return { slopePct: own, source: '계산' as SlopeSource };
|
||||
if (fallbackSlopePct !== undefined) return { slopePct: fallbackSlopePct, source: '입력' as SlopeSource };
|
||||
// The same position in another row that has a pair (시안: 후열 ⑥ ← 전열 ①).
|
||||
for (const other of rows.filter((r) => r !== b.row)) {
|
||||
const twin = bearings.filter((q) => q.row === other)[order(b)];
|
||||
const v = twin && pairSlope(twin);
|
||||
if (v !== undefined) return { slopePct: v, source: '가정' as SlopeSource };
|
||||
}
|
||||
return { slopePct: 0, source: '없음' as SlopeSource };
|
||||
});
|
||||
|
||||
const plate = (b: SoleBearing) => s.plates?.[String(b.no)] ?? { aMm: s.aMm, bMm: s.bMm };
|
||||
const req = bearings.map((b, i) => s.tMinMm + ((plate(b).aMm + s.marginMm) * Math.abs(slopes[i].slopePct)) / 200);
|
||||
const rowT = new Map(rows.map((r) => {
|
||||
const typed = settings.tByRow?.[r];
|
||||
const auto = Math.max(s.tMinMm, Math.ceil(Math.max(...bearings.map((b, i) => (b.row === r ? req[i] : -Infinity))) - 1e-9));
|
||||
return [r, { T: typed ?? auto, typed: typed !== undefined }];
|
||||
}));
|
||||
|
||||
return bearings.map((b, i) => {
|
||||
const { aMm, bMm } = plate(b);
|
||||
const L = aMm + s.marginMm, W = bMm + s.marginMm;
|
||||
const { slopePct, source } = slopes[i];
|
||||
const { T, typed } = rowT.get(b.row)!;
|
||||
const half = (L * Math.abs(slopePct)) / 200;
|
||||
const T1 = Math.round(T + half), T2 = Math.round(T - half);
|
||||
const pair = paired.get(b.no);
|
||||
const warnings: BearingSole['warnings'] = [...(pair?.warnings ?? [])];
|
||||
if (T2 < s.tMinMm) warnings.push('WS02');
|
||||
if (source === '가정' || source === '없음') warnings.push('WS03');
|
||||
// S runs along the structure order, which is the azimuth direction (girderPairs looks ahead along it).
|
||||
const dir = { e: Math.sin(b.azimuthDeg * DEG), n: Math.cos(b.azimuthDeg * DEG) };
|
||||
const sign = Math.sign(slopePct);
|
||||
return {
|
||||
no: b.no, row: b.row, pair: pair ? (pair.from.structure === structure ? pair.to : pair.from) : undefined,
|
||||
pairKind: pair?.kind,
|
||||
azimuthInputDeg: b.inputAzimuthDeg ?? b.azimuthDeg,
|
||||
azimuthCalcDeg: pair ? (pair.from.structure === structure && pair.from.no === b.no ? pair.fromCalcDeg : pair.toCalcDeg) : undefined,
|
||||
azimuthCheckSec: pair ? (pair.from.structure === structure && pair.from.no === b.no ? pair.fromCheckSec : pair.toCheckSec) : undefined,
|
||||
slopePct, slopeSource: source, aMm, bMm, L, W, T, tTyped: typed, T1, T2, tReq: req[i], h3: s.h3Mm,
|
||||
dropMm: T - s.tRefMm, uphill: sign ? { e: sign * dir.e, n: sign * dir.n } : null, warnings,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** Input problems of the settings, in Korean; empty when they can build solplates. */
|
||||
export function soleSettingsIssues(settings: SoleSettings | undefined): string[] {
|
||||
if (!settings) return [];
|
||||
const out: string[] = [];
|
||||
const positive = (v: unknown, label: string) => {
|
||||
if (v !== undefined && !(typeof v === 'number' && Number.isFinite(v) && v > 0)) out.push(`${label}은(는) 0보다 커야 합니다.`);
|
||||
};
|
||||
positive(settings.h3Mm, '받침 높이 h3');
|
||||
positive(settings.tRefMm, 'T_ref');
|
||||
positive(settings.tMinMm, 'T_min');
|
||||
if (settings.marginMm !== undefined && !(Number.isFinite(settings.marginMm) && settings.marginMm >= 0)) out.push('솔플 여유는 0 이상이어야 합니다.');
|
||||
for (const [no, p] of Object.entries(settings.plates ?? {})) { positive(p.aMm, `받침 ${no} A`); positive(p.bMm, `받침 ${no} B`); }
|
||||
for (const [row, t] of Object.entries(settings.tByRow ?? {})) positive(t, `${row} T`);
|
||||
for (const [no, v] of Object.entries(settings.slopePct ?? {})) if (!Number.isFinite(v)) out.push(`받침 ${no} S는 숫자여야 합니다.`);
|
||||
return out;
|
||||
}
|
||||
+15
-27
@@ -1,25 +1,14 @@
|
||||
/**
|
||||
* 솔플레이트 기하 (승인 2026-10-23). Pure: the solplate of one bearing, from its block (position, rowSkew) and the
|
||||
* 공통 규칙 values. The plan is L = A + margin across the 교각축 직각 (n) and W = B + margin along the 교축 (s). The top
|
||||
* tapers across L: the thick end T1 is at the (−) side of the 교각축 직각 direction (n = −L/2), the thin end T2 at (+) (n = +L/2).
|
||||
* 솔플레이트 기하 (승인 2026-10-23, 2026-11-06 결정). Pure: the solplate of one bearing, from its block (position, rowSkew)
|
||||
* and the bearing's solplate plan. L runs along the block's c axis (the girder, by the azimuth) and W along a. The top
|
||||
* tapers across L: the thick end T1 is on the 오르막 쪽 (c = t1Side·L/2), the thin end T2 at the other end.
|
||||
* The underside is the bearing top (block top EL.X + h3). Lengths in m, as in the model.
|
||||
*/
|
||||
import { blockFramePoint, type BlockModel } from './geometry';
|
||||
|
||||
export interface SolplateParams {
|
||||
/** 받침 평면 A (mm, across the 교각축 직각) and B (mm, along the 교축). */
|
||||
A: number;
|
||||
B: number;
|
||||
/** 솔플 여유 (mm): L = A + margin, W = B + margin. */
|
||||
margin: number;
|
||||
/** 중심 두께 T (mm). */
|
||||
T: number;
|
||||
/** 기울기 S (%), negative as drawn: thickness changes by L·|S|/2 from the centre. */
|
||||
slopePct: number;
|
||||
}
|
||||
import type { SolplatePlan } from './types';
|
||||
|
||||
export interface SolplateGeometry {
|
||||
/** Length across the 교각축 직각 (m, = A + margin) and width along the 교축 (m, = B + margin). */
|
||||
/** Length across the 교각축 직각 (m) and width along the 교축 (m). */
|
||||
L: number;
|
||||
W: number;
|
||||
/** Thickness at the (−) side (T1) and the (+) side (T2) of the 교각축 직각 direction (m). */
|
||||
@@ -33,17 +22,16 @@ export interface SolplateGeometry {
|
||||
volume: number;
|
||||
}
|
||||
|
||||
/** Solplate of the bearing at `block`, with the bearing height h3 (mm) and the 공통 규칙 parameters. */
|
||||
export function solplateGeometry(block: BlockModel, h3: number, p: SolplateParams): SolplateGeometry {
|
||||
const L = (p.A + p.margin) / 1000; // across the 교각축 직각 (n)
|
||||
const W = (p.B + p.margin) / 1000; // along the 교축 (s)
|
||||
// Thickness change from the centre to each side: L·|S|/2, with S in % (mm).
|
||||
const drop = ((p.A + p.margin) * Math.abs(p.slopePct)) / 200 / 1000;
|
||||
const T = p.T / 1000;
|
||||
const T1 = T + drop;
|
||||
const T2 = T - drop;
|
||||
const bottom = block.padTop + h3 / 1000;
|
||||
const thickAt = (c: number) => T1 + ((T2 - T1) * (c + L / 2)) / L;
|
||||
/** Solplate of the bearing at `block`, from the pier's solplate plan (mm). */
|
||||
export function solplateGeometry(block: BlockModel, plan: SolplatePlan): SolplateGeometry {
|
||||
const L = plan.L / 1000; // across the 교각축 직각 (n)
|
||||
const W = plan.W / 1000; // along the 교축 (s)
|
||||
const T1 = plan.T1 / 1000;
|
||||
const T2 = plan.T2 / 1000;
|
||||
const bottom = block.padTop + plan.h3 / 1000;
|
||||
// T1 at c = t1Side·L/2 (the 오르막 쪽), T2 at the other end.
|
||||
const side = plan.t1Side ?? -1;
|
||||
const thickAt = (c: number) => T1 + ((T2 - T1) * (side * -c + L / 2)) / L;
|
||||
const corners: [number, number, number][] = [];
|
||||
for (const a of [-W / 2, W / 2]) {
|
||||
for (const c of [-L / 2, L / 2]) {
|
||||
|
||||
@@ -22,7 +22,7 @@ export function buildTemplateWorkbook(): XLSX.WorkBook {
|
||||
const bearing = [
|
||||
note('받침 1행당 받침 1개. 전열·후열 수는 같아야 합니다. 위치는 전열 또는 후열.'),
|
||||
note('z_m은 받침 EL(m), 방위각은 거더 방향입니다.'),
|
||||
['교각명', '받침번호', 'x_m', 'y_m', 'z_m', '방위각', '위치'],
|
||||
['교각명', '받침번호', 'x_m', 'y_m', 'z_m', '방위각', '위치', '교좌면_EL'],
|
||||
];
|
||||
|
||||
XLSX.utils.book_append_sheet(wb, XLSX.utils.aoa_to_sheet(pier), '교각');
|
||||
|
||||
+47
-4
@@ -1,6 +1,9 @@
|
||||
import type { SoleSettings } from './sole-plate';
|
||||
import type { CopingRebarSettings, TopSpliceSettings } from './rebar/coping-settings';
|
||||
import type { ColumnRebarSettings } from './rebar/column-settings';
|
||||
import type { FootingRebarSettings } from './rebar/footing-settings';
|
||||
import type { CastInPlaceRebar } from './rebar/cast-in-place';
|
||||
import type { CastInPlaceShapeSettings } from './cast-in-place-shape';
|
||||
|
||||
export type PierType = 'T형' | '2주형' | '3주형';
|
||||
export type FoundationType = '말뚝기초' | '직접기초' | '현장타설말뚝';
|
||||
@@ -21,6 +24,8 @@ export interface Bearing {
|
||||
z: number;
|
||||
azimuthDeg: number;
|
||||
row: Row;
|
||||
/** 교좌면 EL (m): the block bottom under this bearing. Absent = the 두부보 상면 slope (the coping top). */
|
||||
seatEl?: number;
|
||||
}
|
||||
|
||||
export interface PierInput {
|
||||
@@ -48,8 +53,6 @@ export interface PierInput {
|
||||
bearings: Bearing[];
|
||||
/** Angles normalized with the imported X/Y swap. Absent in legacy saved projects = workbook frame. */
|
||||
bearingAzimuthFrame?: 'model-xy';
|
||||
/** 솔플레이트 plan per bearing (승인 2026-10-23): A, B (mm) and the slope S (%). Absent = no solplate. */
|
||||
bearingPlans?: { no: number; A: number; B: number; S: number }[];
|
||||
/** 확대기초 L (축 방향), W, 높이 h1 (mm); 비림 여유 and 비림 두께 (mm); 말뚝 외경 D and 두께 t (mm). Absent = the 1단계 constants. */
|
||||
footingLengthMm?: number;
|
||||
footingWidthMm?: number;
|
||||
@@ -60,8 +63,17 @@ export interface PierInput {
|
||||
pileThicknessMm?: number;
|
||||
/** 말뚝 종류 (규격 목록): 강관 (default) or PHC. */
|
||||
pileKind?: '강관' | 'PHC';
|
||||
/** 공통 규칙 for the solplate: centre thickness T, 솔플 여유 margin (mm), bearing height h3 (mm). */
|
||||
solplate?: { T: number; margin: number; h3: number };
|
||||
/** Obsolete C안 (2026-11-03) rule: kept so that old project files still open; no longer used (replaced by `sole`, 2026-11-06). */
|
||||
solplate?: SolplateRule;
|
||||
/** 솔플레이트 입력 (결정 2026-11-06, 시안): h3, T_ref, T_min, 여유, 받침별 A × B, 열 T·받침 S 직접 수정. Absent = 기본값. */
|
||||
sole?: SoleSettings;
|
||||
/** 계산 방위각을 적용한 받침: 받침번호 → 방위각 (deg, model 진북). Absent = 엑셀 입력값 (2026-11-06). */
|
||||
azimuthApplied?: Record<string, number>;
|
||||
/** 다주형 기둥 위치 입력 (Q-M10 미결): 끝 ~ 기둥1 중심 e_c, 기둥 간격 s_c (mm). Absent = L/5 규칙. */
|
||||
columnEndOffsetMm?: number;
|
||||
columnSpacingMm?: number;
|
||||
/** 교각 종단경사 (소수, (−) = 교량진행방향으로 낮아짐; Pier 시트 "종단경사" 열, Q-SH2). 짝 받침이 없을 때 S에 쓴다. */
|
||||
longitudinalSlope?: number;
|
||||
/** 지반·지층 (승인 2026-10-26): 위치, 지하수위 고려와 EL, 지층별 행 (위에서 아래, 행의 EL = 그 층의 하단 EL).
|
||||
* Absent = 육상, 지하수위 없음, 지층 행 없음. */
|
||||
groundPosition?: GroundPosition;
|
||||
@@ -88,7 +100,38 @@ export interface PierInput {
|
||||
columnRebar?: Partial<ColumnRebarSettings>;
|
||||
/** 확대기초 배근 입력 (승인 2026-10-26, 시안 ③ 확대기초). Absent = the 확대기초철근 (1).md defaults. */
|
||||
footingRebar?: Partial<FootingRebarSettings>;
|
||||
/** 현장타설 전용 입력. 상세 규칙 확정 전에는 계산된 배근으로 취급하지 않는다. */
|
||||
castInPlaceRebar?: CastInPlaceRebar;
|
||||
strataRows?: StrataRow[];
|
||||
/** 현장타설 형상·암반·희생강관 입력. */
|
||||
castInPlaceShape?: CastInPlaceShapeSettings;
|
||||
}
|
||||
|
||||
/** 솔플레이트 규칙 of one pier (C안, 2026-11-03): 받침높이 (solplate included), plan size (mm) and the slope S (%). */
|
||||
export interface SolplateRule {
|
||||
/** 받침높이 H (mm): from the mortar top (EL.X) to the solplate top, solplate included (H = h3 + T). */
|
||||
heightMm: number;
|
||||
/** 교직 length L (mm, across the 교각축 직각 (n)). */
|
||||
lengthMm: number;
|
||||
/** 교축 width W (mm, along the 교축 (s)). */
|
||||
widthMm: number;
|
||||
/** 기울기 S (%), negative as drawn. */
|
||||
slopePct: number;
|
||||
}
|
||||
|
||||
/** The solplate of one bearing (mm): T is the row thickness, T1 the 오르막 쪽 end, h3 the bearing body (2026-11-06). */
|
||||
export interface SolplatePlan {
|
||||
L: number;
|
||||
W: number;
|
||||
slopePct: number;
|
||||
T: number;
|
||||
/** Thickness at the 오르막 쪽 (T1) and the other end (T2). */
|
||||
T1: number;
|
||||
T2: number;
|
||||
/** Side of the block's c axis (L direction) where T1 sits: −1 = c = −L/2, +1 = c = +L/2. Absent = −1. */
|
||||
t1Side?: -1 | 1;
|
||||
/** Bearing body height h3 (mm) between the mortar top and the solplate underside. */
|
||||
h3: number;
|
||||
}
|
||||
|
||||
export type GroundPosition = '육상' | '하상' | '해상';
|
||||
|
||||
@@ -3,6 +3,8 @@ import type { Issue, PierInput, PierType } from './types';
|
||||
import { validateGround, validatePileEmbedment, validateStrataNValues } from './ground';
|
||||
import { integratedCheck } from './bearing-layout';
|
||||
import { checkBlockRebar } from './rebar/block-check';
|
||||
import { castSettingsIssues } from './rebar/cast-in-place';
|
||||
import { castShape, castShapeIssues } from './cast-in-place-shape';
|
||||
|
||||
const EXPECTED_COLUMNS: Record<PierType, number> = { 'T형': 1, '2주형': 2, '3주형': 3 };
|
||||
|
||||
@@ -16,6 +18,13 @@ export const NOT_IMPLEMENTED_RULES = [
|
||||
/** Input-level checks (spec §10 E01, E02). Returns false when the pier must not be built. */
|
||||
export function validateInput(input: PierInput, issues: Issue[]): boolean {
|
||||
let ok = true;
|
||||
if (input.foundationType === '현장타설말뚝') {
|
||||
for (const message of castShapeIssues(input.castInPlaceShape)) issues.push({ code: 'CIP02', level: 'W', pier: input.name, message });
|
||||
const shape = castShape(input);
|
||||
if (shape.casingOn && shape.casingTopEl <= shape.casingBottomEl) issues.push({ code: 'CIP02', level: 'W', pier: input.name, message: '희생강관 상단 EL은 하단 EL보다 높아야 합니다.' });
|
||||
if (shape.weatheredRockEl < shape.bedrockEl) issues.push({ code: 'CIP02', level: 'W', pier: input.name, message: '풍화암 EL이 기반암 EL보다 낮습니다. 암반 입력을 확인하세요.' });
|
||||
for (const message of castSettingsIssues(input.castInPlaceRebar, input.pileLengthMm, input.columnDiameterMm ?? 2000)) issues.push({ code: 'CIP01', level: 'W', pier: input.name, message });
|
||||
}
|
||||
if (input.columnCount !== EXPECTED_COLUMNS[input.type]) {
|
||||
issues.push({
|
||||
code: 'E01',
|
||||
@@ -38,6 +47,20 @@ export function validateInput(input: PierInput, issues: Issue[]): boolean {
|
||||
|
||||
/** Geometry-level checks (spec §10 W01–W05, W07, I01) on a built model. */
|
||||
export function validatePier(input: PierInput, model: PierModel, issues: Issue[]): void {
|
||||
// W10 (2026-11-03): the input 교좌면 EL should match the 두부보 상면 slope within 5 mm; the input wins.
|
||||
for (const b of model.blocks) {
|
||||
const seat = input.bearings.find((r) => r.no === b.no)?.seatEl;
|
||||
if (seat === undefined) continue;
|
||||
const diffMm = (seat - b.seatCalc) * 1000;
|
||||
if (Math.abs(diffMm) > 5) {
|
||||
issues.push({
|
||||
code: 'W10',
|
||||
level: 'W',
|
||||
pier: input.name,
|
||||
message: `${b.row} 받침 ${b.no}번 교좌면 EL ${seat.toFixed(3)}은(는) 두부보 상면 계산값 ${b.seatCalc.toFixed(3)}과 ${diffMm.toFixed(1)} mm 차이가 납니다 (허용 5 mm). 입력값을 씁니다.`,
|
||||
});
|
||||
}
|
||||
}
|
||||
const ip2Gap = Math.hypot(model.ip2Input.e - model.ip2Calc.e, model.ip2Input.n - model.ip2Calc.n);
|
||||
if (ip2Gap > 0.001) {
|
||||
issues.push({
|
||||
|
||||
+3381
-510
File diff suppressed because it is too large.
Load diff
+164
-26
@@ -212,17 +212,26 @@ select {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
max-height: 70vh;
|
||||
overflow: auto;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
/* The side panel is a flex column; without this the list shrinks to 0 height and shows no rows. */
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Rows: name + badge (2 columns). Rows with a checkbox get a 28 px first column. */
|
||||
.pier-items li {
|
||||
display: grid;
|
||||
grid-template-columns: 28px 1fr auto;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
gap: 8px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
padding: 2px 0;
|
||||
border-radius: 6px;
|
||||
padding: 2px 6px 2px 0;
|
||||
}
|
||||
|
||||
.pier-items li:has(> input[type='checkbox']) {
|
||||
grid-template-columns: 28px minmax(0, 1fr) auto;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.pier-items input[type='checkbox'] {
|
||||
@@ -525,6 +534,21 @@ summary {
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.sheet-pier label { flex: 1; cursor: pointer; }
|
||||
.sheet-pier input:disabled + label { color: var(--muted); cursor: default; }
|
||||
|
||||
.material-fields { border: 0; padding: 0; margin: 16px 0 0; min-width: 0; }
|
||||
.material-fields legend { color: var(--muted); font-weight: 600; padding: 0; margin-bottom: 8px; }
|
||||
.material-fields .mat { display: grid; grid-template-columns: minmax(0, 1fr) minmax(88px, 1fr); align-items: center; gap: 8px; margin-bottom: 8px; }
|
||||
.material-fields input, .material-fields select { width: 100%; min-width: 0; }
|
||||
.review-tasks { display: grid; gap: 12px; }
|
||||
.review-tasks li > p { margin: 4px 0 8px; }
|
||||
.workspace-tools { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; padding: 8px; background: var(--panel); border: 1px solid var(--line); border-radius: 6px; }
|
||||
.model-height-control { display: flex; align-items: center; gap: 8px; font-size: 12px; flex: 1 1 240px; }
|
||||
.model-height-control input { width: 100%; min-width: 72px; flex: 1; accent-color: var(--primary); }
|
||||
.model-height-control output { min-width: 48px; white-space: nowrap; font-variant-numeric: tabular-nums; }
|
||||
.workspace-tools button { flex: 0 0 auto; }
|
||||
|
||||
.rules {
|
||||
margin: 0;
|
||||
padding-left: 18px;
|
||||
@@ -606,25 +630,6 @@ body { background: #eef1f4; font-family: 'Noto Sans KR', system-ui, sans-serif;
|
||||
.top .brand strong { font-size: 18px; letter-spacing: 0.01em; }
|
||||
.top .brand > span:not(.proj) { font-size: 12px; color: #b8c2ce; }
|
||||
.proj { padding-left: 14px; border-left: 1px solid #33404f; color: #ffffff; font-size: 13px; font-weight: 700; }
|
||||
.top { display: grid; grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr); flex-wrap: nowrap; }
|
||||
.top > .steps { justify-self: center; }
|
||||
.top > .actions { justify-self: end; }
|
||||
.top .brand { flex: 1 1 0; min-width: 0; align-items: center; }
|
||||
.top .brand > strong, .top .brand > span:not(.proj) { flex-shrink: 0; white-space: nowrap; }
|
||||
.top .proj { min-width: 0; overflow: hidden; overflow-wrap: anywhere; line-height: 1.5; max-height: 3em; display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; line-clamp: 2; }
|
||||
.top .steps, .top .actions { flex: 0 0 auto; flex-wrap: nowrap; align-items: center; }
|
||||
.top .steps .step, .top .actions button, .top .savest { white-space: nowrap; }
|
||||
@media (max-width: 1600px) {
|
||||
.top { display: grid; grid-template-columns: minmax(0, 1fr) auto; }
|
||||
.top .brand { grid-column: 1; grid-row: 1; }
|
||||
.top .actions { grid-column: 2; grid-row: 1; justify-content: flex-end; }
|
||||
.top .steps { grid-column: 1 / -1; grid-row: 2; }
|
||||
}
|
||||
@media (max-width: 900px) {
|
||||
.top { grid-template-columns: minmax(0, 1fr); }
|
||||
.top .steps, .top .actions { max-width: 100%; overflow-x: auto; }
|
||||
.top .actions { grid-column: 1; grid-row: 3; justify-content: flex-start; justify-self: start; }
|
||||
}
|
||||
.steps .step { color: #dce3ea; min-height: 44px; padding: 4px 14px; border-radius: 6px; background: transparent; border: 0; }
|
||||
.steps .step.current { background: #ffffff; color: #18212b; }
|
||||
.steps .step em { font-style: normal; color: #b8c2ce; font-size: 12px; }
|
||||
@@ -649,7 +654,6 @@ body { background: #eef1f4; font-family: 'Noto Sans KR', system-ui, sans-serif;
|
||||
.pier-tools { display: flex; align-items: center; gap: 8px; padding: 0 4px 8px; }
|
||||
.pier-tools select { border: 1px solid #c3cbd4; border-radius: 6px; background: #ffffff; min-height: 36px; }
|
||||
.phead { display: grid; grid-template-columns: 1fr auto; gap: 8px; padding: 0 10px 6px; font-size: 12px; color: #55606e; border-bottom: 1px solid #d5dbe2; }
|
||||
.pier-items li { display: grid; grid-template-columns: 1fr auto; align-items: center; gap: 8px; border-radius: 6px; padding-right: 6px; }
|
||||
.pier-items li:has(.pier-name[aria-pressed='true']) { background: #e3edf8; }
|
||||
.pier-name { width: 100%; border: 0; background: transparent; color: #18212b; text-align: left; font-size: 13px; min-height: 44px; padding: 0 6px; }
|
||||
.pier-name[aria-pressed='true'] { color: #123e70; font-weight: 700; }
|
||||
@@ -692,7 +696,6 @@ body { background: #eef1f4; font-family: 'Noto Sans KR', system-ui, sans-serif;
|
||||
.chip-opt { min-height: 36px; padding: 0 10px; border: 1px solid #c3cbd4; border-radius: 16px; background: #ffffff; color: #18212b; font-size: 13px; cursor: pointer; }
|
||||
.chip-opt[aria-pressed='true'] { background: #1f5fa8; border-color: #1f5fa8; color: #ffffff; font-weight: 700; }
|
||||
.chip-opt:disabled { color: #8a95a3; background: #f2f4f7; border-style: dashed; cursor: default; }
|
||||
.pier-items { max-height: 300px; overflow-y: auto; }
|
||||
/* 열별 본수 (C4) */
|
||||
.pile-rows { margin-top: 10px; display: grid; gap: 6px; }
|
||||
.pile-row { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
|
||||
@@ -764,6 +767,8 @@ body { background: #eef1f4; font-family: 'Noto Sans KR', system-ui, sans-serif;
|
||||
.grid > #viewer-block .canvas { flex: 1 1 auto; height: auto; min-height: 240px; }
|
||||
/* ②–④: the model keeps a fixed band at the top; the table scrolls below it. */
|
||||
.center-col { display: grid; grid-template-rows: clamp(280px, 48vh, 560px) minmax(0, 1fr); gap: 12px; }
|
||||
.review-workspace { grid-template-rows: auto minmax(180px, var(--review-model-height, 240px)) minmax(0, 1fr); }
|
||||
.review-workspace.model-collapsed { grid-template-rows: auto minmax(0, 1fr); }
|
||||
.viewer-slot { min-height: 0; height: 100%; display: flex; flex-direction: column; }
|
||||
.viewer-slot > .viewer { flex: 1 1 auto; min-height: 0; display: flex; flex-direction: column; }
|
||||
.viewer-slot .canvas { flex: 1 1 auto; height: auto; min-height: 180px; }
|
||||
@@ -777,9 +782,14 @@ body { background: #eef1f4; font-family: 'Noto Sans KR', system-ui, sans-serif;
|
||||
.grid > .panel, .grid > .center-col { max-height: 60vh; overflow: auto; }
|
||||
.center-col { display: flex; flex-direction: column; gap: 16px; }
|
||||
.viewer-slot .canvas { height: 60vh; min-height: 420px; flex: none; }
|
||||
.grid > .review-workspace { max-height: none; }
|
||||
.review-workspace .viewer-slot .canvas { height: var(--review-model-height, 240px); min-height: 180px; }
|
||||
.review-workspace > .viewer { max-height: 75vh; overflow: auto; }
|
||||
.rebar-table thead th { position: sticky; top: 0; background: #ffffff; z-index: 1; }
|
||||
}
|
||||
|
||||
.review-workspace.model-collapsed #slot-rebar { display: none; }
|
||||
|
||||
/* 3D 영역 (승인 2026-10-23): 제목은 교각 목록으로. 좌측 스택(부재 표시 · 시점), 우측 스택(색 기준 · 원점)을 캔버스 위에 둠. */
|
||||
#viewer-block { position: relative; }
|
||||
#viewer-block .canvas { position: relative; }
|
||||
@@ -796,6 +806,8 @@ body { background: #eef1f4; font-family: 'Noto Sans KR', system-ui, sans-serif;
|
||||
.viewer-stack { display: flex; flex-direction: column; gap: 8px; width: 168px; z-index: 5; pointer-events: none; }
|
||||
.viewer-stack > * { pointer-events: auto; }
|
||||
#viewer-block .viewer-stack-right { position: absolute; top: 8px; right: 8px; width: 180px; align-items: stretch; }
|
||||
.review-workspace #viewer-block .viewer-stack-right { bottom: 8px; max-height: calc(100% - 16px); overflow-y: auto; overflow-x: hidden; overscroll-behavior: contain; }
|
||||
.review-workspace #all-toggle { display: none; }
|
||||
#viewer-block .info-panel { background: rgba(255, 255, 255, 0.92); border: 1px solid #d5dde6; border-radius: 6px; box-shadow: 0 4px 14px rgba(0, 0, 0, 0.12); padding: 0 8px 8px; font-size: 13px; }
|
||||
#viewer-block .info-panel > summary { min-height: 44px; display: flex; align-items: center; cursor: pointer; font-weight: 700; color: #18212b; list-style-position: outside; }
|
||||
#viewer-block .ground-map-options { display: flex; flex-direction: column; gap: 4px; }
|
||||
@@ -844,6 +856,14 @@ body { background: #eef1f4; font-family: 'Noto Sans KR', system-ui, sans-serif;
|
||||
.footing-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
/* 교량받침 삽도: the plan on the left, the section on its right. Narrow screens stack both. */
|
||||
.bearing-sketch { display: grid; grid-template-columns: minmax(240px, 1fr) minmax(240px, 1fr); gap: 12px; align-items: start; margin-top: 12px; }
|
||||
.bearing-sketch figure { margin: 0; }
|
||||
.bearing-sketch svg { display: block; width: 100%; max-width: none !important; height: auto; background: #ffffff; border: 1px solid #d5dde6; border-radius: 6px; }
|
||||
@media (max-width: 800px) {
|
||||
.bearing-sketch { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
/* 입력값: one row per pier; the table scrolls sideways in the narrow left panel. */
|
||||
.pier-table-wrap { overflow-x: auto; margin-bottom: 8px; }
|
||||
#inputs-all { font-size: 12px; width: 100%; }
|
||||
@@ -871,3 +891,121 @@ body { background: #eef1f4; font-family: 'Noto Sans KR', system-ui, sans-serif;
|
||||
.copy-panel .cp-chips .chk:has(input:disabled) { color: #8a94a3; background: #f4f6f9; }
|
||||
|
||||
.quantity-total { margin-bottom: 16px; }
|
||||
|
||||
/* 교대 삽도 (벽체·흉벽 개략 단면): fills the panel width; the labels follow the card's values. */
|
||||
|
||||
/* ② 형상계획: with an abutment selected, only the abutment card shows; the pier cards are hidden (2026-11-03). */
|
||||
#view-plan.abut-selected .members-title,
|
||||
#view-plan.abut-selected #members,
|
||||
#view-plan.abut-selected section[aria-labelledby="member-title"],
|
||||
#view-plan.abut-selected #copy-panel { display: none; }
|
||||
|
||||
/* ② 부재 · 교대 list: shown only while an abutment is selected (2026-11-03). */
|
||||
.abut-members-title,
|
||||
#abut-members { display: none; }
|
||||
#view-plan.abut-selected .abut-members-title,
|
||||
#view-plan.abut-selected #abut-members { display: block; }
|
||||
#abut-members .pier-name span:first-child { display: block; }
|
||||
|
||||
/* 교대 형상 입력: a 3-column grid of input · default (시안 ②), the 확인 필요 notes at the end (2026-11-03). */
|
||||
#abut-panel-plan-shape, #abut-panel-shape { display: grid; grid-template-columns: minmax(0, 1.4fr) minmax(0, 1fr) minmax(0, 1fr); gap: 6px 10px; align-items: center; }
|
||||
#abut-panel-plan-shape > .abut-qa { grid-column: 1 / -1; }
|
||||
.abut-qa { border-top: 1px solid #d5dbe2; padding-top: 8px; }
|
||||
.abut-qa p { margin: 3px 0; }
|
||||
|
||||
/* 교대 구간 표 (시안 하단) and the 형상 card shown outside the collapsed editor (2026-11-03). */
|
||||
#abut-panel-plan-shape.abut-shape-view { display: grid; grid-template-columns: minmax(0, 1.4fr) minmax(0, 1fr) minmax(0, 1fr); gap: 6px 10px; align-items: center; }
|
||||
#abut-panel-plan-shape > .abut-seg { grid-column: 1 / -1; width: 100%; border-collapse: collapse; font-size: 12px; }
|
||||
#abut-panel-plan-shape > .abut-seg th, #abut-panel-plan-shape > .abut-seg td { border-bottom: 1px solid #e6eaef; padding: 4px 6px; text-align: right; font-family: 'JetBrains Mono', ui-monospace, monospace; }
|
||||
#abut-panel-plan-shape > .abut-seg th { color: #55606e; font-weight: 500; font-family: inherit; }
|
||||
|
||||
/* ② 교량받침 카드 (2026-11-03): tables and notes span the card; rule inputs use the 3-column grid; wide tables scroll sideways. */
|
||||
#abut-panel-plan-bearings { display: grid; grid-template-columns: minmax(0, 1.4fr) minmax(0, 1fr) minmax(0, 1fr); gap: 6px 10px; align-items: center; }
|
||||
#abut-panel-plan-bearings > p, #abut-panel-plan-bearings > table { grid-column: 1 / -1; }
|
||||
#abut-panel-plan-bearings > .abut-seg { display: block; overflow-x: auto; border-collapse: collapse; font-size: 12px; }
|
||||
#abut-panel-plan-bearings > .abut-seg th, #abut-panel-plan-bearings > .abut-seg td { border-bottom: 1px solid #e6eaef; padding: 4px 6px; text-align: right; white-space: nowrap; font-family: 'JetBrains Mono', ui-monospace, monospace; }
|
||||
#abut-panel-plan-bearings > .abut-seg th { color: #55606e; font-weight: 500; font-family: inherit; }
|
||||
|
||||
/* ② 교대 카드 under the 3D view (2026-11-03): pickers, the three 받침 삽도 side by side, full-width rows. */
|
||||
#abut-panel-plan .abut-bearing-figs { grid-column: 1 / -1; display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 10px; }
|
||||
#abut-panel-plan .abut-fig { margin: 0; background: #f6f8fa; border: 1px solid #d5dbe2; border-radius: 6px; padding: 6px; }
|
||||
#abut-panel-plan .abut-fig svg { display: block; width: 100%; height: auto; }
|
||||
#abut-panel-plan > .edit-row { padding: 0 12px 10px; }
|
||||
@media (max-width: 900px) { #abut-panel-plan .abut-bearing-figs { grid-template-columns: minmax(0, 1fr); } }
|
||||
|
||||
/* ② 벽체·흉벽 / 확대기초·말뚝 카드 (2026-11-03): the two 삽도 side by side, notes and tables across the card. */
|
||||
#abut-panel-plan-footing.abut-shape-view { display: grid; grid-template-columns: minmax(0, 1.4fr) minmax(0, 1fr) minmax(0, 1fr); gap: 6px 10px; align-items: center; }
|
||||
#abut-panel-plan .abut-wall-figs { grid-column: 1 / -1; display: grid; grid-template-columns: minmax(0, 2fr) minmax(0, 3fr); gap: 10px; }
|
||||
#abut-panel-plan-shape > p, #abut-panel-plan-shape > .abut-wall-figs { grid-column: 1 / -1; }
|
||||
#abut-panel-plan .abut-note-warn { color: #8a5a00; }
|
||||
#abut-panel-plan .abut-memo { background: #f6f8fa; border: 1px solid #d5dbe2; border-radius: 6px; padding: 8px; }
|
||||
@media (max-width: 900px) { #abut-panel-plan .abut-wall-figs { grid-template-columns: minmax(0, 1fr); } }
|
||||
|
||||
/* ② 확대기초 · 말뚝 카드 (2026-11-04): the plan drawing, notes and the 열별 본수 table span the card. */
|
||||
#abut-panel-plan-footing > figure, #abut-panel-plan-footing > p, #abut-panel-plan-footing > table { grid-column: 1 / -1; }
|
||||
#abut-panel-plan-footing > .abut-seg { border-collapse: collapse; font-size: 12px; width: 100%; }
|
||||
#abut-panel-plan-footing > .abut-seg th, #abut-panel-plan-footing > .abut-seg td { border-bottom: 1px solid #e6eaef; padding: 4px 6px; text-align: left; font-family: 'JetBrains Mono', ui-monospace, monospace; }
|
||||
#abut-panel-plan-footing > .abut-seg th { color: #55606e; font-weight: 500; font-family: inherit; }
|
||||
#abut-panel-plan .abut-footing-fig { max-width: 760px; }
|
||||
|
||||
/* 시안 선택 줄 (.grp · .opts · .opt / .optt, 2026-11-04): icon tiles and text tiles, the 기본 badge, dashed when not selectable. */
|
||||
#abut-panel-plan .abut-grp { grid-column: 1 / -1; display: flex; flex-direction: column; gap: 6px; margin: 4px 0; }
|
||||
.abut-grp-title { font-size: 12px; color: #55606e; }
|
||||
.abut-opts { display: flex; flex-wrap: wrap; gap: 8px; }
|
||||
.abut-opt { position: relative; font: inherit; font-size: 12px; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 2px; width: 84px; min-height: 56px; padding: 6px 4px; border: 1px solid #c3cbd4; border-radius: 6px; background: #ffffff; color: #18212b; cursor: pointer; }
|
||||
.abut-optt { position: relative; font: inherit; font-size: 13px; min-width: 64px; min-height: 44px; padding: 0 12px; border: 1px solid #c3cbd4; border-radius: 6px; background: #ffffff; color: #18212b; cursor: pointer; }
|
||||
.abut-opt.on, .abut-optt.on { border: 2px solid #1f5fa8; background: #f2f7fc; color: #123e70; font-weight: 700; }
|
||||
.abut-opt:disabled, .abut-optt:disabled { cursor: not-allowed; border-style: dashed; color: #5f6a76; background: #f6f8fa; }
|
||||
.abut-def { position: absolute; top: -9px; right: 4px; font-size: 11px; font-weight: 600; line-height: 1; padding: 3px 6px; border-radius: 8px; background: #4a5868; color: #ffffff; }
|
||||
.abut-tbdn { display: block; font-size: 12px; color: #55606e; }
|
||||
|
||||
/* ② 날개벽 카드 (2026-11-04): tiles and the three 삽도 across the card, then the per-side inputs in the 3-column grid. */
|
||||
#abut-panel-plan-wing { display: grid; grid-template-columns: minmax(0, 1.4fr) minmax(0, 1fr) minmax(0, 1fr); gap: 6px 10px; align-items: center; }
|
||||
#abut-panel-plan-wing > p, #abut-panel-plan-wing > .abut-wall-figs { grid-column: 1 / -1; }
|
||||
#abut-panel-plan .abut-wing-figs { grid-template-columns: minmax(0, 1.2fr) minmax(0, 1.2fr) minmax(0, 1fr); }
|
||||
@media (max-width: 1100px) { #abut-panel-plan .abut-wing-figs { grid-template-columns: minmax(0, 1fr); } }
|
||||
|
||||
/* ② 날개벽 형식 1행 (2026-11-04): 좌 · 우 묶음을 나란히, 작은 타일, 편집 중인 측은 진한 테두리. */
|
||||
#abut-panel-plan .abut-wing-forms { grid-column: 1 / -1; display: flex; flex-wrap: wrap; align-items: flex-end; gap: 8px 18px; }
|
||||
#abut-panel-plan .abut-wing-forms > .abut-grp-title { flex-basis: 100%; }
|
||||
#abut-panel-plan .abut-wing-forms .abut-grp { margin: 0; padding: 6px 8px 8px; border: 1px solid transparent; border-radius: 8px; }
|
||||
#abut-panel-plan .abut-wing-forms .abut-grp.editing { border-color: #1f5fa8; background: #f8fbfe; }
|
||||
#abut-panel-plan .abut-wing-forms .abut-grp .abut-grp-title { cursor: pointer; font-weight: 600; color: #18212b; }
|
||||
#abut-panel-plan .abut-grp.compact .abut-opt { width: 64px; min-height: 48px; padding: 4px 2px; }
|
||||
#abut-panel-plan-slab > p, #abut-panel-plan-slab > .abut-wall-figs { grid-column: 1 / -1; }
|
||||
#abut-panel-plan-ground > p, #abut-panel-plan-ground > .table-scroll, #abut-panel-plan-ground > figure { grid-column: 1 / -1; }
|
||||
#abut-panel-plan-ground > figure { margin: 4px 0; max-width: 560px; }
|
||||
/* ③ 교대 배근 (2026-11-04) */
|
||||
#rebar-table .abut-rebar-head { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; margin: 4px 0; }
|
||||
#rebar-table .abut-rebar-head .abut-optt { border: 1px solid #c9d2dc; border-radius: 8px; background: #fff; padding: 4px 10px; font-size: 12px; cursor: pointer; }
|
||||
#rebar-table .abut-rebar-head .abut-optt.on { border-color: #1f5fa8; background: #f0f6fd; font-weight: 600; }
|
||||
#rebar-table .abut-rebar-fig { margin: 4px 0; }
|
||||
#rebar-table .abut-rebar-inputs { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 8px 12px; margin: 8px 0; }
|
||||
#rebar-table .abut-rebar-inputs > .lbl { grid-column: 1 / -1; margin: 0; }
|
||||
#rebar-table .abut-rebar-inputs label { display: block; font-size: 12px; color: #55606e; }
|
||||
#rebar-table .abut-rebar-inputs input { width: 100%; }
|
||||
#rebar-table .abut-rebar-auto { display: flex; flex-direction: column; gap: 2px; padding: 4px 6px; background: #f5f7f9; border-radius: 6px; }
|
||||
#rebar-table .abut-rebar-tablehead { display: flex; justify-content: space-between; align-items: center; margin: 8px 0 4px; }
|
||||
#rebar-table .abut-grp { display: flex; flex-direction: column; gap: 6px; margin: 6px 0; }
|
||||
#rebar-table .abut-opts { display: flex; flex-wrap: wrap; gap: 8px; }
|
||||
|
||||
/* Compact desktop header; the file controls retain their native button behaviour. */
|
||||
.top { flex-wrap: nowrap; }
|
||||
.top .brand { min-width: 0; flex: 1; }
|
||||
.top .brand strong { flex-shrink: 0; }
|
||||
.top .proj { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.top .steps, .top .actions { flex-wrap: nowrap; flex-shrink: 0; align-items: center; white-space: nowrap; }
|
||||
.top .step em:empty { display: none; }
|
||||
.file-menu { position: relative; margin: 0; }
|
||||
.file-menu > summary { cursor: pointer; list-style: none; gap: 8px; }
|
||||
.file-menu > summary::after { content: '▾'; }
|
||||
.file-menu-items { position: absolute; right: 0; top: 100%; z-index: 20; display: flex; flex-direction: column; padding: 8px; background: var(--panel); border: 1px solid var(--line); border-radius: 6px; }
|
||||
.file-menu-items .btn { white-space: nowrap; color: var(--text); }
|
||||
[data-cast-part='combined'] table .edit-row { display: block; }
|
||||
[data-cast-part='combined'] table label { display: block; min-width: 0; font-size: 12px; }
|
||||
[data-cast-part='combined'] table input { width: 110px; }
|
||||
@media (max-width: 1100px) {
|
||||
.top { flex-wrap: wrap; }
|
||||
.top .brand { flex-basis: 100%; }
|
||||
.top .steps, .top .actions { flex-wrap: wrap; flex-shrink: 1; }
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* 교대 받침 상세 삽도 (2026-11-03, 시안 ② 교량받침 카드): 평면, 교축 방향 단면, 솔플레이트 상세. 순수 문자열 함수.
|
||||
* 입력은 받침 표에서 이미 계산한 값뿐이다(교대 모델을 다시 읽지 않는다). 치수는 mm, EL은 m.
|
||||
*/
|
||||
|
||||
export interface AbutBearingDetail {
|
||||
no: number;
|
||||
/** 블럭 평면 (정사각), 몰탈패드 평면 (정사각), mm. */
|
||||
blockMm: number;
|
||||
padMm: number;
|
||||
/** 솔플레이트 교직 L × 교축 W (mm). 받침 본체 평면은 입력이 없어 그리지 않는다. */
|
||||
L: number;
|
||||
W: number;
|
||||
/** 높이 (mm): 블럭 콘크리트 h1, 패드 h2, 받침 h3, 솔플레이트 h4 = T. */
|
||||
h1: number;
|
||||
h2: number;
|
||||
h3: number;
|
||||
h4: number;
|
||||
T1: number;
|
||||
T2: number;
|
||||
/** 블럭 평면 회전 (deg), 교대축 직각 기준. */
|
||||
rotDeg: number;
|
||||
/** 솔플레이트 경사 (%). */
|
||||
S: number;
|
||||
seatEl: number;
|
||||
z: number;
|
||||
}
|
||||
|
||||
const INK = '#18212b';
|
||||
const MUTED = '#55606e';
|
||||
const ORANGE = '#c46a1a';
|
||||
const text = (x: number, y: number, s: string, anchor = 'middle', fill = INK) =>
|
||||
`<text x="${x.toFixed(1)}" y="${y.toFixed(1)}" text-anchor="${anchor}" font-size="11" fill="${fill}" font-family="JetBrains Mono, monospace">${s}</text>`;
|
||||
const square = (cx: number, cy: number, w: number, h: number, style: string) =>
|
||||
`<rect x="${(cx - w / 2).toFixed(1)}" y="${(cy - h / 2).toFixed(1)}" width="${w.toFixed(1)}" height="${h.toFixed(1)}" ${style}/>`;
|
||||
|
||||
/** 평면: 블럭 1200, 몰탈패드 1000 (점선), 솔플레이트 L × W (주황). 블럭은 받침 방위각만큼 돈다. */
|
||||
export function abutBearingPlanSvg(d: AbutBearingDetail): string {
|
||||
const k = 0.17; // px per mm
|
||||
const cx = 150;
|
||||
const cy = 130;
|
||||
const turn = `transform="rotate(${(-d.rotDeg).toFixed(3)} ${cx} ${cy})"`;
|
||||
return [
|
||||
`<svg viewBox="0 0 300 270" width="100%" role="img" aria-label="받침 ${d.no} 평면: 블럭 ${d.blockMm}, 몰탈패드 ${d.padMm}, 솔플레이트 ${d.L} × ${d.W}, 회전 ${d.rotDeg.toFixed(2)}°">`,
|
||||
`<g ${turn}>`,
|
||||
square(cx, cy, d.blockMm * k, d.blockMm * k, `fill="#dfe5ec" stroke="${MUTED}"`),
|
||||
square(cx, cy, d.padMm * k, d.padMm * k, `fill="none" stroke="${MUTED}" stroke-dasharray="4 3"`),
|
||||
square(cx, cy, d.L * k, d.W * k, `fill="#f0c9a0" fill-opacity="0.6" stroke="${ORANGE}"`),
|
||||
`</g>`,
|
||||
text(cx, cy - (d.blockMm * k) / 2 - 8, `${d.blockMm}`),
|
||||
text(cx, cy + 4, `솔플 ${d.L} × ${d.W}`),
|
||||
text(cx, cy + (d.blockMm * k) / 2 + 16, `패드 ${d.padMm} (점선)`, 'middle', MUTED),
|
||||
text(cx, 262, `받침 ${d.no} · 회전 ${d.rotDeg.toFixed(2)}° (방위각)`, 'middle', MUTED),
|
||||
'</svg>',
|
||||
].join('');
|
||||
}
|
||||
|
||||
/** 교축 방향 단면 (높이 2배 과장): 블럭 h1, 패드 h2, 받침 h3, 솔플레이트 h4. E = h1 + h2 + h3 + h4. */
|
||||
export function abutBearingSectionSvg(d: AbutBearingDetail): string {
|
||||
const kx = 0.17;
|
||||
const ky = 0.34; // 2× exaggerated heights
|
||||
const cx = 150;
|
||||
const base = 215;
|
||||
const E = d.h1 + d.h2 + d.h3 + d.h4;
|
||||
const layer = (bottom: number, h: number, w: number, style: string) =>
|
||||
`<rect x="${(cx - (w * kx) / 2).toFixed(1)}" y="${(base - (bottom + h) * ky).toFixed(1)}" width="${(w * kx).toFixed(1)}" height="${(h * ky).toFixed(1)}" ${style}/>`;
|
||||
const at = (mm: number) => base - mm * ky;
|
||||
const right = cx + (d.blockMm * kx) / 2 + 6;
|
||||
return [
|
||||
`<svg viewBox="0 0 330 250" width="100%" role="img" aria-label="받침 ${d.no} 단면: h1 ${d.h1}, h2 ${d.h2}, h3 ${d.h3}, h4 ${d.h4}, E ${E}">`,
|
||||
`<line x1="20" y1="${base}" x2="300" y2="${base}" stroke="${MUTED}"/>`,
|
||||
layer(0, d.h1, d.blockMm, `fill="#dfe5ec" stroke="${MUTED}"`),
|
||||
layer(d.h1, d.h2, d.padMm, `fill="#c9d1da" stroke="${MUTED}"`),
|
||||
layer(d.h1 + d.h2, d.h3, d.L - 100, `fill="#b9c3ce" stroke="${INK}"`),
|
||||
layer(d.h1 + d.h2 + d.h3, d.h4, d.L, `fill="#f0c9a0" stroke="${ORANGE}"`),
|
||||
text(right, at(d.h1 / 2) + 4, `h1 ${d.h1}`, 'start'),
|
||||
text(right, at(d.h1 + d.h2 / 2) + 4, `h2 ${d.h2}`, 'start'),
|
||||
text(right, at(d.h1 + d.h2 + d.h3 / 2) + 4, `h3 ${d.h3}`, 'start'),
|
||||
text(right, at(E - d.h4 / 2) + 4, `h4 ${d.h4}`, 'start'),
|
||||
text(cx, at(E) - 10, `E = ${E}`),
|
||||
text(24, base + 16, `교좌면 EL.${d.seatEl.toFixed(3)}`, 'start', MUTED),
|
||||
text(24, at(d.h1 + d.h2) - 4, `z EL.${d.z.toFixed(3)}`, 'start', MUTED),
|
||||
text(cx, 244, '교축 방향 · 높이 2배 과장', 'middle', MUTED),
|
||||
'</svg>',
|
||||
].join('');
|
||||
}
|
||||
|
||||
/** 솔플레이트 상세: 길이 L, 두께 T1 → T2 (경사 S). 두께는 과장해서 그린다. */
|
||||
export function abutSolplateSvg(d: AbutBearingDetail): string {
|
||||
const kx = 0.3;
|
||||
const ky = 2.2; // thickness exaggerated
|
||||
const x0 = 45;
|
||||
const x1 = x0 + d.L * kx;
|
||||
const base = 120;
|
||||
return [
|
||||
`<svg viewBox="0 0 330 190" width="100%" role="img" aria-label="받침 ${d.no} 솔플레이트: L ${d.L}, T1 ${d.T1}, T2 ${d.T2}, S ${d.S.toFixed(3)} %">`,
|
||||
`<polygon points="${x0},${base} ${x1.toFixed(1)},${base} ${x1.toFixed(1)},${(base - d.T2 * ky).toFixed(1)} ${x0},${(base - d.T1 * ky).toFixed(1)}" fill="#f0c9a0" stroke="${ORANGE}"/>`,
|
||||
text(x0 - 4, base - (d.T1 * ky) / 2 + 4, `T1 ${d.T1}`, 'end'),
|
||||
text(x1 + 4, base - (d.T2 * ky) / 2 + 4, `T2 ${d.T2}`, 'start'),
|
||||
text((x0 + x1) / 2, base + 18, `L ${d.L} · W ${d.W}`),
|
||||
text((x0 + x1) / 2, base - Math.max(d.T1, d.T2) * ky - 10, `S ${d.S.toFixed(3)} % · T ${d.h4}`),
|
||||
text(165, 180, '솔플레이트 상세 · 두께 과장 · 가정 (QA-15)', 'middle', MUTED),
|
||||
'</svg>',
|
||||
].join('');
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* 교대 받침블럭 배근 삽도 (2026-11-05, 시안 ③ 받침블럭 카드, 받침블럭철근.md §8): 평면 (블럭 1개, S2 실선 · S1 파선),
|
||||
* 단면 (교대축 방향 절단: S1 아래 줄, S2 위 줄, 다리, 블럭 · 패드 · 교좌면). 순수 문자열 함수. 철근은 block.ts paths에서 그린다.
|
||||
* 색: S1 (주철근, 아래) #0F5C66, S2 (보조, 위) #B4531F.
|
||||
*/
|
||||
import type { AbutBlockRebar, AbutBlockPath } from '../core/abut/rebar/block';
|
||||
import { balloon, CONC, MUTED, NO_PICK, opacity, text, WALL, type AbutRebarPick } from './abut-footing-rebar-svg';
|
||||
|
||||
export const ABUT_BLOCK_COLORS = { s1: '#0F5C66', s2: '#B4531F' } as const;
|
||||
const C = ABUT_BLOCK_COLORS;
|
||||
|
||||
/** 블럭 한 개의 경로만 쓴다 (첫 블럭, 방위각 0 가정). */
|
||||
function firstBlock(paths: AbutBlockPath[]): AbutBlockPath[] {
|
||||
const b = paths[0]?.block;
|
||||
return paths.filter((p) => p.block === b);
|
||||
}
|
||||
|
||||
/** 평면: 블럭 1200 × 1200. S2 (교대축 간격, 교대축 직각으로 뻗음) 실선, S1 (교대축 직각 간격, 교대축 방향) 파선. */
|
||||
export function abutBlockPlanSvg(r: AbutBlockRebar, pick: AbutRebarPick = NO_PICK, blockCenter: [number, number] = [0, 0]): string {
|
||||
const ps = firstBlock(r.paths);
|
||||
const k = 0.2;
|
||||
const X = (s: number) => 40 + (s - blockCenter[0] + 600) * k;
|
||||
const Y = (t: number) => 40 + (blockCenter[1] + 600 - t) * k;
|
||||
const parts: string[] = [];
|
||||
parts.push(`<rect x="${X(blockCenter[0] - 600).toFixed(1)}" y="${Y(blockCenter[1] + 600).toFixed(1)}" width="${(1200 * k).toFixed(1)}" height="${(1200 * k).toFixed(1)}" fill="${WALL}" stroke="${MUTED}"/>`);
|
||||
for (const p of ps) {
|
||||
const a = p.pts[1];
|
||||
const b = p.pts[2];
|
||||
const dashed = p.mark === 'S1' ? ' stroke-dasharray="4 3"' : '';
|
||||
parts.push(`<line x1="${X(a[0]).toFixed(1)}" y1="${Y(a[1]).toFixed(1)}" x2="${X(b[0]).toFixed(1)}" y2="${Y(b[1]).toFixed(1)}" stroke="${p.mark === 'S1' ? C.s1 : C.s2}" stroke-width="1.6"${dashed} opacity="${opacity(pick, [p.mark])}"/>`);
|
||||
}
|
||||
parts.push(text(40 + 600 * k, 40 + 1200 * k + 16, '블럭 1200 × 1200 · S2 실선 (위) · S1 파선 (아래) · 교대축 → ', 'middle', MUTED, 10));
|
||||
parts.push(balloon(40 - 16, 40 + 600 * k, 'S2', C.s2, pick), balloon(40 + 1200 * k + 16, 40 + 600 * k, 'S1', C.s1, pick));
|
||||
const W = 40 + 1200 * k + 60;
|
||||
const H = 40 + 1200 * k + 40;
|
||||
return `<svg viewBox="0 0 ${W.toFixed(0)} ${H.toFixed(0)}" width="100%" style="max-width:${Math.round(W * 1.4)}px" role="img" aria-label="받침블럭 배근 평면">${parts.join('')}</svg>`;
|
||||
}
|
||||
|
||||
/** 단면 (교대축 방향 절단, 블럭 가운데): 가로 s (블럭 안), 세로 z (교좌면 0, 블럭 상면 = 콘크리트 높이, 패드 위 125). */
|
||||
export function abutBlockSectionSvg(r: AbutBlockRebar, pick: AbutRebarPick = NO_PICK): string {
|
||||
const ps = firstBlock(r.paths).map((p) => ({ ...p, pts: p.pts.map(([s, t, z]) => [s, t, z - r.seatZ] as [number, number, number]) }));
|
||||
const k = 0.42;
|
||||
const kz = 0.42;
|
||||
const zTop = r.concreteH + 50;
|
||||
const zBot = Math.min(...ps.flatMap((p) => p.pts.map((q) => q[2])), 0) - 60;
|
||||
const sLeft = Math.min(...ps.flatMap((p) => p.pts.map((q) => q[0])));
|
||||
const X = (s: number) => 50 + (s - sLeft) * k;
|
||||
const Y = (z: number) => 40 + (zTop - z) * kz;
|
||||
const W = 50 + 1200 * k + 60;
|
||||
const H = Y(zBot) + 40;
|
||||
const parts: string[] = [];
|
||||
const blockL = Math.min(...ps.flatMap((p) => p.pts.map((q) => q[0])));
|
||||
parts.push(`<rect x="${X(blockL + 50).toFixed(1)}" y="${Y(r.concreteH).toFixed(1)}" width="${(1200 * k).toFixed(1)}" height="${(r.concreteH * kz).toFixed(1)}" fill="${CONC}" stroke="${MUTED}"/>`);
|
||||
parts.push(`<rect x="${X(blockL + 50).toFixed(1)}" y="${Y(zTop).toFixed(1)}" width="${(1200 * k).toFixed(1)}" height="${(50 * kz).toFixed(1)}" fill="${WALL}" stroke="${MUTED}" stroke-dasharray="4 3"/>`);
|
||||
parts.push(text(X(blockL + 50) - 6, Y(zTop) + 4, '몰탈패드 50', 'end', MUTED, 10));
|
||||
parts.push(`<line x1="${X(blockL).toFixed(1)}" y1="${Y(0).toFixed(1)}" x2="${X(blockL + 1300).toFixed(1)}" y2="${Y(0).toFixed(1)}" stroke="${MUTED}" stroke-dasharray="6 3"/>`);
|
||||
parts.push(text(X(blockL + 1300) + 6, Y(0) + 4, '교좌면', 'start', MUTED, 10));
|
||||
for (const p of ps) {
|
||||
const color = p.mark === 'S1' ? C.s1 : C.s2;
|
||||
if (p.mark === 'S1') {
|
||||
// S1: 교대축 방향 줄 (단면 가운데에서 보면 점 · 선). 다리 두 개를 같이 그린다.
|
||||
const pts = p.pts.map(([s, , zz]) => `${X(s).toFixed(1)},${Y(zz).toFixed(1)}`).join(' ');
|
||||
parts.push(`<polyline points="${pts}" fill="none" stroke="${color}" stroke-width="1.4" opacity="${opacity(pick, ['S1'])}"/>`);
|
||||
} else {
|
||||
// S2: 교대축 간격으로 놓인 본 — 단면에서 점.
|
||||
const s = p.pts[1][0];
|
||||
parts.push(`<circle cx="${X(s).toFixed(1)}" cy="${Y(p.pts[1][2]).toFixed(1)}" r="2.6" fill="${color}" opacity="${opacity(pick, ['S2'])}"/>`);
|
||||
}
|
||||
}
|
||||
parts.push(text(X(blockL + 600), Y(zBot) + 14, `블럭 콘크리트 ${r.concreteH} · 묻힘 S2 ${Math.round(r.embedment.S2)} · S1 ${Math.round(r.embedment.S1)}`, 'middle', MUTED, 10));
|
||||
parts.push(balloon(X(blockL + 600), Y(r.depth.S2 + 0) - 10, 'S2', C.s2, pick), balloon(X(blockL + 600), Y(0) - 22, 'S1', C.s1, pick));
|
||||
return `<svg viewBox="0 0 ${W.toFixed(0)} ${H.toFixed(0)}" width="100%" style="max-width:${Math.round(W * 1.2)}px" role="img" aria-label="받침블럭 배근 단면">${parts.join('')}</svg>`;
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
/**
|
||||
* 교대 저판 배근 삽도 (2026-11-04, 시안 ③ 확대기초 카드, 교대확대기초철근.md §10): 교대축 직각 단면 (폭 W), 교대축 단면 (길이 L),
|
||||
* 상면 평면. 순수 문자열 함수. 번호 풍선(data-pick)을 누르면 그 철근만 강조한다; 선택한 번호는 `pick`(철근표 번호 집합)으로, 선택만 보기는 `only`로 받는다.
|
||||
* 색은 역할 기준: 주철근 B1–B4 #0F5C66, 보조 B5–B8 #B4531F, 측면 B10 #2E7D32 점선, 전단 B9 #6B3FA0.
|
||||
*/
|
||||
import type { AbutFootingGeom, AbutFootingRebar } from '../core/abut/rebar/footing';
|
||||
|
||||
export const ABUT_FOOTING_COLORS = { main: '#0F5C66', dist: '#B4531F', side: '#2E7D32', shear: '#6B3FA0' } as const;
|
||||
export const INK = '#18212b';
|
||||
export const MUTED = '#55606e';
|
||||
export const CONC = '#eef1f4';
|
||||
export const WALL = '#d9dee4';
|
||||
|
||||
export const text = (x: number, y: number, s: string, anchor = 'middle', fill = INK, size = 11) =>
|
||||
`<text x="${x.toFixed(1)}" y="${y.toFixed(1)}" text-anchor="${anchor}" font-size="${size}" fill="${fill}" font-family="JetBrains Mono, monospace">${s}</text>`;
|
||||
|
||||
/** Selected bars (철근표 번호) and 선택만 보기. An empty selection shows every bar. */
|
||||
export type AbutRebarPick = { marks: ReadonlySet<string>; only: boolean };
|
||||
export const NO_PICK: AbutRebarPick = { marks: new Set(), only: false };
|
||||
|
||||
/** Opacity of one bar group: dimmed (or hidden with 선택만 보기) when other bars are selected. */
|
||||
export function opacity(pick: AbutRebarPick, marks: string[]): number {
|
||||
if (pick.marks.size === 0) return 1;
|
||||
return marks.some((m) => pick.marks.has(m)) ? 1 : pick.only ? 0 : 0.15;
|
||||
}
|
||||
|
||||
/** A clickable 번호 풍선. */
|
||||
export function balloon(x: number, y: number, mark: string, color: string, pick: AbutRebarPick): string {
|
||||
// The B10 풍선 stands for B10-1 and B10-2.
|
||||
const on = [...pick.marks].some((m) => m === mark || (mark === 'B10' && m.startsWith('B10')));
|
||||
const r = mark.length > 3 ? 15 : 12;
|
||||
return `<g data-pick="${mark}" style="cursor:pointer" role="button" tabindex="0" aria-label="${mark} 강조"><circle cx="${x.toFixed(1)}" cy="${y.toFixed(1)}" r="${r}" fill="${on ? color : '#fff'}" stroke="${color}" stroke-width="1.4"/>${text(x, y + 4, mark, 'middle', on ? '#fff' : color, 10)}</g>`;
|
||||
}
|
||||
|
||||
/** 교대축 직각 단면 (폭 W): 왼쪽 배면(뒷굽) → 오른쪽 전면(앞굽). B1–B4 선, B5–B8 점, B10-1 점, B9 연직선. */
|
||||
export function abutFootingSectionWSvg(g: AbutFootingGeom, r: AbutFootingRebar, pick: AbutRebarPick = NO_PICK): string {
|
||||
const k = 0.12;
|
||||
const x0 = 60;
|
||||
const y0 = 46;
|
||||
const X = (x: number) => x0 + x * k;
|
||||
const Y = (z: number) => y0 + (g.H - z) * k;
|
||||
const c = r.settings.cSide;
|
||||
const C = ABUT_FOOTING_COLORS;
|
||||
const parts: string[] = [];
|
||||
parts.push(`<rect x="${X(g.xb)}" y="${y0 - 26}" width="${(g.xf - g.xb) * k}" height="26" fill="${WALL}" stroke="${MUTED}" stroke-dasharray="4 3"/>`);
|
||||
parts.push(text((X(g.xb) + X(g.xf)) / 2, y0 - 10, `벽체 ${Math.round(g.xf - g.xb)} (상면 B5·B7 없음)`, 'middle', MUTED, 10));
|
||||
parts.push(`<rect x="${X(0)}" y="${Y(g.H)}" width="${g.W * k}" height="${g.H * k}" fill="${CONC}" stroke="${MUTED}"/>`);
|
||||
const line = (pts: [number, number][], color: string, marks: string[], w = 1.8, dash = '') =>
|
||||
`<polyline points="${pts.map(([x, z]) => `${X(x).toFixed(1)},${Y(z).toFixed(1)}`).join(' ')}" fill="none" stroke="${color}" stroke-width="${w}"${dash ? ` stroke-dasharray="${dash}"` : ''} opacity="${opacity(pick, marks)}"/>`;
|
||||
const { B13, B24, B57, B68 } = r.z;
|
||||
// B9 (behind the others): vertical lines at the row positions.
|
||||
for (const row of r.shear.rows) parts.push(line([[row.x, B24], [row.x, B13]], C.shear, ['B9'], 1.2));
|
||||
// B1 ⊓ (slightly inside so the legs show next to B2's), B2 ⊔, B3 to the 벽체 전면 − c, B4 straight.
|
||||
const lx = c + 6;
|
||||
parts.push(line([[lx, B24], [lx, B13], [g.W - lx, B13], [g.W - lx, B24]], C.main, ['B1']));
|
||||
parts.push(line([[c, B13], [c, B24], [g.W - c, B24], [g.W - c, B13]], C.main, ['B2']));
|
||||
parts.push(line([[c, B13 + 14], [g.xf - c, B13 + 14]], C.main, ['B3'], 1.6, '6 3'));
|
||||
parts.push(line([[c, B24 - 14], [g.W - c, B24 - 14]], C.main, ['B4'], 1.6, '6 3'));
|
||||
// B5–B8 dots.
|
||||
const dot = (x: number, z: number, color: string, mark: string) =>
|
||||
`<circle cx="${X(x).toFixed(1)}" cy="${Y(z).toFixed(1)}" r="2.6" fill="${color}" opacity="${opacity(pick, [mark])}"/>`;
|
||||
r.dist.positions.forEach((x, i) => {
|
||||
if (r.dist.topKept[i]) parts.push(dot(x + (i % 2 === 0 ? r.sideShift.B5 : 0), B57, C.dist, i % 2 === 0 ? 'B5' : 'B7'));
|
||||
parts.push(dot(x, B68, C.dist, i % 2 === 0 ? 'B6' : 'B8'));
|
||||
});
|
||||
// B10-1 dots on both faces.
|
||||
for (const z of r.side.levels) {
|
||||
const zz = z + 19.1;
|
||||
parts.push(dot(r.side.depth1, zz, C.side, 'B10-1'), dot(g.W - r.side.depth1, zz, C.side, 'B10-1'));
|
||||
}
|
||||
// 치수 문자열.
|
||||
parts.push(text(X(g.W / 2), Y(0) + 18, r.dist.bottomText + ' (하면)', 'middle', INK, 10));
|
||||
parts.push(text(X(g.W / 2), Y(g.H) - 32, r.dist.topText + ' (상면)', 'middle', INK, 10));
|
||||
parts.push(text(X(0) - 6, Y(g.H / 2), r.side.text, 'end', MUTED, 10));
|
||||
// 번호 풍선.
|
||||
const by = Y(g.H) - 2;
|
||||
parts.push(balloon(X(g.xb / 2), Y(B13) + 26, 'B1', C.main, pick), balloon(X(g.xb / 2) + 30, Y(B13) + 26, 'B3', C.main, pick));
|
||||
parts.push(balloon(X(g.xb / 2), Y(B24) - 26, 'B2', C.main, pick), balloon(X(g.xb / 2) + 30, Y(B24) - 26, 'B4', C.main, pick));
|
||||
parts.push(balloon(X(g.xf + (g.W - g.xf) / 2) - 15, Y(B57) + 30, 'B5', C.dist, pick), balloon(X(g.xf + (g.W - g.xf) / 2) + 15, Y(B57) + 30, 'B7', C.dist, pick));
|
||||
parts.push(balloon(X(g.xf + (g.W - g.xf) / 2) - 15, Y(B68) - 30, 'B6', C.dist, pick), balloon(X(g.xf + (g.W - g.xf) / 2) + 15, Y(B68) - 30, 'B8', C.dist, pick));
|
||||
if (r.shear.rows.length) parts.push(balloon(X(r.shear.rows[0].x) + 22, Y(g.H / 2), 'B9', C.shear, pick));
|
||||
parts.push(balloon(X(g.W) + 22, Y(r.side.levels[1] ?? g.H / 2), 'B10', C.side, pick));
|
||||
parts.push(text(X(0), by - 30, '← 배면 (뒷굽)', 'start', MUTED, 10), text(X(g.W), by - 30, '전면 (앞굽) →', 'end', MUTED, 10));
|
||||
const W = X(g.W) + 50;
|
||||
const H = Y(0) + 30;
|
||||
return `<svg viewBox="0 0 ${W.toFixed(0)} ${H.toFixed(0)}" width="100%" role="img" aria-label="교대축 직각 단면: 폭 ${g.W}, 두께 ${g.H}, 하면 ${r.dist.bottomText}">${parts.join('')}</svg>`;
|
||||
}
|
||||
|
||||
/** 교대축 단면 (길이 L, 가로 축척 0.4배): B5–B8 선(이음 위치), B1–B4 점, B10-2 점. 왼쪽 IP3 쪽. */
|
||||
export function abutFootingSectionLSvg(g: AbutFootingGeom, r: AbutFootingRebar, pick: AbutRebarPick = NO_PICK): string {
|
||||
const kx = 0.045;
|
||||
const kz = 0.12;
|
||||
const x0 = 50;
|
||||
const y0 = 30;
|
||||
const X = (s: number) => x0 + s * kx;
|
||||
const Y = (z: number) => y0 + (g.H - z) * kz;
|
||||
const C = ABUT_FOOTING_COLORS;
|
||||
const parts: string[] = [];
|
||||
parts.push(`<rect x="${X(0)}" y="${Y(g.H)}" width="${g.L * kx}" height="${g.H * kz}" fill="${CONC}" stroke="${MUTED}"/>`);
|
||||
const e = r.settings.cSide / g.angle.sin;
|
||||
const line = (pts: [number, number][], color: string, marks: string[], w = 1.8, dash = '') =>
|
||||
`<polyline points="${pts.map(([s, z]) => `${X(s).toFixed(1)},${Y(z).toFixed(1)}`).join(' ')}" fill="none" stroke="${color}" stroke-width="${w}"${dash ? ` stroke-dasharray="${dash}"` : ''} opacity="${opacity(pick, marks)}"/>`;
|
||||
const { B13, B24, B57, B68 } = r.z;
|
||||
parts.push(line([[e + 4, B68], [e + 4, B57], [g.L - e - 4, B57], [g.L - e - 4, B68]], C.dist, ['B5']));
|
||||
parts.push(line([[e, B57], [e, B68], [g.L - e, B68], [g.L - e, B57]], C.dist, ['B6']));
|
||||
parts.push(line([[e, B57 - 14], [g.L - e, B57 - 14]], C.dist, ['B7'], 1.4, '6 3'));
|
||||
parts.push(line([[e, B68 + 14], [g.L - e, B68 + 14]], C.dist, ['B8'], 1.4, '6 3'));
|
||||
// 이음 (B5 · B6): the two splice centres (± l_s around the middle) as ticks.
|
||||
const lap = r.bars.find((b) => b.mark === 'B5')?.splice?.lap;
|
||||
if (lap) {
|
||||
for (const sc of [g.L / 2 - lap, g.L / 2 + lap]) {
|
||||
parts.push(`<line x1="${X(sc)}" y1="${Y(B57) - 6}" x2="${X(sc)}" y2="${Y(B68) + 6}" stroke="${C.dist}" stroke-dasharray="2 2" opacity="${opacity(pick, ['B5', 'B6'])}"/>`);
|
||||
}
|
||||
parts.push(text(X(g.L / 2), Y(B57) - 10, `B5·B6 이음 l_s ${lap} (± l_s 엇갈림)`, 'middle', C.dist, 10));
|
||||
}
|
||||
const dot = (s: number, z: number, color: string, mark: string) =>
|
||||
`<circle cx="${X(s).toFixed(1)}" cy="${Y(z).toFixed(1)}" r="1.8" fill="${color}" opacity="${opacity(pick, [mark])}"/>`;
|
||||
r.main.positions.forEach((s, i) => {
|
||||
const one = i % 2 === 0;
|
||||
parts.push(dot(one ? s + r.sideShift.B1 : s, B13, C.main, one ? 'B1' : 'B3'));
|
||||
parts.push(dot(s, B24, C.main, one ? 'B2' : 'B4'));
|
||||
});
|
||||
const d2 = r.side.depth2 / g.angle.sin;
|
||||
for (const z of r.side.levels) parts.push(dot(d2, z, C.side, 'B10-2'), dot(g.L - d2, z, C.side, 'B10-2'));
|
||||
parts.push(text(X(g.L / 2), Y(0) + 18, r.main.text, 'middle', INK, 10));
|
||||
parts.push(balloon(X(g.L * 0.12), Y(B13) - 16, 'B1', C.main, pick), balloon(X(g.L * 0.12) + 28, Y(B13) - 16, 'B3', C.main, pick));
|
||||
parts.push(balloon(X(g.L * 0.88), Y(B57) + 30, 'B5', C.dist, pick), balloon(X(g.L * 0.88) + 28, Y(B57) + 30, 'B7', C.dist, pick));
|
||||
parts.push(balloon(X(g.L * 0.3), Y(B68) - 30, 'B6', C.dist, pick), balloon(X(g.L * 0.3) + 28, Y(B68) - 30, 'B8', C.dist, pick));
|
||||
parts.push(balloon(X(g.L * 0.12), Y(B24) + 20, 'B2', C.main, pick), balloon(X(g.L * 0.12) + 28, Y(B24) + 20, 'B4', C.main, pick));
|
||||
parts.push(balloon(X(g.L) + 22, Y(g.H / 2), 'B10', C.side, pick));
|
||||
const W = X(g.L) + 50;
|
||||
const H = Y(0) + 30;
|
||||
return `<svg viewBox="0 0 ${W.toFixed(0)} ${H.toFixed(0)}" width="100%" role="img" aria-label="교대축 단면: 길이 ${Math.round(g.L)}, ${r.main.text}">${parts.join('')}</svg>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 상면 평면: skew 평행사변형, 상면 B1·B3 (옵션에 따라 skew 선과 나란히 / 교대축 직각, 옵션 ② B1-S), B5·B7, B9, 벽체 밑 띠.
|
||||
* `skewK` = 날개벽 방향의 s / t 비 (장면의 abutSkewK): s(x) = s + skewK · x. 위 = 전면, 왼쪽 = IP3 끝 (s = L).
|
||||
*/
|
||||
export function abutFootingRebarPlanSvg(g: AbutFootingGeom, r: AbutFootingRebar, skewK: number, pick: AbutRebarPick = NO_PICK): string {
|
||||
const k = 0.045;
|
||||
const shiftMax = Math.abs(skewK) * g.W;
|
||||
const sMin = Math.min(0, skewK * g.W);
|
||||
const x0 = 30;
|
||||
const y0 = 24;
|
||||
const X = (s: number) => x0 + (g.L + shiftMax + sMin - s) * k; // s = L (IP3) on the left
|
||||
const Y = (x: number) => y0 + (g.W - x) * k; // 전면 (x = W) on top
|
||||
const P = (s: number, x: number) => `${X(s + skewK * x).toFixed(1)},${Y(x).toFixed(1)}`;
|
||||
// Option ② positions start after the end face that leans out (core: start = c + W·|cot θ|); with the faces leaning the
|
||||
// other way (skewK < 0) that side is at x = W, so the square bars sit W·|cot θ| further toward s = 0 in this frame.
|
||||
const SQ = (s: number) => (skewK >= 0 ? s : s - shiftMax);
|
||||
const C = ABUT_FOOTING_COLORS;
|
||||
const c = r.settings.cSide;
|
||||
const parts: string[] = [];
|
||||
parts.push(`<polygon points="${[P(0, 0), P(g.L, 0), P(g.L, g.W), P(0, g.W)].join(' ')}" fill="${CONC}" stroke="${MUTED}"/>`);
|
||||
parts.push(`<polygon points="${[P(0, g.xb), P(g.L, g.xb), P(g.L, g.xf), P(0, g.xf)].join(' ')}" fill="${WALL}" opacity="0.7"/>`);
|
||||
const seg = (a: string, b: string, color: string, mark: string, w = 1, dash = '') =>
|
||||
`<line x1="${a.split(',')[0]}" y1="${a.split(',')[1]}" x2="${b.split(',')[0]}" y2="${b.split(',')[1]}" stroke="${color}" stroke-width="${w}"${dash ? ` stroke-dasharray="${dash}"` : ''} opacity="${opacity(pick, [mark])}"/>`;
|
||||
// B5 · B7: along the axis at the kept top positions, cut square at the ends (끝 피복 c / sin θ).
|
||||
const e = c / g.angle.sin;
|
||||
r.dist.positions.forEach((x, i) => {
|
||||
if (!r.dist.topKept[i]) return;
|
||||
parts.push(seg(P(e, x), P(g.L - e, x), C.dist, i % 2 === 0 ? 'B5' : 'B7', 0.8));
|
||||
});
|
||||
// B1 · B3: option ① along the skew line from the end-face grid; option ② square to the axis.
|
||||
r.main.positions.forEach((s, i) => {
|
||||
const one = i % 2 === 0;
|
||||
const xEnd = one ? g.W - c : g.xf - c;
|
||||
if (r.dir === 'skew') parts.push(seg(P(s, c), P(s, xEnd), C.main, one ? 'B1' : 'B3', 0.8, one ? '' : '4 2'));
|
||||
else parts.push(seg(`${X(SQ(s)).toFixed(1)},${Y(c).toFixed(1)}`, `${X(SQ(s)).toFixed(1)},${Y(xEnd).toFixed(1)}`, C.main, one ? 'B1' : 'B3', 0.8, one ? '' : '4 2'));
|
||||
});
|
||||
if (r.skewEnd) {
|
||||
for (const off of r.skewEnd.offsets) {
|
||||
parts.push(seg(P(c + off, c), P(c + off, g.W - c), C.main, 'B1-S', 1.4));
|
||||
parts.push(seg(P(g.L - c - off, c), P(g.L - c - off, g.W - c), C.main, 'B1-S', 1.4));
|
||||
}
|
||||
}
|
||||
// B9 dots (rows).
|
||||
for (const row of r.shear.rows) {
|
||||
for (const s of row.s) {
|
||||
const at = r.dir === 'skew' ? P(s, row.x) : `${X(SQ(s)).toFixed(1)},${Y(row.x).toFixed(1)}`;
|
||||
const [cx, cy] = at.split(',');
|
||||
parts.push(`<circle cx="${cx}" cy="${cy}" r="1.2" fill="${C.shear}" opacity="${opacity(pick, ['B9'])}"/>`);
|
||||
}
|
||||
}
|
||||
parts.push(text(X(g.L / 2 + skewK * (g.xb + g.xf) / 2), Y((g.xb + g.xf) / 2) + 4, '벽체 밑 (상면 B5·B7 없음, B9 없음)', 'middle', MUTED, 10));
|
||||
parts.push(text(X(g.L / 2 + skewK * g.W), Y(g.W) - 6, '전면 (앞굽 끝)', 'middle', MUTED, 10));
|
||||
parts.push(text(X(g.L / 2), Y(0) + 14, '배면 (뒷굽 끝)', 'middle', MUTED, 10));
|
||||
parts.push(text(X(g.L) - 4, Y(g.W / 2), 'IP3', 'end', MUTED, 10), text(X(0) + 4, Y(g.W / 2), 'IP1', 'start', MUTED, 10));
|
||||
parts.push(balloon(X(g.L * 0.75 + skewK * g.W * 0.7), Y(g.W * 0.7), 'B1', C.main, pick), balloon(X(g.L * 0.75 + skewK * g.W * 0.7) + 28, Y(g.W * 0.7), 'B3', C.main, pick));
|
||||
parts.push(balloon(X(g.L * 0.4 + skewK * g.W * 0.9), Y(g.W * 0.92), 'B5', C.dist, pick), balloon(X(g.L * 0.4 + skewK * g.W * 0.9) + 28, Y(g.W * 0.92), 'B7', C.dist, pick));
|
||||
parts.push(balloon(X(g.L * 0.25 + skewK * g.W * 0.1), Y(g.W * 0.1), 'B9', C.shear, pick));
|
||||
if (r.skewEnd) parts.push(balloon(X(c + skewK * g.W * 0.5) - 26, Y(g.W * 0.5), 'B1-S', C.main, pick));
|
||||
const W = x0 * 2 + (g.L + shiftMax) * k;
|
||||
const H = Y(0) + 26;
|
||||
return `<svg viewBox="0 0 ${W.toFixed(0)} ${H.toFixed(0)}" width="100%" role="img" aria-label="상면 평면: 배근 방향 ${r.dir === 'skew' ? '① skew' : '② 교대축 직각'}">${parts.join('')}</svg>`;
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* 교대 확대기초 · 말뚝 평면 삽도 (2026-11-04, 시안 ② 확대기초·말뚝 카드). 순수 문자열 함수.
|
||||
* 생성본 값으로 그린다: 저판(skew 평행사변형, 굵은 선), 벽체 윤곽(실선), 교대축(점선), 말뚝 3열(원), 열 위치, 폭 치수.
|
||||
* 가로(s)와 세로(t) 축척이 같다. 위가 전면, 아래가 배면이다.
|
||||
*/
|
||||
|
||||
export interface AbutFootingDrawing {
|
||||
/** 교대축 길이 L (m). */
|
||||
lengthM: number;
|
||||
/** s per m of t along the wing (skew): s = s_axis + k·t. */
|
||||
skewK: number;
|
||||
/** t (m): 저판 앞굽 끝, 뒷굽 끝, 벽체 전면, 벽체 배면. */
|
||||
footingFrontM: number;
|
||||
footingBackM: number;
|
||||
wallFrontM: number;
|
||||
wallBackM: number;
|
||||
/** 말뚝 열: t (m), s (m, 교대축 기준, skew 전), 열 이름 (1열 · 전면 …). */
|
||||
rows: { t: number; s: number[]; name: string }[];
|
||||
pileDiameterM: number;
|
||||
/** 열 위치 치수 (mm, 배면부터): 배면 연단, 간격, 간격, 전면 연단. */
|
||||
rowDimsMm: [number, number, number, number];
|
||||
footingWidthMm: number;
|
||||
}
|
||||
|
||||
const INK = '#18212b';
|
||||
const MUTED = '#55606e';
|
||||
const label = (x: number, y: number, s: string, anchor = 'start', fill = INK) =>
|
||||
`<text x="${x.toFixed(1)}" y="${y.toFixed(1)}" text-anchor="${anchor}" font-size="11" fill="${fill}" font-family="JetBrains Mono, monospace">${s}</text>`;
|
||||
|
||||
export function abutFootingPlanSvg(d: AbutFootingDrawing): string {
|
||||
const k = 36; // px per m, both directions
|
||||
const sMin = Math.min(d.skewK * d.footingFrontM, d.skewK * d.footingBackM) - 0.4;
|
||||
const sMax = d.lengthM + Math.max(d.skewK * d.footingFrontM, d.skewK * d.footingBackM) + 0.4;
|
||||
const tTop = d.footingFrontM + 0.4;
|
||||
const X = (s: number, t: number) => 20 + (s + d.skewK * t - sMin) * k;
|
||||
const Y = (t: number) => 14 + (tTop - t) * k;
|
||||
const W = 20 + (sMax - sMin) * k + 170;
|
||||
const H = Y(d.footingBackM - 0.4) + 34;
|
||||
const quad = (t0: number, t1: number, style: string) =>
|
||||
`<polygon points="${[[0, t0], [d.lengthM, t0], [d.lengthM, t1], [0, t1]].map(([s, t]) => `${X(s, t).toFixed(1)},${Y(t).toFixed(1)}`).join(' ')}" ${style}/>`;
|
||||
const parts: string[] = [];
|
||||
parts.push(quad(d.footingBackM, d.footingFrontM, `fill="#e6ebf0" stroke="${INK}" stroke-width="2"`));
|
||||
parts.push(quad(d.wallBackM, d.wallFrontM, `fill="none" stroke="${MUTED}"`));
|
||||
parts.push(`<line x1="${X(0, 0).toFixed(1)}" y1="${Y(0).toFixed(1)}" x2="${X(d.lengthM, 0).toFixed(1)}" y2="${Y(0).toFixed(1)}" stroke="#c0392b" stroke-dasharray="6 3"/>`);
|
||||
parts.push(label(X(d.lengthM, 0) + 6, Y(0) + 4, '교대축', 'start', '#c0392b'));
|
||||
const r = Math.max(2, (d.pileDiameterM / 2) * k);
|
||||
for (const row of d.rows) {
|
||||
for (const s of row.s) parts.push(`<circle cx="${X(s, row.t).toFixed(1)}" cy="${Y(row.t).toFixed(1)}" r="${r.toFixed(1)}" fill="#ffffff" stroke="${INK}"/>`);
|
||||
const tLabel = `${row.t >= 0 ? '+' : '−'}${Math.round(Math.abs(row.t) * 1000)}`;
|
||||
parts.push(label(X(d.lengthM, row.t) + r + 8, Y(row.t) + 4, `${row.name} · t ${tLabel} · ${row.s.length}본`));
|
||||
}
|
||||
const [e1, g1, g2, e2] = d.rowDimsMm;
|
||||
parts.push(label(20, H - 10, `${d.footingWidthMm} = 뒷굽쪽 ${e1} · ${g1} · ${g2} · ${e2} 전면 · 위 = 전면`, 'start', MUTED));
|
||||
return [
|
||||
`<svg viewBox="0 0 ${W.toFixed(0)} ${H.toFixed(0)}" width="100%" role="img" aria-label="확대기초 평면: skew 평행사변형, 폭 ${d.footingWidthMm}, 말뚝 ${d.rows.map((r2) => r2.s.length).join(' + ')}본">`,
|
||||
...parts,
|
||||
'</svg>',
|
||||
].join('');
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* 교대 흉벽 · 브라켓 배근 삽도 (2026-11-04, 시안 ③ 흉벽·브라켓 카드, 교대흉벽철근.md §10): 교대축 직각 단면 (대표 단면),
|
||||
* 교대축 단면 (전면, 상단 경사), P10 다웰 평면. 순수 문자열 함수, 철근은 배근 규칙의 경로(parapet.ts paths)에서 그린다.
|
||||
* 색: 연직 #0F5C66, 수평 #B4531F, 간격재 #6B3FA0, 측면 · 다웰 #2E7D32.
|
||||
*/
|
||||
import type { AbutParapetGeom, AbutParapetRebar, ParapetBarPath, ParapetMark } from '../core/abut/rebar/parapet';
|
||||
import { balloon, CONC, MUTED, NO_PICK, opacity, text, WALL, type AbutRebarPick } from './abut-footing-rebar-svg';
|
||||
|
||||
export const ABUT_PARAPET_COLORS = { vertical: '#0F5C66', horizontal: '#B4531F', tie: '#6B3FA0', side: '#2E7D32' } as const;
|
||||
const C = ABUT_PARAPET_COLORS;
|
||||
const COLOR: Record<ParapetMark, string> = {
|
||||
P1: C.vertical, P2: C.vertical, P3: C.vertical, P11: C.vertical,
|
||||
P4: C.horizontal, P5: C.horizontal,
|
||||
P6: C.tie, P7: C.tie,
|
||||
P8: C.side, P9: C.side, P10: C.side,
|
||||
};
|
||||
const DASH: Partial<Record<ParapetMark, string>> = { P6: '3 3', P7: '3 3', P8: '2 2', P9: '2 2' };
|
||||
|
||||
const topAt = (g: AbutParapetGeom, u: number) => g.top[0] + ((g.top[1] - g.top[0]) * u) / g.wall.L;
|
||||
const seatAt = (g: AbutParapetGeom, u: number) => {
|
||||
const sg = g.wall.segments.find((s) => u < s.to) ?? g.wall.segments[g.wall.segments.length - 1];
|
||||
return g.wall.HF + sg.H;
|
||||
};
|
||||
const nearest = (paths: ParapetBarPath[], mark: ParapetMark, u: number, at = 1) =>
|
||||
paths.filter((p) => p.mark === mark).reduce<ParapetBarPath | undefined>((a, b) => (!a || Math.abs(b.pts[at][0] - u) < Math.abs(a.pts[at][0] - u) ? b : a), undefined);
|
||||
|
||||
/** 교대축 직각 단면 at the 대표 단면 (받침 ① 위치): t 가로 (왼쪽 브라켓 배면 → 오른쪽 흉벽 전면), z 세로. */
|
||||
export function abutParapetSectionSvg(g: AbutParapetGeom, r: AbutParapetRebar, pick: AbutRebarPick = NO_PICK): string {
|
||||
const u = r.uRef;
|
||||
const seat = seatAt(g, u);
|
||||
const top = topAt(g, u);
|
||||
const br = top - g.bracketH;
|
||||
const zMin = seat - r.ld.H16 - 150;
|
||||
const k = 0.16;
|
||||
const tL = g.wall.xb - 150;
|
||||
const tR = 450;
|
||||
const X = (t: number) => 60 + (t - tL) * k;
|
||||
const Y = (z: number) => 40 + (top + 120 - z) * k;
|
||||
const parts: string[] = [];
|
||||
const xb = g.wall.xb;
|
||||
// 벽체 (교좌면 아래) · 교좌면 · 브라켓 · 흉벽.
|
||||
parts.push(`<rect x="${X(xb)}" y="${Y(seat)}" width="${(tR - xb) * k}" height="${(seat - zMin) * k}" fill="${WALL}" stroke="${MUTED}" stroke-dasharray="4 3"/>`);
|
||||
parts.push(`<path d="M${X(xb)},${Y(seat)} L${X(xb)},${Y(br)} L${X(g.parapetBack)},${Y(br)} L${X(g.parapetBack)},${Y(top)} L${X(0)},${Y(top)} L${X(0)},${Y(seat)} Z" fill="${CONC}" stroke="${MUTED}"/>`);
|
||||
parts.push(text(X(tR) - 4, Y(seat) - 6, '교좌면', 'end', MUTED, 10), text((X(xb) + X(g.parapetBack)) / 2, Y(br) - 6, '브라켓 상면', 'middle', MUTED, 10));
|
||||
const line = (p: ParapetBarPath, w = 1.6) =>
|
||||
`<polyline points="${p.pts.map(([, t, z]) => `${X(t).toFixed(1)},${Y(z).toFixed(1)}`).join(' ')}" fill="none" stroke="${COLOR[p.mark]}" stroke-width="${w}"${DASH[p.mark] ? ` stroke-dasharray="${DASH[p.mark]}"` : ''} opacity="${opacity(pick, [p.mark])}"/>`;
|
||||
// 간격재: every level at the 교대축 position nearest the section.
|
||||
for (const mark of ['P6', 'P7'] as const) {
|
||||
const ref = nearest(r.paths, mark, u);
|
||||
// That column and its staggered levels (반 칸 뒤), not the previous column's.
|
||||
if (ref) for (const p of r.paths.filter((q) => q.mark === mark && q.pts[1][0] - ref.pts[1][0] > -1 && q.pts[1][0] - ref.pts[1][0] < 600)) parts.push(line(p, 1.1));
|
||||
}
|
||||
for (const mark of ['P3', 'P2', 'P1', 'P10'] as const) {
|
||||
const p = nearest(r.paths, mark, u, mark === 'P10' ? 0 : 1);
|
||||
if (p) parts.push(line(p, mark === 'P10' ? 3 : 1.8));
|
||||
}
|
||||
// 수평 P4 · P5: dots where they pass the section.
|
||||
for (const p of r.paths.filter((q) => (q.mark === 'P4' || q.mark === 'P5') && q.pts[0][0] <= u && q.pts[1][0] >= u)) {
|
||||
const f = (u - p.pts[0][0]) / (p.pts[1][0] - p.pts[0][0]);
|
||||
const z = p.pts[0][2] + f * (p.pts[1][2] - p.pts[0][2]);
|
||||
parts.push(`<circle cx="${X(p.pts[0][1]).toFixed(1)}" cy="${Y(z).toFixed(1)}" r="2.4" fill="${COLOR[p.mark]}" opacity="${opacity(pick, [p.mark])}"/>`);
|
||||
}
|
||||
// 치수: 흉벽 두께 (c · 사이 · c), 단 수.
|
||||
const c = r.settings.c;
|
||||
parts.push(text((X(g.parapetBack) + X(0)) / 2, Y(top) - 8, `${c} · ${Math.round(-g.parapetBack - 2 * c)} · ${c}`, 'middle', MUTED, 10));
|
||||
parts.push(text((X(xb) + X(g.parapetBack)) / 2, Y(br) + 14, `브라켓 ${Math.round(g.parapetBack - xb)}`, 'middle', MUTED, 10));
|
||||
parts.push(text(X(tR) - 4, Y((top + seat) / 2), `P4 ${r.levels.P4}단 · P5 ${r.levels.P5up} + ${r.levels.P5low}단`, 'end', MUTED, 10));
|
||||
// 번호 풍선.
|
||||
const ym = Y((top + seat) / 2);
|
||||
parts.push(balloon(X(0) + 30, Y(top) + 30, 'P1', C.vertical, pick), balloon(X(0) + 30, ym + 30, 'P4', C.horizontal, pick));
|
||||
parts.push(balloon(X(g.parapetBack) + 30, Y(top) - 26, 'P2', C.vertical, pick), balloon(X(xb) - 26, Y(br) + 40, 'P3', C.vertical, pick), balloon(X(xb) - 26, Y(br) + 80, 'P5', C.horizontal, pick));
|
||||
parts.push(balloon((X(g.parapetBack) + X(0)) / 2, Y(br) - 40, 'P7', C.tie, pick), balloon((X(xb) + X(0)) / 2, Y(seat) - 30, 'P6', C.tie, pick), balloon(X(r.x.P10) - 26, Y(br) - 30, 'P10', C.side, pick));
|
||||
const W = X(tR) + 20;
|
||||
const H = Y(zMin) + 16;
|
||||
return `<svg viewBox="0 0 ${W.toFixed(0)} ${H.toFixed(0)}" width="100%" style="max-width:${Math.round(W * 1.4)}px" role="img" aria-label="흉벽 · 브라켓 교대축 직각 단면 (대표 단면)">${parts.join('')}</svg>`;
|
||||
}
|
||||
|
||||
/** 교대축 단면 (전면): u 가로 (왼쪽 IP3 → 오른쪽 IP1), z 세로. 상단 경사와 나란한 P4, P1, 끝면 P11 · P9 · P8. */
|
||||
export function abutParapetElevationSvg(g: AbutParapetGeom, r: AbutParapetRebar, pick: AbutRebarPick = NO_PICK): string {
|
||||
const L = g.wall.L;
|
||||
const k = 0.045;
|
||||
const kz = 0.12;
|
||||
const zMax = Math.max(g.top[0], g.top[1]) + 100;
|
||||
const zMin = Math.min(...g.wall.segments.map((s) => g.wall.HF + s.H)) - r.ld.H16 - 100;
|
||||
const X = (u: number) => 60 + (L - u) * k;
|
||||
const Y = (z: number) => 50 + (zMax - z) * kz;
|
||||
const parts: string[] = [];
|
||||
let outline = `M${X(0).toFixed(1)},${Y(g.top[0]).toFixed(1)} L${X(L).toFixed(1)},${Y(g.top[1]).toFixed(1)}`;
|
||||
for (const s of [...g.wall.segments].reverse()) outline += ` L${X(s.to).toFixed(1)},${Y(g.wall.HF + s.H).toFixed(1)} L${X(s.from).toFixed(1)},${Y(g.wall.HF + s.H).toFixed(1)}`;
|
||||
parts.push(`<path d="${outline} Z" fill="${CONC}" stroke="${MUTED}"/>`);
|
||||
parts.push(`<line x1="${X(0)}" y1="${Y(g.top[0] - g.bracketH)}" x2="${X(L)}" y2="${Y(g.top[1] - g.bracketH)}" stroke="${MUTED}" stroke-dasharray="5 4"/>`);
|
||||
parts.push(text(X(L / 2), Y(g.top[0] - g.bracketH + ((g.top[1] - g.top[0]) / 2)) - 4, '브라켓 상면 (배면)', 'middle', MUTED, 10));
|
||||
const seg = (mark: ParapetMark, a: [number, number], b: [number, number], w: number) =>
|
||||
`<line x1="${X(a[0]).toFixed(1)}" y1="${Y(a[1]).toFixed(1)}" x2="${X(b[0]).toFixed(1)}" y2="${Y(b[1]).toFixed(1)}" stroke="${COLOR[mark]}" stroke-width="${w}"${DASH[mark] ? ` stroke-dasharray="${DASH[mark]}"` : ''} opacity="${opacity(pick, [mark])}"/>`;
|
||||
for (const p of r.paths) {
|
||||
const a = p.pts[0];
|
||||
const b = p.pts[p.pts.length - 1];
|
||||
if (p.mark === 'P4') parts.push(seg('P4', [a[0], a[2]], [b[0], b[2]], 0.8));
|
||||
else if (p.mark === 'P1') parts.push(seg('P1', [a[0], a[2]], [b[0], b[2]], 0.6));
|
||||
else if (p.mark === 'P11' && Math.abs(a[1] - r.x.P4) < 1e-6) parts.push(seg('P11', [a[0], a[2]], [b[0], b[2]], 2));
|
||||
else if (p.mark === 'P8' || p.mark === 'P9') parts.push(`<circle cx="${X(p.pts[1][0]).toFixed(1)}" cy="${Y(a[2]).toFixed(1)}" r="1.8" fill="${COLOR[p.mark]}" opacity="${opacity(pick, [p.mark])}"/>`);
|
||||
}
|
||||
parts.push(text(X(L), Y(zMin) + 14, 'IP3 (구간 1)', 'start', MUTED, 10), text(X(0), Y(zMin) + 14, 'IP1', 'end', MUTED, 10));
|
||||
parts.push(balloon(X(L * 0.7), 22, 'P4', C.horizontal, pick), balloon(X(L * 0.4), 22, 'P1', C.vertical, pick));
|
||||
parts.push(balloon(X(0) + 30, Y(g.top[0] - 300), 'P9', C.side, pick), balloon(X(0) + 30, Y(g.top[0] - 1300), 'P8', C.side, pick), balloon(X(0) + 30, Y(g.top[0] - 2300), 'P11', C.vertical, pick));
|
||||
const W = X(0) + 60;
|
||||
const H = Y(zMin) + 24;
|
||||
return `<svg viewBox="0 0 ${W.toFixed(0)} ${H.toFixed(0)}" width="100%" style="max-width:${Math.round(W * 1.2)}px" role="img" aria-label="흉벽 교대축 단면 (전면)">${parts.join('')}</svg>`;
|
||||
}
|
||||
|
||||
/** P10 다웰 배치 평면: u 가로 (왼쪽 IP3 → 오른쪽 IP1), 다웰 n개, 슬래브 측면선, P3 수평 다리를 비켜 옮긴 다웰. */
|
||||
export function abutParapetDowelSvg(g: AbutParapetGeom, r: AbutParapetRebar, pick: AbutRebarPick = NO_PICK): string {
|
||||
const L = g.wall.L;
|
||||
const k = 0.07;
|
||||
const X = (u: number) => 40 + (L - u) * k;
|
||||
const y0 = 70;
|
||||
const parts: string[] = [];
|
||||
parts.push(`<rect x="${X(L)}" y="${y0 - 12}" width="${L * k}" height="24" fill="${CONC}" stroke="${MUTED}"/>`);
|
||||
parts.push(text(X(L / 2), y0 - 18, `브라켓 상면 · 다웰 선 x ${r.x.P10}`, 'middle', MUTED, 10));
|
||||
if (g.slab) {
|
||||
for (const u of [g.slab.a, g.slab.b]) parts.push(`<line x1="${X(u)}" y1="${y0 - 30}" x2="${X(u)}" y2="${y0 + 30}" stroke="${MUTED}" stroke-dasharray="4 3"/>`);
|
||||
parts.push(text(X((g.slab.a + g.slab.b) / 2), y0 + 44, `접속슬래브 폭 ${Math.round(g.slab.B)} · n = ${r.dowel.n} · 양끝 여유 c + e = ${Math.round((r.settings.c + r.dowel.e) * 10) / 10}${r.dowel.moved ? ` · ${r.dowel.moved}개 P3 비켜 옮김` : ''}`, 'middle', MUTED, 10));
|
||||
}
|
||||
for (const p of r.paths.filter((q) => q.mark === 'P3')) parts.push(`<line x1="${X(p.pts[0][0]).toFixed(1)}" y1="${y0 - 8}" x2="${X(p.pts[0][0]).toFixed(1)}" y2="${y0 + 8}" stroke="${C.vertical}" stroke-width="0.5" opacity="${opacity(pick, ['P3'])}"/>`);
|
||||
for (const u of r.dowel.positions) parts.push(`<circle cx="${X(u).toFixed(1)}" cy="${y0}" r="3" fill="${C.side}" opacity="${opacity(pick, ['P10'])}"/>`);
|
||||
parts.push(text(X(L), y0 + 26, 'IP3', 'start', MUTED, 10), text(X(0), y0 + 26, 'IP1', 'end', MUTED, 10));
|
||||
parts.push(balloon(X(L * 0.5), 20, 'P10', C.side, pick), balloon(X(L * 0.5) + 30, 20, 'P3', C.vertical, pick));
|
||||
const W = X(0) + 40;
|
||||
return `<svg viewBox="0 0 ${W.toFixed(0)} ${y0 + 60}" width="100%" style="max-width:${Math.round(W * 1.2)}px" role="img" aria-label="P10 다웰 배치 평면">${parts.join('')}</svg>`;
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* 교대 3D 철근 (③, 2026-11-04): 저판 B1–B10, 벽체 W1–W11, 흉벽 · 브라켓 P1–P11, 날개벽 WR1–WR20 (좌 · 우), 접속슬래브 R1–R8. Positions come from `abutFootingRebar` ·
|
||||
* `abutWallRebar` · `abutParapetRebar` (no new rules here; the 벽체 · 흉벽 bars are their paths). The 저판 shapes follow the
|
||||
* 배근 삽도 (abut-footing-rebar-svg.ts): B1 ⊓ / B2 ⊔ / B3 · B4 직선, B5 ⊓ / B6 ⊔ / B7 · B8 직선, B9 연직, B10 측면, B1-S · B2-S.
|
||||
* Hooks and the bend radii are not drawn. Each bar is named by its 철근표 number, so the table, the 삽도 and the model
|
||||
* share one selection. Local frame = the abutment group's (x = s along the axis, y = EL, z = t toward the front).
|
||||
*/
|
||||
import * as THREE from 'three';
|
||||
import type { AbutFootingGeom, AbutFootingRebar } from '../core/abut/rebar/footing';
|
||||
import { NOMINAL_DIAMETER } from '../core/rebar/common';
|
||||
import { memberOfBar } from '../core/rebar/color';
|
||||
import { ABUT_WALL_SIZES, type AbutWallRebar } from '../core/abut/rebar/wall';
|
||||
import { ABUT_PARAPET_SIZES, type AbutParapetRebar } from '../core/abut/rebar/parapet';
|
||||
import { ABUT_PARAPET_COLORS } from './abut-parapet-rebar-svg';
|
||||
import { ABUT_WING_SIZES, type AbutWingRebar } from '../core/abut/rebar/wing';
|
||||
import { ABUT_WING_COLORS } from './abut-wing-rebar-svg';
|
||||
import { ABUT_SLAB_SIZES, type AbutSlabRebar } from '../core/abut/rebar/slab';
|
||||
import { ABUT_BLOCK_SIZES, type AbutBlockRebar } from '../core/abut/rebar/block';
|
||||
import { ABUT_BLOCK_COLORS } from './abut-block-rebar-svg';
|
||||
import { ABUT_SLAB_COLORS } from './abut-slab-rebar-svg';
|
||||
import { ABUT_FOOTING_COLORS } from './abut-footing-rebar-svg';
|
||||
import { ABUT_WALL_COLORS } from './abut-wall-rebar-svg';
|
||||
import { rebarColor, rebarControls, type RebarScene } from './rebar3d';
|
||||
|
||||
type P3 = [number, number, number];
|
||||
|
||||
/**
|
||||
* @param skewK plan skew (s per m of t, `abutSkewK`), @param backM 저판 배면 t (m), @param bottomEl 기초 하단 EL (m),
|
||||
* @param wall 벽체 배근, @param parapet 흉벽 배근, @param wings 날개벽 배근 (측마다, 경로는 교대 s · t · z) — 없으면 그 부재는 그리지 않음.
|
||||
*/
|
||||
export function buildAbutRebar3D(
|
||||
g: AbutFootingGeom,
|
||||
r: AbutFootingRebar,
|
||||
skewK: number,
|
||||
backM: number,
|
||||
bottomEl: number,
|
||||
wall?: AbutWallRebar,
|
||||
parapet?: AbutParapetRebar,
|
||||
wings: AbutWingRebar[] = [],
|
||||
slab?: AbutSlabRebar,
|
||||
blocks?: AbutBlockRebar,
|
||||
): RebarScene {
|
||||
const group = new THREE.Group();
|
||||
group.name = 'abut-rebar';
|
||||
const byName = new Map<string, THREE.Mesh[]>();
|
||||
const C = Object.fromEntries(Object.entries(ABUT_FOOTING_COLORS).map(([k, v]) => [k, parseInt(v.slice(1), 16)])) as Record<keyof typeof ABUT_FOOTING_COLORS, number>;
|
||||
const sizeOf = new Map<string, keyof typeof NOMINAL_DIAMETER>([...r.bars.map((b) => [b.mark, b.size] as const), ...Object.entries(ABUT_WALL_SIZES), ...Object.entries(ABUT_PARAPET_SIZES), ...Object.entries(ABUT_WING_SIZES), ...Object.entries(ABUT_SLAB_SIZES), ...Object.entries(ABUT_BLOCK_SIZES)]);
|
||||
// (s, x, z) in mm: s along the skew grid (끝면과 나란히), x from the 배면, z from the 저판 하면.
|
||||
const P = (s: number, x: number, z: number): P3 => [s / 1000 + skewK * (backM + x / 1000), bottomEl + z / 1000, backM + x / 1000];
|
||||
// Option ②: square to the axis, s in the 삽도's frame (abutFootingRebarPlanSvg SQ), the same for every x.
|
||||
const shiftMax = Math.abs(skewK) * g.W;
|
||||
const Q = (s: number, x: number, z: number): P3 => {
|
||||
const u = (skewK >= 0 ? s : s - shiftMax) / 1000 + skewK * backM;
|
||||
return [u, bottomEl + z / 1000, backM + x / 1000];
|
||||
};
|
||||
const main = r.dir === 'skew' ? P : Q;
|
||||
|
||||
const add = (name: string, pts: P3[], color: number) => {
|
||||
const size = sizeOf.get(name);
|
||||
if (!size) return;
|
||||
const dMm = NOMINAL_DIAMETER[size];
|
||||
for (let i = 1; i < pts.length; i++) {
|
||||
const a = new THREE.Vector3(...pts[i - 1]);
|
||||
const b = new THREE.Vector3(...pts[i]);
|
||||
const dir = b.clone().sub(a);
|
||||
const len = dir.length();
|
||||
if (len < 1e-6) continue;
|
||||
const geo = new THREE.CylinderGeometry(dMm / 2000, dMm / 2000, len, 6);
|
||||
geo.applyQuaternion(new THREE.Quaternion().setFromUnitVectors(new THREE.Vector3(0, 1, 0), dir.normalize()));
|
||||
geo.translate((a.x + b.x) / 2, (a.y + b.y) / 2, (a.z + b.z) / 2);
|
||||
const mesh = new THREE.Mesh(geo, new THREE.MeshStandardMaterial({ color: rebarColor(color), roughness: 0.45, metalness: 0.1 }));
|
||||
mesh.userData = { name, roleColor: color, dMm, member: memberOfBar(name) };
|
||||
group.add(mesh);
|
||||
const list = byName.get(name) ?? [];
|
||||
list.push(mesh);
|
||||
byName.set(name, list);
|
||||
}
|
||||
};
|
||||
|
||||
const c = r.settings.cSide;
|
||||
const { B13, B24, B57, B68 } = r.z;
|
||||
// B1–B4: across the width. B1 sits beside B2 along the axis (옆붙임).
|
||||
r.main.positions.forEach((s, i) => {
|
||||
if (i % 2 === 0) {
|
||||
const s1 = s + r.sideShift.B1;
|
||||
add('B1', [main(s1, c, B24), main(s1, c, B13), main(s1, g.W - c, B13), main(s1, g.W - c, B24)], C.main);
|
||||
add('B2', [main(s, c, B13), main(s, c, B24), main(s, g.W - c, B24), main(s, g.W - c, B13)], C.main);
|
||||
} else {
|
||||
add('B3', [main(s, c, B13), main(s, g.xf - c, B13)], C.main);
|
||||
add('B4', [main(s, c, B24), main(s, g.W - c, B24)], C.main);
|
||||
}
|
||||
});
|
||||
for (const off of r.skewEnd?.offsets ?? []) {
|
||||
for (const s of [c + off, g.L - c - off]) {
|
||||
add('B1-S', [P(s, c, B13), P(s, g.W - c, B13)], C.main);
|
||||
add('B2-S', [P(s, c, B24), P(s, g.W - c, B24)], C.main);
|
||||
}
|
||||
}
|
||||
// B5–B8: along the axis, cut at the end cover (c / sin θ). Top bars only where the 삽도 keeps them.
|
||||
const e = c / g.angle.sin;
|
||||
r.dist.positions.forEach((x, i) => {
|
||||
const one = i % 2 === 0;
|
||||
if (r.dist.topKept[i]) {
|
||||
if (one) {
|
||||
const x5 = x + r.sideShift.B5;
|
||||
add('B5', [P(e, x5, B68), P(e, x5, B57), P(g.L - e, x5, B57), P(g.L - e, x5, B68)], C.dist);
|
||||
} else add('B7', [P(e, x, B57), P(g.L - e, x, B57)], C.dist);
|
||||
}
|
||||
if (one) add('B6', [P(e, x, B57), P(e, x, B68), P(g.L - e, x, B68), P(g.L - e, x, B57)], C.dist);
|
||||
else add('B8', [P(e, x, B68), P(g.L - e, x, B68)], C.dist);
|
||||
});
|
||||
// B9: vertical, B2 → B1 level.
|
||||
for (const row of r.shear.rows) for (const s of row.s) add('B9', [main(s, row.x, B24), main(s, row.x, B13)], C.shear);
|
||||
// B10-1 on the front and back faces (one bar above the level, as in the 단면), B10-2 on the two skew end faces.
|
||||
const { depth1, depth2, levels } = r.side;
|
||||
const e2 = depth2 / g.angle.sin;
|
||||
for (const z of levels) {
|
||||
const z1 = z + NOMINAL_DIAMETER[sizeOf.get('B10-1') ?? 'H19'];
|
||||
for (const x of [depth1, g.W - depth1]) add('B10-1', [P(e2, x, z1), P(g.L - e2, x, z1)], C.side);
|
||||
for (const s of [e2, g.L - e2]) add('B10-2', [P(s, depth1, z), P(s, g.W - depth1, z)], C.side);
|
||||
}
|
||||
// 벽체 · 흉벽: the rule paths in (u, t, z) mm — u on the skew grid, t from the axis, z from the 저판 하면.
|
||||
const toScene = ([u, t, z]: [number, number, number]): P3 => [u / 1000 + (skewK * t) / 1000, bottomEl + z / 1000, t / 1000];
|
||||
if (wall) {
|
||||
const W = Object.fromEntries(Object.entries(ABUT_WALL_COLORS).map(([k, v]) => [k, parseInt(v.slice(1), 16)])) as Record<keyof typeof ABUT_WALL_COLORS, number>;
|
||||
const colorOf = (mark: string) =>
|
||||
['W1', 'W3', 'W4', 'W11'].includes(mark) ? W.vertical : mark === 'W7' ? W.tie : mark === 'W9' || mark === 'W10' ? W.step : W.horizontal;
|
||||
for (const p of wall.paths) add(p.mark, p.pts.map(toScene), colorOf(p.mark));
|
||||
}
|
||||
if (parapet) {
|
||||
const P = Object.fromEntries(Object.entries(ABUT_PARAPET_COLORS).map(([k, v]) => [k, parseInt(v.slice(1), 16)])) as Record<keyof typeof ABUT_PARAPET_COLORS, number>;
|
||||
const colorOf = (mark: string) =>
|
||||
['P1', 'P2', 'P3', 'P11'].includes(mark) ? P.vertical : mark === 'P6' || mark === 'P7' ? P.tie : ['P8', 'P9', 'P10'].includes(mark) ? P.side : P.horizontal;
|
||||
for (const p of parapet.paths) add(p.mark, p.pts.map(toScene), colorOf(p.mark));
|
||||
}
|
||||
const G = Object.fromEntries(Object.entries(ABUT_WING_COLORS).map(([k, v]) => [k, parseInt(v.slice(1), 16)])) as Record<keyof typeof ABUT_WING_COLORS, number>;
|
||||
const wingColor = (mark: string) =>
|
||||
['WR1', 'WR2', 'WR11', 'WR12'].includes(mark) ? G.vertical : /^WR(3|4|5|13|14|15)$/.test(mark) ? G.horizontal : mark === 'WR6' || mark === 'WR20' ? G.other : G.haunch;
|
||||
for (const w of wings) for (const p of w.paths) add(p.mark, p.pts.map(([s, t, z]): P3 => [s / 1000, bottomEl + z / 1000, t / 1000]), wingColor(p.mark));
|
||||
if (slab) {
|
||||
const S = Object.fromEntries(Object.entries(ABUT_SLAB_COLORS).map(([k, v]) => [k, parseInt(v.slice(1), 16)])) as Record<keyof typeof ABUT_SLAB_COLORS, number>;
|
||||
const slabColor = (mark: string) => (mark === 'R1' || mark === 'R3' ? S.main : mark === 'R7' ? S.corner : mark === 'R8' ? S.shear : S.dist);
|
||||
for (const p of slab.paths) add(p.mark, p.pts.map(([s, t, z]): P3 => [s / 1000, bottomEl + z / 1000, t / 1000]), slabColor(p.mark));
|
||||
}
|
||||
if (blocks) {
|
||||
const B = Object.fromEntries(Object.entries(ABUT_BLOCK_COLORS).map(([k, v]) => [k, parseInt(v.slice(1), 16)])) as Record<'s1' | 's2', number>;
|
||||
for (const p of blocks.paths) add(p.mark, p.pts.map(([s, t, z]): P3 => [s / 1000, bottomEl + z / 1000, t / 1000]), p.mark === 'S1' ? B.s1 : B.s2);
|
||||
}
|
||||
return { group, byName, records: [], ...rebarControls(byName) };
|
||||
}
|
||||
@@ -0,0 +1,524 @@
|
||||
/**
|
||||
* 교대 3D 장면 (2026-11-03): 벽체, 흉벽, 저판, 말뚝, 날개벽, 헌치를 Three.js 그룹으로 만든다. 순수 함수 (렌더러 없음).
|
||||
*
|
||||
* 좌표 (장면 기준): x = 교대축 방향 s (IP1 → IP3), y = EL (위 +), z = 교대축 직각 t (전면 +).
|
||||
* 날개벽 평면(E, N)은 축 기준 (s, t)로 바꿔 쓴다: s = (P − IP1)·u, t = (P − IP1)·(전면 법선).
|
||||
* 흉벽·벽체 단면은 모식도다. 흉벽은 저판 상면에서 상단까지 그린다(교좌면 아래 세부는 배근 단계에서).
|
||||
*/
|
||||
import type { BearingSole } from '../core/sole-plate';
|
||||
import { jointPositions } from '../core/abut/joints';
|
||||
import * as THREE from 'three';
|
||||
import type { AbutModel, AbutPoint } from '../core/abut/geometry';
|
||||
import { buildMembers, type AbutMembers } from '../core/abut/members';
|
||||
import { buildWings, type WingSolid } from '../core/abut/wings';
|
||||
import { resolveSlab, slabGradePct, slabLevelAt, slabSection, type AbutSlab } from '../core/abut/slab';
|
||||
import type { TerrainGrid } from '../core/ground-model';
|
||||
import { terrainGeometry } from './terrain';
|
||||
|
||||
export const ABUT_GROUP_NAMES = ['wall', 'parapet', 'footing', 'blinding', 'piles', 'wings', 'haunches', 'ground', 'blocks', 'bearings', 'solplates', 'slab', 'joints'] as const;
|
||||
/** 말뚝 두부 매입 (m): the drawn pile head goes this far into the 저판 (drawing only). */
|
||||
const PILE_HEAD_EMBED_M = 0.1;
|
||||
|
||||
/** Keeps the part of a convex plan polygon with tLo ≤ t ≤ tHi (two half-plane cuts). */
|
||||
function clipT(poly: [number, number][], tHi: number, tLo: number): [number, number][] {
|
||||
const cut = (pts: [number, number][], keep: (t: number) => boolean, edge: number) => {
|
||||
const out: [number, number][] = [];
|
||||
for (let i = 0; i < pts.length; i++) {
|
||||
const a = pts[i];
|
||||
const b = pts[(i + 1) % pts.length];
|
||||
if (keep(a[1])) out.push(a);
|
||||
if (keep(a[1]) !== keep(b[1])) {
|
||||
const k = (edge - a[1]) / (b[1] - a[1]);
|
||||
out.push([a[0] + k * (b[0] - a[0]), edge]);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
};
|
||||
return cut(cut(poly, (t) => t <= tHi + 1e-9, tHi), (t) => t >= tLo - 1e-9, tLo);
|
||||
}
|
||||
|
||||
/**
|
||||
* 접속슬래브 solid (2026-11-04): the plan polygon (s, t), cut into strips at the section stations (t, descending). Inside a
|
||||
* strip the top and the bottom are flat, so each strip is fanned; the sides follow the plan edges, split at the stations.
|
||||
* Double-sided material: the winding is not critical.
|
||||
*/
|
||||
function slabGeometry(plan: [number, number][], stationsT: number[], level: (s: number, t: number) => { top: number; bottom: number }): THREE.BufferGeometry {
|
||||
const positions: number[] = [];
|
||||
const push = (s: number, y: number, t: number) => {
|
||||
positions.push(s, y, t);
|
||||
return positions.length / 3 - 1;
|
||||
};
|
||||
const indices: number[] = [];
|
||||
for (let i = 0; i + 1 < stationsT.length; i++) {
|
||||
const strip = clipT(plan, stationsT[i], stationsT[i + 1]);
|
||||
if (strip.length < 3) continue;
|
||||
for (const face of ['top', 'bottom'] as const) {
|
||||
const ids = strip.map(([s, t]) => push(s, level(s, t)[face], t));
|
||||
for (let j = 1; j + 1 < ids.length; j++) indices.push(ids[0], ids[j], ids[j + 1]);
|
||||
}
|
||||
}
|
||||
for (let i = 0; i < plan.length; i++) {
|
||||
const a = plan[i];
|
||||
const b = plan[(i + 1) % plan.length];
|
||||
const cuts = stationsT.filter((t) => (t - a[1]) * (t - b[1]) < 0).map((t) => (t - a[1]) / (b[1] - a[1]));
|
||||
const ks = [0, ...cuts.sort((x, y) => x - y), 1];
|
||||
for (let j = 0; j + 1 < ks.length; j++) {
|
||||
const p = [a[0] + ks[j] * (b[0] - a[0]), a[1] + ks[j] * (b[1] - a[1])];
|
||||
const q = [a[0] + ks[j + 1] * (b[0] - a[0]), a[1] + ks[j + 1] * (b[1] - a[1])];
|
||||
const lp = level(p[0], p[1]);
|
||||
const lq = level(q[0], q[1]);
|
||||
const v = [push(p[0], lp.bottom, p[1]), push(q[0], lq.bottom, q[1]), push(q[0], lq.top, q[1]), push(p[0], lp.top, p[1])];
|
||||
indices.push(v[0], v[1], v[2], v[0], v[2], v[3]);
|
||||
}
|
||||
}
|
||||
const geom = new THREE.BufferGeometry();
|
||||
geom.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3));
|
||||
geom.setIndex(indices);
|
||||
geom.computeVertexNormals();
|
||||
return geom;
|
||||
}
|
||||
/** 지반 pad margin (m) around the abutment plan, like the pier's GROUND_PAD. */
|
||||
export const ABUT_GROUND_MARGIN_M = 2;
|
||||
|
||||
/** The third axis point is IP3; the model keeps IP1 only, so it is taken from the axis length and direction. */
|
||||
function input3(model: AbutModel): AbutPoint {
|
||||
return { e: model.ip1.e + model.u.e * model.length, n: model.ip1.n + model.u.n * model.length };
|
||||
}
|
||||
|
||||
/** True when every point (with margin m) lies inside the terrain grid's E–N extent. */
|
||||
export function covers(grid: TerrainGrid, points: AbutPoint[], margin: number): boolean {
|
||||
const eMin = grid.origin[1];
|
||||
const eMax = grid.origin[1] + (grid.ny - 1) * grid.step;
|
||||
const nMin = grid.origin[0];
|
||||
const nMax = grid.origin[0] + (grid.nx - 1) * grid.step;
|
||||
return points.every((p) => p.e - margin >= eMin && p.e + margin <= eMax && p.n - margin >= nMin && p.n + margin <= nMax);
|
||||
}
|
||||
|
||||
/** Extrudes a closed profile given in its own (x, y) plane, placed by the basis vectors ax, ay and the extrusion az. */
|
||||
function prism(profile: [number, number][], depth: number, origin: THREE.Vector3, ax: THREE.Vector3, ay: THREE.Vector3, az: THREE.Vector3): THREE.BufferGeometry {
|
||||
const shape = new THREE.Shape(profile.map(([x, y]) => new THREE.Vector2(x, y)));
|
||||
const geom = new THREE.ExtrudeGeometry(shape, { depth, bevelEnabled: false, steps: 1 });
|
||||
geom.applyMatrix4(new THREE.Matrix4().makeBasis(ax, ay, az).setPosition(origin));
|
||||
return geom;
|
||||
}
|
||||
|
||||
/** Solplate wedge in the block frame: W along x, L along z, bottom at y0; thickness T1 at z = side·L/2, T2 at the other end. */
|
||||
function solplateWedge(L: number, W: number, T1: number, T2: number, side: 1 | -1, y0: number): THREE.BufferGeometry {
|
||||
const g = new THREE.BoxGeometry(W, 1, L);
|
||||
const pos = g.getAttribute('position') as THREE.BufferAttribute;
|
||||
for (let i = 0; i < pos.count; i++) {
|
||||
const z = pos.getZ(i), top = pos.getY(i) > 0;
|
||||
const thick = T1 + ((T2 - T1) * (L / 2 - side * z)) / L;
|
||||
pos.setY(i, y0 + (top ? thick : 0));
|
||||
}
|
||||
pos.needsUpdate = true;
|
||||
g.computeVertexNormals();
|
||||
return g;
|
||||
}
|
||||
|
||||
function material(color: number): THREE.MeshStandardMaterial {
|
||||
return new THREE.MeshStandardMaterial({ color, side: THREE.DoubleSide, roughness: 0.85 });
|
||||
}
|
||||
|
||||
/** Local (s, t) of a plan point, relative to the IP1 end of the axis. */
|
||||
function toAxis(model: AbutModel, p: AbutPoint): { s: number; t: number } {
|
||||
const d = { e: p.e - model.ip1.e, n: p.n - model.ip1.n };
|
||||
const front = { e: model.nLeft.e * model.frontSide, n: model.nLeft.n * model.frontSide };
|
||||
return { s: d.e * model.u.e + d.n * model.u.n, t: d.e * front.e + d.n * front.n };
|
||||
}
|
||||
|
||||
/** Keeps the part of a 2-D profile where g(x, y) ≥ 0 (Sutherland–Hodgman; g is linear, so the cut is a straight line). */
|
||||
function clipByLine(poly: [number, number][], g: (x: number, y: number) => number): [number, number][] {
|
||||
const out: [number, number][] = [];
|
||||
for (let i = 0; i < poly.length; i++) {
|
||||
const p = poly[i];
|
||||
const q = poly[(i + 1) % poly.length];
|
||||
const gp = g(p[0], p[1]);
|
||||
const gq = g(q[0], q[1]);
|
||||
if (gp >= 0) out.push(p);
|
||||
if (gp * gq < 0) {
|
||||
const f = gp / (gp - gq);
|
||||
out.push([p[0] + (q[0] - p[0]) * f, p[1] + (q[1] - p[1]) * f]);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Keeps the part of a 2-D profile with x ≥ x0 (Sutherland–Hodgman against one line). */
|
||||
function clipBelowX(poly: [number, number][], x0: number): [number, number][] {
|
||||
const out: [number, number][] = [];
|
||||
for (let i = 0; i < poly.length; i++) {
|
||||
const p = poly[i];
|
||||
const q = poly[(i + 1) % poly.length];
|
||||
if (p[0] >= x0) out.push(p);
|
||||
if ((p[0] - x0) * (q[0] - x0) < 0) {
|
||||
const y = p[1] + ((q[1] - p[1]) * (x0 - p[0])) / (q[0] - p[0]);
|
||||
out.push([x0, y]);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function wingDirectionAxis(model: AbutModel): { ws: number; wt: number } {
|
||||
const rad = ((model.wing?.lineDeg ?? 0) * Math.PI) / 180;
|
||||
const w = { e: Math.cos(rad), n: Math.sin(rad) };
|
||||
const front = { e: model.nLeft.e * model.frontSide, n: model.nLeft.n * model.frontSide };
|
||||
return { ws: w.e * model.u.e + w.n * model.u.n, wt: w.e * front.e + w.n * front.n };
|
||||
}
|
||||
|
||||
/**
|
||||
* Plan (s, t) outlines of the abutment: the 벽체 (skew), each wing, and the 접속슬래브 between the wings (spec §4.8).
|
||||
* The slab starts 흉벽과 간격 behind the 흉벽 back face, runs 길이 back, keeps 날개벽과 간격 from each wing's inner face, and
|
||||
* is chamfered at the 흉벽 corners by that wing's 헌치 (2026-11-04: 모따기 = 날개벽 헌치). No wing: the 교좌면 끝선, no chamfer.
|
||||
*/
|
||||
export interface AbutPlanOutline {
|
||||
wall: [number, number][];
|
||||
wings: { side: 'left' | 'right'; sEnd: number; poly: [number, number][] }[];
|
||||
slab: [number, number][];
|
||||
}
|
||||
|
||||
export function abutPlanOutline(model: AbutModel, members: AbutMembers, wings: WingSolid[], slab: AbutSlab = resolveSlab()): AbutPlanOutline {
|
||||
const L = model.length;
|
||||
const k = abutSkewK(model);
|
||||
const tb = members.shape.wallBackM;
|
||||
const tf = members.shape.wallFrontM;
|
||||
const near = members.shape.parapetBackM - slab.parapetGapMm / 1000;
|
||||
const far = near - slab.lengthMm / 1000;
|
||||
const sideK = (w: WingSolid) => {
|
||||
const p0 = toAxis(model, w.corners[0]);
|
||||
const p1 = toAxis(model, w.corners[1]);
|
||||
return (p1.s - p0.s) / (p1.t - p0.t);
|
||||
};
|
||||
const innerS = (sEnd: number, t: number): number => {
|
||||
const w = wings.find((x) => x.sEnd === sEnd);
|
||||
if (!w) return sEnd + k * t; // no wing on this side: the slab ends at the 교좌면 끝선 (skew)
|
||||
const a = w.sEnd === 0 ? 1 : -1;
|
||||
return sEnd + a * (w.thicknessM + slab.wingGapMm / 1000) + sideK(w) * t;
|
||||
};
|
||||
// Near corner of one side: [point on the side line, point on the near edge] (chamfer), or the corner alone.
|
||||
const nearCorner = (sEnd: number): [number, number][] => {
|
||||
const w = wings.find((x) => x.sEnd === sEnd);
|
||||
const corner: [number, number] = [innerS(sEnd, near), near];
|
||||
if (!w || !(w.haunchM > 0)) return [corner];
|
||||
const c = w.haunchM;
|
||||
const a = sEnd === 0 ? 1 : -1;
|
||||
const tSide = near - c / Math.hypot(1, sideK(w));
|
||||
return [[innerS(sEnd, tSide), tSide], [corner[0] + a * c, near]];
|
||||
};
|
||||
const axis = (c: AbutPoint): [number, number] => {
|
||||
const p = toAxis(model, c);
|
||||
return [p.s, p.t];
|
||||
};
|
||||
return {
|
||||
wall: [[k * tb, tb], [L + k * tb, tb], [L + k * tf, tf], [k * tf, tf]],
|
||||
wings: wings.map((w) => ({ side: w.side, sEnd: w.sEnd, poly: w.corners.map(axis) })),
|
||||
slab: [...nearCorner(0), ...nearCorner(L).reverse(), [innerS(L, far), far], [innerS(0, far), far]],
|
||||
};
|
||||
}
|
||||
|
||||
/** Skew of the plan (s per m of t along the wing), the same one the 저판 · 벽체 · 말뚝 열 use. */
|
||||
export function abutSkewK(model: AbutModel): number {
|
||||
const d = wingDirectionAxis(model);
|
||||
return d.ws / d.wt;
|
||||
}
|
||||
|
||||
/**
|
||||
* Placement of the abutment in the pier scene (2026-11-03). The pier scene maps (E, N, EL) to
|
||||
* (E − pivot.E, EL, −(N − pivot.N)); the abutment's local (s, t, EL) → (E, N) with s along u and t along the front
|
||||
* normal, then the same mapping. So the abutment and any pier in the same workbook share one frame.
|
||||
*/
|
||||
export function abutPlacement(model: AbutModel, pivot: { e: number; n: number }): THREE.Matrix4 {
|
||||
const f = { e: model.nLeft.e * model.frontSide, n: model.nLeft.n * model.frontSide };
|
||||
const xs = new THREE.Vector3(model.u.e, 0, -model.u.n);
|
||||
const y = new THREE.Vector3(0, 1, 0);
|
||||
const zt = new THREE.Vector3(f.e, 0, -f.n);
|
||||
const origin = new THREE.Vector3(model.ip1.e - pivot.e, 0, -(model.ip1.n - pivot.n));
|
||||
return new THREE.Matrix4().makeBasis(xs, y, zt).setPosition(origin);
|
||||
}
|
||||
|
||||
export function buildAbutGroup(
|
||||
model: AbutModel,
|
||||
members: AbutMembers = buildMembers(model),
|
||||
wings: WingSolid[] = buildWings(model, members),
|
||||
pivot?: { e: number; n: number },
|
||||
groundEl?: number,
|
||||
terrain?: TerrainGrid,
|
||||
slabInput?: Partial<AbutSlab>,
|
||||
/** Solplate of each bearing (abutSoles, 2026-11-06): the bearing body and the solplate wedge, and the block-top drop. */
|
||||
soles?: BearingSole[],
|
||||
/** 수축줄눈 위치 s (m, core/abut/joints.ts). Absent = the default count between the bearings. */
|
||||
jointS?: number[],
|
||||
): THREE.Group {
|
||||
const root = new THREE.Group();
|
||||
root.name = model.name;
|
||||
if (pivot) {
|
||||
// applyMatrix4 keeps auto-update on, so the world matrix is refreshed by the scene and by bounding-box calls.
|
||||
root.applyMatrix4(abutPlacement(model, pivot));
|
||||
}
|
||||
const L = model.length;
|
||||
const ex = new THREE.Vector3(1, 0, 0);
|
||||
const ey = new THREE.Vector3(0, 1, 0);
|
||||
const ez = new THREE.Vector3(0, 0, 1);
|
||||
const add = (name: (typeof ABUT_GROUP_NAMES)[number], geom: THREE.BufferGeometry, color: number) => {
|
||||
const mesh = new THREE.Mesh(geom, material(color));
|
||||
mesh.name = name;
|
||||
let group = root.getObjectByName(name) as THREE.Group | undefined;
|
||||
if (!group) {
|
||||
group = new THREE.Group();
|
||||
group.name = name;
|
||||
root.add(group);
|
||||
}
|
||||
group.add(mesh);
|
||||
};
|
||||
|
||||
const footingTop = members.footingTopEl;
|
||||
// Skew (2026-11-03): the 벽체·흉벽 plan follows the wing lines, s = s_axis + k·t, with k = ws/wt of the wing direction.
|
||||
const skewAxis = wingDirectionAxis(model);
|
||||
const skewK = skewAxis.ws / skewAxis.wt;
|
||||
// A prism whose profile (s, EL) is given at the face t0 and which is extruded along t with the skew (λ·(k, 0, 1)).
|
||||
const skewPrism = (profile: [number, number][], depth: number, t0: number) =>
|
||||
prism(
|
||||
profile.map(([s, y]) => [s + skewK * t0, y] as [number, number]),
|
||||
depth,
|
||||
new THREE.Vector3(0, 0, t0),
|
||||
ex,
|
||||
ey,
|
||||
new THREE.Vector3(skewK, 0, 1),
|
||||
);
|
||||
const footingBottom = members.footingBottomEl;
|
||||
|
||||
// 벽체 (spec §4.3, 삽도): the full 2400 block from the footing top up to the 교좌면 step (t −0.8 … 1.6).
|
||||
const parapetTopAt = (s: number) => members.parapetTopStartEl + ((members.parapetTopEndEl - members.parapetTopStartEl) * s) / L;
|
||||
const bracketAt = (s: number) => parapetTopAt(s) - members.shape.bracketHeightM;
|
||||
for (const seg of members.wallSegments) {
|
||||
const block: [number, number][] = [
|
||||
[seg.sFrom, footingTop],
|
||||
[seg.sTo, footingTop],
|
||||
[seg.sTo, seg.seatEl],
|
||||
[seg.sFrom, seg.seatEl],
|
||||
];
|
||||
add('wall', skewPrism(block, members.shape.wallFrontM - members.shape.wallBackM, members.shape.wallBackM), 0xb7c7d3);
|
||||
}
|
||||
|
||||
// 흉벽 (삽도): sits on the 벽체 top, above the 교좌면 step. The back strip (t −0.8 … −0.5) rises to the 브라켓 상면 (흉벽 상단 − 0.85),
|
||||
// where the 접속슬래브 sits; the 흉벽 (t −0.5 … 0) rises to the parapet top line. Both start at the 교좌면 step, so nothing overlaps the 벽체.
|
||||
for (const seg of members.wallSegments) {
|
||||
const backStrip: [number, number][] = [
|
||||
[seg.sFrom, seg.seatEl],
|
||||
[seg.sTo, seg.seatEl],
|
||||
[seg.sTo, bracketAt(seg.sTo)],
|
||||
[seg.sFrom, bracketAt(seg.sFrom)],
|
||||
];
|
||||
add('parapet', skewPrism(backStrip, members.shape.parapetBackM - members.shape.wallBackM, members.shape.wallBackM), 0x8898aa);
|
||||
const parapet: [number, number][] = [
|
||||
[seg.sFrom, seg.seatEl],
|
||||
[seg.sTo, seg.seatEl],
|
||||
[seg.sTo, parapetTopAt(seg.sTo)],
|
||||
[seg.sFrom, parapetTopAt(seg.sFrom)],
|
||||
];
|
||||
add('parapet', skewPrism(parapet, -members.shape.parapetBackM, members.shape.parapetBackM), 0x8898aa);
|
||||
}
|
||||
|
||||
// 수축줄눈 (spec_abut §4.9, 2026-11-06): lines only, on the 벽체 · 흉벽 faces (the 저판 is continuous). At each joint s:
|
||||
// 벽체 전면 (t = 교좌면 끝) 저판 상면 → 교좌면, 흉벽 전면 (t = 0) 교좌면 → 흉벽 상단, 배면 (t = 벽체 배면) 저판 상면 → 브라켓 상면,
|
||||
// 흉벽 배면 브라켓 상면 → 흉벽 상단. A joint between two steps takes the lower 교좌면. Lifted 3 mm off the faces.
|
||||
{
|
||||
const joints = jointS ?? jointPositions(L, model.bearings.map((b) => b.s));
|
||||
const seatAt = (s: number) => Math.min(...members.wallSegments.filter((g) => s >= g.sFrom - 1e-6 && s <= g.sTo + 1e-6).map((g) => g.seatEl));
|
||||
const pts: number[] = [];
|
||||
const line = (s: number, t: number, y0: number, y1: number) => { if (y1 > y0) pts.push(s + skewK * t, y0, t, s + skewK * t, y1, t); };
|
||||
const lift = 0.003, sh = members.shape;
|
||||
for (const s of joints) {
|
||||
const seat = seatAt(s);
|
||||
if (!Number.isFinite(seat)) continue;
|
||||
line(s, sh.wallFrontM + lift, footingTop, seat);
|
||||
line(s, 0 + lift, seat, parapetTopAt(s));
|
||||
line(s, sh.wallBackM - lift, footingTop, bracketAt(s));
|
||||
line(s, sh.parapetBackM - lift, bracketAt(s), parapetTopAt(s));
|
||||
}
|
||||
if (pts.length) {
|
||||
const g = new THREE.BufferGeometry();
|
||||
g.setAttribute('position', new THREE.Float32BufferAttribute(pts, 3));
|
||||
const lines = new THREE.LineSegments(g, new THREE.LineBasicMaterial({ color: 0x1b2733 }));
|
||||
lines.name = 'joints';
|
||||
const group = new THREE.Group();
|
||||
group.name = 'joints';
|
||||
group.add(lines);
|
||||
root.add(group);
|
||||
}
|
||||
}
|
||||
|
||||
if (footingBottom !== undefined) {
|
||||
// 저판: s 0 … L, t −1.8 … +2.8, from the 기초 하단 to the 저판 상면.
|
||||
// Skew (2026-11-03): the 저판 ends follow the wing outer lines (s = sEnd + (ws/wt)·t), so the plan is a parallelogram.
|
||||
const footProfile: [number, number][] = [
|
||||
[skewK * members.shape.footingBackM, members.shape.footingBackM],
|
||||
[L + skewK * members.shape.footingBackM, members.shape.footingBackM],
|
||||
[L + skewK * members.shape.footingFrontM, members.shape.footingFrontM],
|
||||
[skewK * members.shape.footingFrontM, members.shape.footingFrontM],
|
||||
];
|
||||
add('footing', prism(footProfile, members.shape.footingThicknessM, new THREE.Vector3(0, footingBottom, 0), ex, ez, ey), 0x5a6a78);
|
||||
// 버림 (2026-11-04): under the 저판, wider by the 여유 on every side, the same skew.
|
||||
const m = members.shape.blindingMarginM;
|
||||
const th = members.shape.blindingThicknessM;
|
||||
if (th > 0) {
|
||||
const tb = members.shape.footingBackM - m;
|
||||
const tf = members.shape.footingFrontM + m;
|
||||
const blindProfile: [number, number][] = [
|
||||
[skewK * tb - m, tb],
|
||||
[L + skewK * tb + m, tb],
|
||||
[L + skewK * tf + m, tf],
|
||||
[skewK * tf - m, tf],
|
||||
];
|
||||
add('blinding', prism(blindProfile, th, new THREE.Vector3(0, footingBottom - th, 0), ex, ez, ey), 0x9aa29a);
|
||||
}
|
||||
}
|
||||
|
||||
// 받침블럭 (spec §4.2, §6.6): concrete 1200 × 1200 from the 교좌면 to 50 below z, and the 몰탈패드 1000 × 1000 × 50 up to z.
|
||||
// The plan follows the 교대축 (받침 방위각 = 축 방향). t is the 전면 +, so the bearing's lateral offset is taken with the front side.
|
||||
// The block turns by the bearing azimuth (rotDeg, from the 방위각 input) about its own centre.
|
||||
// 교량받침 본체 · 솔플레이트 (2026-11-06, 교각과 같은 개념): the body is the solplate plan less 100 mm each side, height h3,
|
||||
// on the pad; the solplate tapers along the girder (L) with T1 on the 오르막 쪽. 거더 하면 고정: the block top and the pad
|
||||
// drop by T − T_ref (as the 받침 ELEVATION TABLE).
|
||||
const soleOf = new Map((soles ?? []).map((s) => [s.no, s]));
|
||||
for (const b of model.bearings) {
|
||||
const t = model.frontSide * b.lateral;
|
||||
const sole = soleOf.get(b.no);
|
||||
const padTop = b.z - (sole?.dropMm ?? 0) / 1000;
|
||||
const blockTop = padTop - 0.05;
|
||||
const turn = (b.rotDeg * Math.PI) / 180;
|
||||
const placed = (g: THREE.BufferGeometry) => g.rotateY(turn).translate(b.s, 0, t);
|
||||
add('blocks', placed(prism([[-0.6, b.seatEl], [0.6, b.seatEl], [0.6, blockTop], [-0.6, blockTop]], 1.2, new THREE.Vector3(0, 0, -0.6), ex, ey, ez)), 0xa9b4bf);
|
||||
add('blocks', placed(prism([[-0.5, blockTop], [0.5, blockTop], [0.5, padTop], [-0.5, padTop]], 1.0, new THREE.Vector3(0, 0, -0.5), ex, ey, ez)), 0x6e7a86);
|
||||
if (!sole) continue;
|
||||
const L = sole.L / 1000, W = sole.W / 1000, h3 = sole.h3 / 1000;
|
||||
// Local block frame after rotateY(turn): +z (the girder, L) is (sin turn)·u + (cos turn)·front in (E, N).
|
||||
const front = { e: model.nLeft.e * model.frontSide, n: model.nLeft.n * model.frontSide };
|
||||
const zDir = { e: Math.sin(turn) * model.u.e + Math.cos(turn) * front.e, n: Math.sin(turn) * model.u.n + Math.cos(turn) * front.n };
|
||||
const t1Side = sole.uphill && zDir.e * sole.uphill.e + zDir.n * sole.uphill.n > 0 ? 1 : -1;
|
||||
const body = new THREE.BoxGeometry(W - 0.2, h3, L - 0.2);
|
||||
body.translate(0, padTop + h3 / 2, 0);
|
||||
add('bearings', placed(body), 0x3d4a57);
|
||||
add('solplates', placed(solplateWedge(L, W, sole.T1 / 1000, sole.T2 / 1000, t1Side, padTop + h3)), 0xc08a3e);
|
||||
}
|
||||
|
||||
// 접속슬래브 (spec §4.8, 2026-11-04 매립형 / 노출형): the section (core/abut/slab.ts) is measured from the 흉벽 상단 at each s.
|
||||
// Plan: between the wing inner faces with the gaps and chamfers, from abutPlanOutline (shared with the ② 날개벽 plan).
|
||||
const slab = resolveSlab(slabInput);
|
||||
const slabPlan = abutPlanOutline(model, members, wings, slab).slab;
|
||||
const section = slabSection(slab, slabGradePct(slab, model.wing?.slopePct ?? 0), members.shape);
|
||||
const slabMesh = new THREE.Mesh(
|
||||
slabGeometry(
|
||||
slabPlan,
|
||||
section.map((p) => p.t),
|
||||
(s, t) => {
|
||||
const lv = slabLevelAt(section, t);
|
||||
return { top: parapetTopAt(s) + lv.top, bottom: parapetTopAt(s) + lv.bottom };
|
||||
},
|
||||
),
|
||||
new THREE.MeshStandardMaterial({ color: 0xd8c8a0, roughness: 0.9, side: THREE.DoubleSide }),
|
||||
);
|
||||
slabMesh.name = 'slab';
|
||||
const slabGroup = new THREE.Group();
|
||||
slabGroup.name = 'slab';
|
||||
slabGroup.add(slabMesh);
|
||||
root.add(slabGroup);
|
||||
|
||||
// 말뚝: 원기둥 (지름 입력, 기본 D600) at each row, from the 기초 하단 down to the 선단.
|
||||
if (members.pileBottomEl !== undefined && footingBottom !== undefined) {
|
||||
// The pile head runs PILE_HEAD_EMBED_M into the 저판, so its top cap is hidden inside the slab and does not z-fight
|
||||
// with the 저판 bottom (the striped discs seen from below, 2026-11-04). The 선단 EL is unchanged.
|
||||
const height = footingBottom + PILE_HEAD_EMBED_M - members.pileBottomEl;
|
||||
for (const row of members.piles) {
|
||||
for (const s of row.s) {
|
||||
// 말뚝 지름은 입력값 (QA-5). Each row moves along s with the skew, like the 저판 (시안: 열마다 skew만큼 밀림).
|
||||
const r = members.shape.pileDiameterM / 2;
|
||||
const geom = new THREE.CylinderGeometry(r, r, height, 16);
|
||||
geom.translate(s + skewK * row.t, members.pileBottomEl + height / 2, row.t);
|
||||
add('piles', geom, 0x7d8b99);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 날개벽: the side profile (d along the wing, EL) extruded across the 0.45 m thickness toward the inside of the axis.
|
||||
const dir = wingDirectionAxis(model);
|
||||
for (const wing of wings) {
|
||||
const sEnd = wing.sEnd;
|
||||
const a = sEnd === 0 ? 1 : -1;
|
||||
const profile: [number, number][] = [
|
||||
...wing.topLine.map((p) => [p.dM, p.el] as [number, number]),
|
||||
...[...wing.bottomLine].reverse().map((p) => [p.dM, p.el] as [number, number]),
|
||||
];
|
||||
const ax = new THREE.Vector3(dir.ws, 0, dir.wt);
|
||||
// 삽도 (A-A, 평면): above the 브라켓 상면 the wing meets the 흉벽 back face (t = −0.5); below it, the wing meets the 벽체 back face (t = −0.8).
|
||||
// The part in front of those faces lies inside the 벽체 or 흉벽, so it is cut off. The bracket falls along s, so the cut follows it.
|
||||
// The wing is 0.45 thick along s, so its footprint spans two s values at each dM; the cut uses the end where the 브라켓 is highest.
|
||||
const slopeS = (members.parapetTopEndEl - members.parapetTopStartEl) / L;
|
||||
const highEnd = slopeS < 0 ? Math.min(0, a * wing.thicknessM) : Math.max(0, a * wing.thicknessM);
|
||||
const bracketAtDm = (dM: number) => members.parapetTopStartEl + slopeS * (sEnd + dir.ws * dM + highEnd) - members.shape.bracketHeightM;
|
||||
const above = clipByLine(profile, (x, y) => y - bracketAtDm(x));
|
||||
const below = clipByLine(profile, (x, y) => bracketAtDm(x) - y);
|
||||
const upper = prism(clipBelowX(above, members.shape.parapetBackM / dir.wt), wing.thicknessM, new THREE.Vector3(sEnd, 0, 0), ax, ey, new THREE.Vector3(a, 0, 0));
|
||||
const lower = prism(clipBelowX(below, members.shape.wallBackM / dir.wt), wing.thicknessM, new THREE.Vector3(sEnd, 0, 0), ax, ey, new THREE.Vector3(a, 0, 0));
|
||||
add('wings', upper, 0xc9a77c);
|
||||
add('wings', lower, 0xc9a77c);
|
||||
// 저판 상면 헌치 (수평, 2026-11-03): a right triangle in the plane EL–s on the 저판 상면, next to the 날개벽 내측면,
|
||||
// extruded along the wing from the 벽체 배면 to the 뒷굽.
|
||||
if (wing.footingHaunch) {
|
||||
const f = wing.footingHaunch;
|
||||
const leg = wing.haunchM;
|
||||
const triangle: [number, number][] = [[0, 0], [leg, 0], [0, leg]];
|
||||
const origin = new THREE.Vector3(sEnd + a * wing.thicknessM + dir.ws * f.dMFrom, f.el, dir.wt * f.dMFrom);
|
||||
add('haunches', prism(triangle, f.dMTo - f.dMFrom, origin, new THREE.Vector3(a, 0, 0), ey, new THREE.Vector3(dir.ws, 0, dir.wt)), 0xc9a77c);
|
||||
}
|
||||
for (const h of wing.haunches) {
|
||||
const shape: [number, number][] = h.corners.map((c) => {
|
||||
const p = toAxis(model, c);
|
||||
return [p.s, p.t];
|
||||
});
|
||||
const zBottom = h.zBottomEl;
|
||||
const height = h.zTopEl - h.zBottomEl;
|
||||
if (height > 0) add('haunches', prism(shape, height, new THREE.Vector3(0, zBottom, 0), ex, ez, ey), 0xc9a77c);
|
||||
}
|
||||
}
|
||||
|
||||
// 지형: when the ground model's terrain grid covers the abutment footprint, the terrain is drawn (the same frame as the
|
||||
// piers); otherwise a flat pad at 지반고 covers the footing and the wings.
|
||||
const footprint = [model.ip1, input3(model), ...wings.flatMap((w) => w.corners)];
|
||||
const grid = terrain && pivot && covers(terrain, footprint, ABUT_GROUND_MARGIN_M) ? terrain : undefined;
|
||||
if (grid && pivot) {
|
||||
const mesh = new THREE.Mesh(terrainGeometry(grid, pivot), new THREE.MeshStandardMaterial({ color: 0x6b8f4e, roughness: 0.9, transparent: true, opacity: 0.35, depthWrite: false, side: THREE.DoubleSide }));
|
||||
const group = new THREE.Group();
|
||||
group.name = 'ground';
|
||||
group.add(mesh);
|
||||
root.add(group);
|
||||
} else if (groundEl !== undefined) {
|
||||
const ts = [members.shape.footingBackM, members.shape.footingFrontM, ...wings.flatMap((w) => w.corners.map((c) => toAxis(model, c).t))];
|
||||
const tMin = Math.min(...ts) - ABUT_GROUND_MARGIN_M;
|
||||
const tMax = Math.max(...ts) + ABUT_GROUND_MARGIN_M;
|
||||
const sMin = -ABUT_GROUND_MARGIN_M;
|
||||
const sMax = L + ABUT_GROUND_MARGIN_M;
|
||||
const pad = new THREE.BoxGeometry(sMax - sMin, 0.05, tMax - tMin);
|
||||
pad.translate((sMin + sMax) / 2, groundEl, (tMin + tMax) / 2);
|
||||
const mesh = new THREE.Mesh(pad, new THREE.MeshStandardMaterial({ color: 0x6b8f4e, roughness: 0.9, transparent: true, opacity: 0.35, depthWrite: false, side: THREE.DoubleSide }));
|
||||
mesh.name = 'ground';
|
||||
const group = new THREE.Group();
|
||||
group.name = 'ground';
|
||||
group.add(mesh);
|
||||
root.add(group);
|
||||
}
|
||||
return root;
|
||||
}
|
||||
|
||||
/**
|
||||
* Camera for looking at one abutment (2026-11-03): the target is the centre of its bounding box, and the camera stands
|
||||
* back along a diagonal from the bridge side so both the wall and the piles are in view.
|
||||
*/
|
||||
export function cameraForBox(box: THREE.Box3): { position: THREE.Vector3; target: THREE.Vector3 } {
|
||||
const target = box.getCenter(new THREE.Vector3());
|
||||
const size = box.getSize(new THREE.Vector3());
|
||||
const span = Math.max(size.x, size.y, size.z, 1);
|
||||
const dist = span * 1.6;
|
||||
const position = new THREE.Vector3(target.x + dist * 0.6, target.y + dist * 0.5, target.z + dist * 0.8);
|
||||
return { position, target };
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* 교대 접속슬래브 배근 삽도 (2026-11-05, 시안 ③ 접속슬래브 카드, 교대접속슬래브철근.md §9): 평면 (왼쪽 절반 하면 · 오른쪽 절반 상면,
|
||||
* 삽도와 같음), 단면 (길이 방향, 폭 가운데). 순수 문자열 함수, 철근은 배근 규칙의 경로(slab.ts paths, 교대 s · t · z)에서 그린다.
|
||||
* 색: 길이 방향 주철근 #0F5C66, 교대축 방향 #B4531F, R7 #C0501A, R8 #6B3FA0.
|
||||
*/
|
||||
import type { AbutSlabGeom, AbutSlabRebar, SlabBarPath, SlabMark } from '../core/abut/rebar/slab';
|
||||
import { balloon, CONC, MUTED, NO_PICK, opacity, text, type AbutRebarPick } from './abut-footing-rebar-svg';
|
||||
|
||||
export const ABUT_SLAB_COLORS = { main: '#0F5C66', dist: '#B4531F', corner: '#C0501A', shear: '#6B3FA0' } as const;
|
||||
const C = ABUT_SLAB_COLORS;
|
||||
const COLOR: Record<SlabMark, string> = { R1: C.main, R3: C.main, R2: C.dist, R4: C.dist, R7: C.corner, R8: C.shear };
|
||||
|
||||
/** u (0 측면에서 교대축을 따라) and t' (흉벽 쪽 끝에서) of a plan point. */
|
||||
const frame = (g: AbutSlabGeom) => ({
|
||||
u: (s: number, t: number) => s - (g.side0.s + g.side0.k * (t - g.near)),
|
||||
tp: (t: number) => g.near - t,
|
||||
});
|
||||
|
||||
/** 평면: 가로 u (교대축), 세로 t' (위 = 흉벽 쪽 끝). 왼쪽 절반은 하면 (R3 · R4 · R8 · 하면 R7), 오른쪽 절반은 상면 (R1 · R2 · 상면 R7). */
|
||||
export function abutSlabPlanSvg(g: AbutSlabGeom, r: AbutSlabRebar, pick: AbutRebarPick = NO_PICK): string {
|
||||
const f = frame(g);
|
||||
const k = 0.055;
|
||||
const X = (u: number) => 50 + u * k;
|
||||
const Y = (tp: number) => 60 + tp * k;
|
||||
const half = g.B / 2;
|
||||
const parts: string[] = [];
|
||||
const P = (u: number, tp: number) => `${X(u).toFixed(1)},${Y(tp).toFixed(1)}`;
|
||||
const outline = [P(g.chamfer0, 0), P(g.B - g.chamferL, 0), P(g.B, g.chamferL), P(g.B, g.Ls), P(0, g.Ls), P(0, g.chamfer0)];
|
||||
parts.push(`<polygon points="${outline.join(' ')}" fill="${CONC}" stroke="${MUTED}"/>`);
|
||||
parts.push(`<line x1="${X(half)}" y1="${Y(0) - 10}" x2="${X(half)}" y2="${Y(g.Ls) + 10}" stroke="${MUTED}" stroke-dasharray="6 4"/>`);
|
||||
parts.push(text(X(half / 2), Y(g.Ls) + 24, '하면 (R3 · R4 · R8)', 'middle', MUTED, 11), text(X(half * 1.5), Y(g.Ls) + 24, '상면 (R1 · R2)', 'middle', MUTED, 11));
|
||||
parts.push(text(X(half), Y(0) - 14, `흉벽 쪽 끝 · ${r.across.text}`, 'middle', MUTED, 10));
|
||||
const seg = (mark: SlabMark, a: [number, number], b: [number, number], w: number) =>
|
||||
`<line x1="${X(a[0]).toFixed(1)}" y1="${Y(a[1]).toFixed(1)}" x2="${X(b[0]).toFixed(1)}" y2="${Y(b[1]).toFixed(1)}" stroke="${COLOR[mark]}" stroke-width="${w}" opacity="${opacity(pick, [mark])}"/>`;
|
||||
const uvOf = (p: SlabBarPath) => p.pts.map(([s, t]) => [f.u(s, t), f.tp(t)] as [number, number]);
|
||||
for (const p of r.paths) {
|
||||
const uv = uvOf(p);
|
||||
const bottom = p.mark === 'R3' || p.mark === 'R4' || p.mark === 'R8';
|
||||
if (p.mark === 'R8') {
|
||||
const [u, tp] = uv[1];
|
||||
if (u < half) parts.push(`<circle cx="${X(u).toFixed(1)}" cy="${Y(tp).toFixed(1)}" r="2.2" fill="${C.shear}" opacity="${opacity(pick, ['R8'])}"/>`);
|
||||
continue;
|
||||
}
|
||||
if (p.mark === 'R7') {
|
||||
// 상 · 하 R7은 평면에서 같은 자리라 그대로 그린다 (모서리마다 한 모양).
|
||||
for (let i = 1; i < uv.length; i++) parts.push(seg('R7', uv[i - 1], uv[i], 1.6));
|
||||
continue;
|
||||
}
|
||||
// 길이 방향 (R1 · R3)은 절반 안의 것만, 교대축 방향 (R2 · R4)은 절반까지 자름.
|
||||
if (p.mark === 'R1' || p.mark === 'R3') {
|
||||
const u = uv[0][0];
|
||||
if (bottom ? u < half : u >= half) for (let i = 1; i < uv.length; i++) parts.push(seg(p.mark, uv[i - 1], uv[i], 0.7));
|
||||
} else {
|
||||
const [a, b] = [uv[0], uv[uv.length - 1]];
|
||||
const lo = a[0] < b[0] ? a : b;
|
||||
const hi = a[0] < b[0] ? b : a;
|
||||
if (bottom) parts.push(seg(p.mark, lo, [Math.min(hi[0], half), hi[1]], 0.7));
|
||||
else parts.push(seg(p.mark, [Math.max(lo[0], half), lo[1]], hi, 0.7));
|
||||
}
|
||||
}
|
||||
parts.push(text(X(g.B) + 8, Y(g.Ls / 2), r.along.text, 'start', MUTED, 10));
|
||||
parts.push(balloon(X(half * 0.25), Y(g.Ls) + 50, 'R3', C.main, pick), balloon(X(half * 0.5), Y(g.Ls) + 50, 'R4', C.dist, pick), balloon(X(half * 0.75), Y(g.Ls) + 50, 'R8', C.shear, pick));
|
||||
parts.push(balloon(X(half * 1.4), Y(g.Ls) + 50, 'R1', C.main, pick), balloon(X(half * 1.7), Y(g.Ls) + 50, 'R2', C.dist, pick), balloon(X(g.B * 0.06), Y(0) + 40, 'R7', C.corner, pick));
|
||||
const W = X(g.B) + 260;
|
||||
const H = Y(g.Ls) + 80;
|
||||
return `<svg viewBox="0 0 ${W.toFixed(0)} ${H.toFixed(0)}" width="100%" style="max-width:${Math.round(W * 1.3)}px" role="img" aria-label="접속슬래브 배근 평면 (왼쪽 하면 · 오른쪽 상면)">${parts.join('')}</svg>`;
|
||||
}
|
||||
|
||||
/** 단면 (길이 방향, 폭 가운데): 가로 t' (왼쪽 흉벽 쪽 끝), 세로 높이. 경사, 층 순서 (R1 · R2 위, R4 · R3 아래), R8. */
|
||||
export function abutSlabSectionSvg(g: AbutSlabGeom, r: AbutSlabRebar, pick: AbutRebarPick = NO_PICK): string {
|
||||
const f = frame(g);
|
||||
const uMid = g.B / 2;
|
||||
const kx = 0.11;
|
||||
const kz = 0.5;
|
||||
const sMid = (t: number) => g.side0.s + g.side0.k * (t - g.near) + uMid;
|
||||
const parapetAt = (sv: number) => g.parapetTop[0] + ((g.parapetTop[1] - g.parapetTop[0]) * sv) / g.L;
|
||||
const zs = g.stations.flatMap((st) => [parapetAt(sMid(st.t)) + st.top, parapetAt(sMid(st.t)) + st.bottom]);
|
||||
const zMax = Math.max(...zs) + 60;
|
||||
const zMin = Math.min(...zs) - 60;
|
||||
const X = (tp: number) => 40 + tp * kx;
|
||||
const Y = (z: number) => 50 + (zMax - z) * kz;
|
||||
const parts: string[] = [];
|
||||
const topPts = g.stations.map((st) => `${X(f.tp(st.t)).toFixed(1)},${Y(parapetAt(sMid(st.t)) + st.top).toFixed(1)}`);
|
||||
const botPts = [...g.stations].reverse().map((st) => `${X(f.tp(st.t)).toFixed(1)},${Y(parapetAt(sMid(st.t)) + st.bottom).toFixed(1)}`);
|
||||
parts.push(`<polygon points="${[...topPts, ...botPts].join(' ')}" fill="${CONC}" stroke="${MUTED}"/>`);
|
||||
// 폭 가운데에 가장 가까운 길이 방향 철근 (R1 · R3), 교대축 방향 철근 (R2 · R4) 점, R8 한 줄.
|
||||
const nearest = (mark: SlabMark) =>
|
||||
r.paths.filter((p) => p.mark === mark).reduce<SlabBarPath | undefined>((a, b) => (!a || Math.abs(f.u(b.pts[0][0], b.pts[0][1]) - uMid) < Math.abs(f.u(a.pts[0][0], a.pts[0][1]) - uMid) ? b : a), undefined);
|
||||
for (const mark of ['R1', 'R3'] as const) {
|
||||
const p = nearest(mark);
|
||||
if (p) parts.push(`<polyline points="${p.pts.map(([, t, z]) => `${X(f.tp(t)).toFixed(1)},${Y(z).toFixed(1)}`).join(' ')}" fill="none" stroke="${C.main}" stroke-width="1.6" opacity="${opacity(pick, [mark])}"/>`);
|
||||
}
|
||||
for (const p of r.paths.filter((q) => q.mark === 'R2' || q.mark === 'R4')) {
|
||||
const t = p.pts[0][1];
|
||||
const a = p.pts[0];
|
||||
const b = p.pts[p.pts.length - 1];
|
||||
const fz = (uMid - f.u(a[0], t)) / (f.u(b[0], t) - f.u(a[0], t));
|
||||
const z = a[2] + fz * (b[2] - a[2]);
|
||||
parts.push(`<circle cx="${X(f.tp(t)).toFixed(1)}" cy="${Y(z).toFixed(1)}" r="1.8" fill="${C.dist}" opacity="${opacity(pick, [p.mark])}"/>`);
|
||||
}
|
||||
// R8: 줄마다 하나, 그린 R1 · R3 선 사이 (슬래브 상면이 교대축을 따라 기울어 실제 R8마다 높이가 조금씩 다름).
|
||||
const zOn = (p: SlabBarPath | undefined, t: number) => {
|
||||
if (!p) return NaN;
|
||||
for (let i = 1; i < p.pts.length; i++) {
|
||||
const [a0, a1] = [p.pts[i - 1], p.pts[i]];
|
||||
if ((t - a0[1]) * (t - a1[1]) <= 0) return a0[2] + ((t - a0[1]) / (a1[1] - a0[1] || 1)) * (a1[2] - a0[2]);
|
||||
}
|
||||
return p.pts[p.pts.length - 1][2];
|
||||
};
|
||||
const r1 = nearest('R1');
|
||||
const r3 = nearest('R3');
|
||||
const rowTs = [...new Set(r.paths.filter((q) => q.mark === 'R8').map((q) => Math.round(q.pts[1][1])))];
|
||||
for (const t of rowTs) {
|
||||
const zt = zOn(r1, t) + 11.1;
|
||||
const zb = zOn(r3, t) - 11.1;
|
||||
parts.push(`<line x1="${X(f.tp(t)).toFixed(1)}" y1="${Y(zb).toFixed(1)}" x2="${X(f.tp(t)).toFixed(1)}" y2="${Y(zt).toFixed(1)}" stroke="${C.shear}" stroke-width="0.9" opacity="${opacity(pick, ['R8'])}"/>`);
|
||||
}
|
||||
parts.push(text(X(0), Y(zMin) + 16, '흉벽 쪽 끝 (브라켓 위)', 'start', MUTED, 10), text(X(g.Ls), Y(zMin) + 16, `먼 끝 · 경사 ${g.gradePct.toFixed(1)} %`, 'end', MUTED, 10));
|
||||
parts.push(text(X(g.Ls / 2), 18, `${g.form === 'buried' ? '매립형' : '노출형'} · 상면 R1 → R2, 하면 R3 → R4 (피복 ${r.settings.cv}) · R8 @${Math.round(r.settings.k8 * (r.settings.sDist ?? r.settings.sL))}`, 'middle', MUTED, 10));
|
||||
parts.push(balloon(X(g.Ls * 0.3), 40, 'R1', C.main, pick), balloon(X(g.Ls * 0.4), 40, 'R2', C.dist, pick), balloon(X(g.Ls * 0.5), 40, 'R8', C.shear, pick), balloon(X(g.Ls * 0.6), 40, 'R4', C.dist, pick), balloon(X(g.Ls * 0.7), 40, 'R3', C.main, pick));
|
||||
const W = X(g.Ls) + 40;
|
||||
const H = Y(zMin) + 30;
|
||||
return `<svg viewBox="0 0 ${W.toFixed(0)} ${H.toFixed(0)}" width="100%" style="max-width:${Math.round(W * 1.2)}px" role="img" aria-label="접속슬래브 배근 단면 (길이 방향)">${parts.join('')}</svg>`;
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
/**
|
||||
* 교대 접속슬래브 삽도 (2026-11-04, 시안 ② 접속슬래브 카드): 평면(날개벽 사이)과 단면 + 접합 상세(매립형 / 노출형, 중간 생략).
|
||||
* 순수 문자열 함수. 생성본 값으로 그리고 가로·세로 축척이 같다(단면은 파단선으로 중간을 생략). 접합 부속재는 표시용이다.
|
||||
*/
|
||||
import type { AbutPlanOutline } from './abut-scene';
|
||||
import { slabLevelAt, type SlabForm, type SlabStation } from '../core/abut/slab';
|
||||
import { dimLine } from './abut-wing-svg';
|
||||
|
||||
const INK = '#18212b';
|
||||
const MUTED = '#55606e';
|
||||
const WING = '#d6bb8c';
|
||||
const WALL = '#b7c7d3';
|
||||
const SLAB = '#f3eee4';
|
||||
const SLAB_EDGE = '#b49a6b';
|
||||
const text = (x: number, y: number, s: string, anchor = 'start', fill = INK) =>
|
||||
`<text x="${x.toFixed(1)}" y="${y.toFixed(1)}" text-anchor="${anchor}" font-size="11" fill="${fill}" font-family="JetBrains Mono, monospace">${s}</text>`;
|
||||
const poly = (pts: [number, number][], style: string) => `<polygon points="${pts.map(([x, y]) => `${x.toFixed(1)},${y.toFixed(1)}`).join(' ')}" ${style}/>`;
|
||||
const line = (x1: number, y1: number, x2: number, y2: number, style = `stroke="${MUTED}"`) =>
|
||||
`<line x1="${x1.toFixed(1)}" y1="${y1.toFixed(1)}" x2="${x2.toFixed(1)}" y2="${y2.toFixed(1)}" ${style}/>`;
|
||||
const el = (v: number) => `EL.${v.toFixed(3)}`;
|
||||
|
||||
export interface AbutSlabPlanDrawing {
|
||||
outline: AbutPlanOutline;
|
||||
/** 좌 = ① 쪽 끝 (sEnd); 날개벽 평면과 같은 좌우. */
|
||||
leftSEnd: number;
|
||||
widthM: number;
|
||||
lengthMm: number;
|
||||
/** 측별 모따기 (mm, 0 = 없음). */
|
||||
chamferMm: { left: number; right: number };
|
||||
}
|
||||
|
||||
/** 평면: 벽체, 날개벽, 접속슬래브(모따기), 폭 · 길이 치수. 왼쪽이 ① 쪽(좌), 위가 전면. */
|
||||
export function abutSlabPlanSvg(d: AbutSlabPlanDrawing): string {
|
||||
const pts = [...d.outline.wall, ...d.outline.slab, ...d.outline.wings.flatMap((w) => w.poly)];
|
||||
const flip = d.leftSEnd !== 0;
|
||||
const xs = pts.map(([s]) => (flip ? -s : s));
|
||||
const ts = pts.map(([, t]) => t);
|
||||
const k = 30;
|
||||
const xMin = Math.min(...xs) - 1.6;
|
||||
const tMax = Math.max(...ts) + 0.4;
|
||||
const X = (s: number) => 20 + ((flip ? -s : s) - xMin) * k;
|
||||
const Y = (t: number) => 18 + (tMax - t) * k;
|
||||
const W = 40 + (Math.max(...xs) - xMin + 0.6) * k;
|
||||
const tMin = Math.min(...d.outline.slab.map(([, t]) => t));
|
||||
const H = Y(tMin) + 46;
|
||||
const map = (p: [number, number][]) => p.map(([s, t]) => [X(s), Y(t)] as [number, number]);
|
||||
const slab = d.outline.slab;
|
||||
const parts = [
|
||||
poly(map(d.outline.wall), `fill="${WALL}" stroke="${MUTED}"`),
|
||||
...d.outline.wings.map((w) => poly(map(w.poly), `fill="${WING}" stroke="#9c8763"`)),
|
||||
poly(map(slab), `fill="${SLAB}" stroke="${SLAB_EDGE}" stroke-width="1.4"`),
|
||||
];
|
||||
// 폭: the far edge (the last two corners), below it. 길이: the left side, from the near edge to the far edge.
|
||||
const farA = slab[slab.length - 1];
|
||||
const farB = slab[slab.length - 2];
|
||||
const [fl, fr] = X(farA[0]) < X(farB[0]) ? [farA, farB] : [farB, farA];
|
||||
parts.push(dimLine([X(fl[0]), Y(fl[1])], [X(fr[0]), Y(fr[1])], Y(tMin) + 16, 'h', `폭 ${d.widthM.toFixed(3)} m`, true));
|
||||
const tNear = Math.max(...slab.map(([, t]) => t));
|
||||
const xLeft = Math.min(...slab.map(([s]) => X(s)));
|
||||
const nearLeft = slab.filter(([, t]) => Math.abs(t - tNear) < 1e-9).map(([s]) => X(s)).sort((a, b) => a - b)[0];
|
||||
parts.push(dimLine([nearLeft, Y(tNear)], [X(fl[0]), Y(fl[1])], xLeft - 12, 'v', `${d.lengthMm}`));
|
||||
const c = slab.reduce((a, [s, t]) => [a[0] + s / slab.length, a[1] + t / slab.length], [0, 0]);
|
||||
parts.push(text(X(c[0]), Y(c[1]), '접속슬래브', 'middle'));
|
||||
for (const w of d.outline.wings) {
|
||||
const mm = w.side === 'left' ? d.chamferMm.left : d.chamferMm.right;
|
||||
if (!(mm > 0)) continue;
|
||||
const x = X(w.sEnd) + (X(c[0]) > X(w.sEnd) ? 1 : -1) * 34;
|
||||
parts.push(text(x, Y(tNear) + 26, `모따기 ${mm} × ${mm}`, X(c[0]) > X(w.sEnd) ? 'start' : 'end', MUTED));
|
||||
}
|
||||
parts.push(text((X(0) + X(Math.max(...d.outline.wall.map(([s]) => s)))) / 2, 12, '흉벽 · 벽체 (위 = 전면)', 'middle', MUTED));
|
||||
return [
|
||||
`<svg viewBox="0 0 ${W.toFixed(0)} ${H.toFixed(0)}" width="100%" role="img" aria-label="접속슬래브 평면: 폭 ${d.widthM.toFixed(3)} m, 길이 ${d.lengthMm}">`,
|
||||
...parts,
|
||||
'</svg>',
|
||||
].join('');
|
||||
}
|
||||
|
||||
export interface AbutSlabSectionDrawing {
|
||||
form: SlabForm;
|
||||
/** "하향 4 %" / "종단경사 +1.91 %". */
|
||||
slopeLabel: string;
|
||||
/** 단면 (t, 흉벽 상단에서 잰 상면 · 하면), core/abut/slab.ts slabSection. */
|
||||
section: SlabStation[];
|
||||
/** 이 단면 위치의 흉벽 상단 EL (m). */
|
||||
parapetTopEl: number;
|
||||
bracketHeightM: number;
|
||||
parapetBackM: number;
|
||||
wallBackM: number;
|
||||
lengthMm: number;
|
||||
thicknessMm: number;
|
||||
parapetGapMm: number;
|
||||
haunchLengthMm: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 단면 + 접합 상세 (2026-11-04, 그림 합침): 교대축 직각, 왼쪽이 배면. 흉벽 쪽(헌치 끝 너머까지)과 먼 끝 0.8 m만 같은 축척으로
|
||||
* 그리고 그 사이는 파단선으로 생략한다. 간격(5)과 고무판 두께는 보이도록 키워 그리고, 부속재(충진재 · 스티로폼 · 고무판 ·
|
||||
* 타르페이퍼 · 다웰 · 강관)는 표시용이다. 치수: 7000 (중간 생략), 450, 흉벽 높이(브라켓 상면 ~ 흉벽 상단), 헌치, 브라켓 얹힘 길이.
|
||||
*/
|
||||
export function abutSlabSectionSvg(d: AbutSlabSectionDrawing): string {
|
||||
const k = 150; // px per m in both zones
|
||||
const st = d.section;
|
||||
const t0 = st[0].t;
|
||||
const t1 = st[st.length - 1].t;
|
||||
const bH = d.bracketHeightM;
|
||||
const wb = d.wallBackM;
|
||||
const pb = d.parapetBackM;
|
||||
const he = wb - d.haunchLengthMm / 1000;
|
||||
const farLen = 0.8;
|
||||
const tz = Math.max(d.form === 'exposed' ? he - 0.5 : wb - 1.5, t1 + farLen); // left end of the near zone
|
||||
const breakW = 34;
|
||||
const xL = 70;
|
||||
const xNear0 = xL + farLen * k + breakW;
|
||||
const X = (t: number) => (t <= t1 + farLen + 1e-9 ? xL + (t - t1) * k : xNear0 + (t - tz) * k);
|
||||
const gapPx = 4; // 간격 drawn 4 px wide whatever its size
|
||||
const Xs = (t: number) => X(t) - (t >= t0 - 1e-9 ? gapPx - (d.parapetGapMm / 1000) * k : 0);
|
||||
const low = -bH - 0.75;
|
||||
const hi = Math.max(0, ...st.map((p) => p.top)) + 0.15;
|
||||
const Y = (y: number) => 26 + (hi - y) * k;
|
||||
const lv = (t: number) => slabLevelAt(st, t);
|
||||
// Slab pieces: far (t1 … t1 + 0.8) and near (tz … t0), each closed by a zigzag at the cut.
|
||||
const zig = (x: number, top: number, bottom: number, dir: 1 | -1): [number, number][] => {
|
||||
const h = bottom - top;
|
||||
return [[x, top - 4], [x, top + h * 0.3], [x + dir * 6, top + h * 0.45], [x - dir * 6, top + h * 0.6], [x, top + h * 0.72], [x, bottom + 4]];
|
||||
};
|
||||
const piece = (from: number, to: number, cutLeft: boolean) => {
|
||||
const ts = [from, ...st.map((p) => p.t).filter((t) => t > from + 1e-9 && t < to - 1e-9), to].sort((a, b) => a - b);
|
||||
const top = ts.map((t) => [Xs(t), Y(lv(t).top)] as [number, number]);
|
||||
const bottom = ts.map((t) => [Xs(t), Y(lv(t).bottom)] as [number, number]).reverse();
|
||||
const cutT = cutLeft ? from : to;
|
||||
const cut = zig(Xs(cutT), Y(lv(cutT).top), Y(lv(cutT).bottom), cutLeft ? 1 : -1);
|
||||
return cutLeft ? [...top, ...bottom, ...cut.reverse()] : [...top, ...cut, ...bottom];
|
||||
};
|
||||
const slabStyle = `fill="${SLAB}" stroke="${SLAB_EDGE}" stroke-width="1.4"`;
|
||||
const parts = [
|
||||
poly([[X(wb), Y(low)], [X(0), Y(low)], [X(0), Y(0)], [X(pb), Y(0)], [X(pb), Y(-bH)], [X(wb), Y(-bH)]], `fill="${WALL}" stroke="${MUTED}"`),
|
||||
poly(piece(t1, t1 + farLen, false), slabStyle),
|
||||
poly(piece(tz, t0, true), slabStyle),
|
||||
];
|
||||
// 간격: 아스팔트 충진재 (위 30 %), 스티로폼 (아래).
|
||||
const gTop = Y(st[0].top);
|
||||
const gBot = Y(-bH);
|
||||
const gMid = gTop + (gBot - gTop) * 0.3;
|
||||
const gx = Xs(t0);
|
||||
parts.push(`<rect x="${gx.toFixed(1)}" y="${gTop.toFixed(1)}" width="${(X(pb) - gx).toFixed(1)}" height="${(gMid - gTop).toFixed(1)}" fill="#3b3f45"/>`);
|
||||
parts.push(`<rect x="${gx.toFixed(1)}" y="${gMid.toFixed(1)}" width="${(X(pb) - gx).toFixed(1)}" height="${(gBot - gMid).toFixed(1)}" fill="#ffffff" stroke="${MUTED}" stroke-width="0.5"/>`);
|
||||
// 타르페이퍼 (브라켓 상면), 고무판 150 (가운데), 다웰 D25 in 강관 Φ60 L300.
|
||||
const xc = (X(wb) + Xs(t0)) / 2;
|
||||
parts.push(line(X(wb), Y(-bH), X(pb), Y(-bH), 'stroke="#8a5a2b" stroke-width="2.4"'));
|
||||
parts.push(`<rect x="${(xc - 0.075 * k).toFixed(1)}" y="${(Y(-bH) - 3).toFixed(1)}" width="${(0.15 * k).toFixed(1)}" height="3" fill="#2b2b2b"/>`);
|
||||
parts.push(`<rect x="${(xc - 0.03 * k).toFixed(1)}" y="${(Y(-bH) - 3 - 0.3 * k).toFixed(1)}" width="${(0.06 * k).toFixed(1)}" height="${(0.3 * k).toFixed(1)}" fill="none" stroke="${INK}" stroke-width="0.9"/>`);
|
||||
parts.push(line(xc, Y(-bH) - 0.34 * k, xc, Y(-bH) + 0.3 * k, `stroke="${INK}" stroke-width="1.8"`));
|
||||
// 치수.
|
||||
const yRow = Y(-bH) + 18;
|
||||
if (d.form === 'exposed') parts.push(dimLine([X(he), Y(lv(he).bottom)], [X(wb), Y(-bH)], yRow, 'h', `헌치 ${d.haunchLengthMm}`, true));
|
||||
// 브라켓 얹힘 길이 = 브라켓 폭 − 흉벽과 간격 (예: 300 − 5 = 295).
|
||||
parts.push(dimLine([X(wb), Y(-bH)], [Xs(t0), Y(-bH)], yRow, 'h', `${Math.round((t0 - wb) * 1000)}`, true));
|
||||
const yLen = Y(low) + 22;
|
||||
parts.push(dimLine([X(t1), Y(lv(t1).bottom)], [Xs(t0), Y(st[0].bottom)], yLen, 'h', `${d.lengthMm} (중간 생략)`, true));
|
||||
parts.push(dimLine([X(t1), Y(lv(t1).top)], [X(t1), Y(lv(t1).bottom)], X(t1) - 12, 'v', `${d.thicknessMm}`));
|
||||
parts.push(dimLine([X(0), Y(0)], [X(0), Y(-bH)], X(0) + 16, 'v', `${Math.round(bH * 1000)}`, true));
|
||||
// 글자: 경사, EL, 부속재 (오른쪽 · 왼쪽 아래로 끌어냄).
|
||||
const xMidNear = (X(tz) + Xs(t0)) / 2;
|
||||
parts.push(text(xMidNear, Y(lv(tz).top) - 12, `경사 ${d.slopeLabel}`, 'middle'));
|
||||
const xr = X(0) + 58;
|
||||
const lead = (x1: number, y1: number, x2: number, y2: number, s: string, anchor: 'start' | 'end') =>
|
||||
line(x1, y1, x2, y2, `stroke="${MUTED}" stroke-width="0.7"`) + text(x2 + (anchor === 'start' ? 3 : -3), y2 + 4, s, anchor);
|
||||
parts.push(text(xr, Y(0) + 4, `흉벽 상단 ${el(d.parapetTopEl)}`, 'start'));
|
||||
parts.push(lead(gx + 2, gTop + 4, xr - 4, Y(0) + 26, `간격 ${d.parapetGapMm} · 아스팔트 충진재`, 'start'));
|
||||
parts.push(lead(gx + 2, gMid + 8, xr - 4, Y(0) + 48, '스티로폼', 'start'));
|
||||
parts.push(text(xr, Y(-bH) + 4, `브라켓 상면 ${el(d.parapetTopEl - bH)}`, 'start'));
|
||||
const xl = X(wb) - 24;
|
||||
parts.push(lead(xc - 6, Y(-bH) - 1, xl, Y(-bH) + 46, '고무판 150 × 150 × 15', 'end'));
|
||||
parts.push(lead(X(wb) + 4, Y(-bH) + 1, xl, Y(-bH) + 64, '타르페이퍼 T = 15', 'end'));
|
||||
parts.push(lead(xc, Y(-bH) + 0.25 * k, xl, Y(-bH) + 82, '다웰 D25 @400 · 강관 Φ60 L300', 'end'));
|
||||
parts.push(text(X(0) + 6, Y(low) - 4, '흉벽 · 벽체', 'start', MUTED));
|
||||
const W = xr + 200;
|
||||
const H = yLen + 24;
|
||||
return [
|
||||
`<svg viewBox="0 0 ${W.toFixed(0)} ${H.toFixed(0)}" width="100%" role="img" aria-label="접속슬래브 단면 · 접합 상세 · ${d.form === 'exposed' ? '노출형' : '매립형'} · 경사 ${d.slopeLabel} · 길이 ${d.lengthMm} (중간 생략) · 두께 ${d.thicknessMm} · 흉벽 높이 ${Math.round(bH * 1000)}">`,
|
||||
...parts,
|
||||
'</svg>',
|
||||
].join('');
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* 교대 벽체 배근 삽도 (2026-11-04, 시안 ③ 벽체 카드, 교대벽체철근.md §9): 교대축 직각 단면 (구간 1), 교대축 단면 (계단).
|
||||
* 순수 문자열 함수. 철근은 배근 규칙의 경로(wall.ts paths)에서 그린다. 번호 풍선(data-pick)과 선택은 저판 삽도와 같다.
|
||||
* 색: 연직 #0F5C66, 수평 #B4531F, 간격재 #6B3FA0, 계단 상면 W9 · W10 #C0501A (W10 파선).
|
||||
*/
|
||||
import type { AbutWallGeom, AbutWallRebar, WallBarPath, WallMark } from '../core/abut/rebar/wall';
|
||||
import { balloon, CONC, MUTED, NO_PICK, opacity, text, WALL, type AbutRebarPick } from './abut-footing-rebar-svg';
|
||||
|
||||
export const ABUT_WALL_COLORS = { vertical: '#0F5C66', horizontal: '#B4531F', tie: '#6B3FA0', step: '#C0501A' } as const;
|
||||
const C = ABUT_WALL_COLORS;
|
||||
const COLOR: Record<WallMark, string> = {
|
||||
W1: C.vertical, W3: C.vertical, W4: C.vertical, W11: C.vertical,
|
||||
W2: C.horizontal, W5: C.horizontal, W6: C.horizontal, W8: C.horizontal,
|
||||
W7: C.tie, W9: C.step, W10: C.step,
|
||||
};
|
||||
const DASH: Partial<Record<WallMark, string>> = { W4: '6 3', W7: '3 3', W10: '6 3' };
|
||||
|
||||
/** 교대축 직각 단면 at 구간 1 (IP3 끝): t 가로 (왼쪽 배면 → 오른쪽 전면), z 세로. */
|
||||
export function abutWallSectionSvg(g: AbutWallGeom, r: AbutWallRebar, pick: AbutRebarPick = NO_PICK): string {
|
||||
const seg = g.segments.find((s) => s.no === 1) ?? g.segments[0];
|
||||
const uMid = (seg.from + seg.to) / 2;
|
||||
const k = 0.1;
|
||||
const x0 = 70;
|
||||
const zTop = g.HF + seg.H;
|
||||
const zMax = Math.max(zTop + r.ld.H19, g.bracketTop[0], g.bracketTop[1]) + 150;
|
||||
const X = (t: number) => x0 + (t - g.xb) * k;
|
||||
const Y = (z: number) => 30 + (zMax - z) * k;
|
||||
const parts: string[] = [];
|
||||
const pad = 600;
|
||||
// 저판 (위 부분) · 벽체 · 교좌면 뒤 흉벽 · 브라켓 (점선).
|
||||
parts.push(`<rect x="${X(g.xb - pad)}" y="${Y(g.HF)}" width="${(g.xf - g.xb + 2 * pad) * k}" height="${g.HF * k}" fill="${CONC}" stroke="${MUTED}"/>`);
|
||||
parts.push(`<rect x="${X(g.xb)}" y="${Y(zTop)}" width="${(g.xf - g.xb) * k}" height="${seg.H * k}" fill="${CONC}" stroke="${MUTED}"/>`);
|
||||
const bt = Math.min(g.bracketTop[0], g.bracketTop[1]);
|
||||
parts.push(`<rect x="${X(g.xb)}" y="${Y(bt)}" width="${-g.xb * k}" height="${(bt - zTop) * k}" fill="${WALL}" stroke="${MUTED}" stroke-dasharray="4 3"/>`);
|
||||
parts.push(text((X(g.xb) + X(0)) / 2, Y(bt) - 6, '흉벽 · 브라켓', 'middle', MUTED, 10));
|
||||
parts.push(text((X(0) + X(g.xf)) / 2, Y(zTop) - 6, `교좌면 (구간 1)`, 'middle', MUTED, 10));
|
||||
const line = (p: WallBarPath, w = 1.6) =>
|
||||
`<polyline points="${p.pts.map(([, t, z]) => `${X(t).toFixed(1)},${Y(z).toFixed(1)}`).join(' ')}" fill="none" stroke="${COLOR[p.mark]}" stroke-width="${w}"${DASH[p.mark] ? ` stroke-dasharray="${DASH[p.mark]}"` : ''} opacity="${opacity(pick, [p.mark])}"/>`;
|
||||
const dot = (p: WallBarPath) => {
|
||||
const [, t, z] = p.pts[0];
|
||||
return `<circle cx="${X(t).toFixed(1)}" cy="${Y(z).toFixed(1)}" r="2.4" fill="${COLOR[p.mark]}" opacity="${opacity(pick, [p.mark])}"/>`;
|
||||
};
|
||||
// Bars across the section (in the t-z plane): the first of each mark in 구간 1.
|
||||
const first = (mark: WallMark) => r.paths.find((p) => p.mark === mark && p.pts[1][0] >= seg.from && p.pts[1][0] < seg.to);
|
||||
for (const mark of ['W7', 'W1', 'W3', 'W4', 'W2'] as WallMark[]) {
|
||||
if (mark === 'W7') {
|
||||
for (const p of r.paths.filter((q) => q.mark === 'W7' && Math.abs(q.pts[1][0] - (first('W7')?.pts[1][0] ?? NaN)) < 1e-6)) parts.push(line(p, 1.1));
|
||||
} else {
|
||||
const p = first(mark);
|
||||
if (p) parts.push(line(p, mark === 'W4' ? 1.3 : 1.8));
|
||||
}
|
||||
}
|
||||
// Bars along the axis that pass the middle of 구간 1: dots.
|
||||
const along = r.paths.filter((p) => ['W5', 'W6', 'W9', 'W10'].includes(p.mark) && Math.min(p.pts[0][0], p.pts[1][0]) <= uMid && Math.max(p.pts[0][0], p.pts[1][0]) >= uMid);
|
||||
for (const p of along) parts.push(dot(p));
|
||||
// 치수: 전면 수평 단 (구간 1), 상면 W9 간격.
|
||||
const lv = r.levels.find((l) => l.no === seg.no);
|
||||
if (lv) {
|
||||
const xd = X(g.xf) + 14;
|
||||
parts.push(`<line x1="${xd}" y1="${Y(g.HF)}" x2="${xd}" y2="${Y(zTop)}" stroke="${MUTED}"/>`);
|
||||
parts.push(`<text x="${xd + 6}" y="${(Y(g.HF) + Y(zTop)) / 2}" font-size="10.5" fill="${MUTED}" font-family="JetBrains Mono, monospace" transform="rotate(-90 ${xd + 6} ${(Y(g.HF) + Y(zTop)) / 2})" text-anchor="middle">${r.settings.c} · ${lv.text}</text>`);
|
||||
}
|
||||
if (r.x.W9.length > 1) parts.push(text((X(r.x.W9[0]) + X(r.x.W9[r.x.W9.length - 1])) / 2, Y(zTop) + 22, `W9 · W10 ${r.x.W9.length - 1}@${Math.round(r.x.W9[0] - r.x.W9[1])}`, 'middle', C.step, 10));
|
||||
// 번호 풍선.
|
||||
const yMid = Y(g.HF + seg.H / 2);
|
||||
parts.push(balloon(X(g.xf) + 60, yMid - 40, 'W1', C.vertical, pick), balloon(X(g.xf) + 60, yMid + 10, 'W5', C.horizontal, pick));
|
||||
parts.push(balloon(X(g.xb) - 40, yMid - 60, 'W3', C.vertical, pick), balloon(X(g.xb) - 40, yMid - 10, 'W4', C.vertical, pick), balloon(X(g.xb) - 40, yMid + 40, 'W6', C.horizontal, pick));
|
||||
parts.push(balloon((X(0) + X(g.xf)) / 2 - 20, Y(zTop) - 34, 'W2', C.horizontal, pick), balloon((X(0) + X(g.xf)) / 2 + 20, Y(zTop) - 34, 'W9', C.step, pick), balloon((X(0) + X(g.xf)) / 2 + 60, Y(zTop) - 34, 'W10', C.step, pick));
|
||||
parts.push(balloon((X(g.xb) + X(g.xf)) / 2, Y(g.HF + 300), 'W7', C.tie, pick));
|
||||
const W = X(g.xf + pad) + 40;
|
||||
const H = Y(0) + 10;
|
||||
return `<svg viewBox="0 0 ${W.toFixed(0)} ${H.toFixed(0)}" width="100%" style="max-width:${Math.round(W * 1.4)}px" role="img" aria-label="벽체 교대축 직각 단면 (구간 1)">${parts.join('')}</svg>`;
|
||||
}
|
||||
|
||||
/** 교대축 단면 (계단): u 가로 (왼쪽 IP3 = 구간 1 → 오른쪽 IP1, 시안과 같은 좌우), z 세로. 전면 철근 (W1 · W5) · 계단 상면 · 끝면. */
|
||||
export function abutWallElevationSvg(g: AbutWallGeom, r: AbutWallRebar, pick: AbutRebarPick = NO_PICK): string {
|
||||
const k = 0.045;
|
||||
const kz = 0.08;
|
||||
const x0 = 70;
|
||||
const zMax = g.HF + Math.max(...g.segments.map((s) => s.H)) + 300;
|
||||
const X = (u: number) => x0 + (g.L - u) * k;
|
||||
const Y = (z: number) => 56 + (zMax - z) * kz;
|
||||
const parts: string[] = [];
|
||||
parts.push(`<rect x="${X(g.L)}" y="${Y(g.HF)}" width="${g.L * k}" height="${g.HF * kz}" fill="${CONC}" stroke="${MUTED}"/>`);
|
||||
let outline = `M${X(0).toFixed(1)},${Y(g.HF).toFixed(1)}`;
|
||||
for (const s of g.segments) outline += ` L${X(s.from).toFixed(1)},${Y(g.HF + s.H).toFixed(1)} L${X(s.to).toFixed(1)},${Y(g.HF + s.H).toFixed(1)}`;
|
||||
outline += ` L${X(g.L).toFixed(1)},${Y(g.HF).toFixed(1)} Z`;
|
||||
parts.push(`<path d="${outline}" fill="${CONC}" stroke="${MUTED}"/>`);
|
||||
for (const s of g.segments) parts.push(text((X(s.from) + X(s.to)) / 2, Y(g.HF + s.H) - 8, `구간 ${s.no} · ${Math.round(s.H)}`, 'middle', MUTED, 10));
|
||||
const seg = (mark: WallMark, a: [number, number], b: [number, number], w: number) =>
|
||||
`<line x1="${X(a[0]).toFixed(1)}" y1="${Y(a[1]).toFixed(1)}" x2="${X(b[0]).toFixed(1)}" y2="${Y(b[1]).toFixed(1)}" stroke="${COLOR[mark]}" stroke-width="${w}"${DASH[mark] ? ` stroke-dasharray="${DASH[mark]}"` : ''} opacity="${opacity(pick, [mark])}"/>`;
|
||||
const front = r.x.W1;
|
||||
const w11Front = Math.max(...r.paths.filter((p) => p.mark === 'W11').map((p) => p.pts[1][1]));
|
||||
for (const p of r.paths) {
|
||||
const [u0, t0, z0] = p.pts[0];
|
||||
const [u1, , z1] = p.pts[p.pts.length - 1];
|
||||
if (p.mark === 'W1') parts.push(seg('W1', [p.pts[1][0], p.pts[1][2]], [u1, z1], 0.7));
|
||||
else if (p.mark === 'W5') parts.push(seg('W5', [u0, z0], [u1, z1], 0.8));
|
||||
else if ((p.mark === 'W9' || p.mark === 'W10') && Math.abs(t0 - r.x.W9[0]) < 1e-6) {
|
||||
for (let i = 1; i < p.pts.length; i++) parts.push(seg(p.mark, [p.pts[i - 1][0], p.pts[i - 1][2]], [p.pts[i][0], p.pts[i][2]], 2));
|
||||
}
|
||||
else if (p.mark === 'W11' && Math.abs(t0 - w11Front) < 1e-6) {
|
||||
// ㄷ형: 연직 + 위 갈고리 (아래 갈고리는 저판 안).
|
||||
parts.push(seg('W11', [p.pts[1][0], p.pts[1][2]], [p.pts[2][0], p.pts[2][2]], 2), seg('W11', [p.pts[2][0], p.pts[2][2]], [u1, z1], 2));
|
||||
}
|
||||
else if (p.mark === 'W8') parts.push(`<circle cx="${X(p.pts[1][0]).toFixed(1)}" cy="${Y(z0).toFixed(1)}" r="1.6" fill="${COLOR.W8}" opacity="${opacity(pick, ['W8'])}"/>`);
|
||||
}
|
||||
// 단 치수 (양끝).
|
||||
const low = r.levels.find((l) => l.no === 1);
|
||||
const high = r.levels.find((l) => l.no === g.segments.length);
|
||||
const lvText = [low, high && g.segments.length > 1 ? high : undefined].filter((l) => l).map((l) => `구간 ${l!.no} 수평 ${l!.text}`).join(' ');
|
||||
parts.push(text((X(0) + X(g.L)) / 2, Y(0) + 30, lvText, 'middle', MUTED, 10));
|
||||
parts.push(text((X(0) + X(g.L)) / 2, Y(g.HF / 2), `저판 (연직철근 갈고리는 B6 · B8 위) · 전면 W1 x ${Math.round(front * 10) / 10}`, 'middle', MUTED, 10));
|
||||
parts.push(text(X(g.L), Y(0) + 14, 'IP3 (구간 1)', 'start', MUTED, 10), text(X(0), Y(0) + 14, 'IP1', 'end', MUTED, 10));
|
||||
const top = 20;
|
||||
// W9 · W10 balloons over their own bars (the mark follows the shape: ㄷ / ㄴ).
|
||||
const midOf = (mark: WallMark, fallback: number) => {
|
||||
const p = r.paths.find((q) => q.mark === mark);
|
||||
return p ? (p.pts[0][0] + p.pts[p.pts.length - 1][0]) / 2 : fallback;
|
||||
};
|
||||
parts.push(balloon(X(midOf('W9', g.L * 0.85)), top, 'W9', C.step, pick), balloon(X(midOf('W10', g.L * 0.5)), top, 'W10', C.step, pick));
|
||||
parts.push(balloon(X(g.L * 0.3), top, 'W1', C.vertical, pick), balloon(X(g.L * 0.65), top, 'W5', C.horizontal, pick));
|
||||
parts.push(balloon(X(0) + 34, Y(g.HF + 1200), 'W11', C.vertical, pick), balloon(X(0) + 34, Y(g.HF + 600), 'W8', C.horizontal, pick));
|
||||
const W = X(0) + 60;
|
||||
const H = Y(0) + 40;
|
||||
return `<svg viewBox="0 0 ${W.toFixed(0)} ${H.toFixed(0)}" width="100%" style="max-width:${Math.round(W * 1.2)}px" role="img" aria-label="벽체 교대축 단면 (계단)">${parts.join('')}</svg>`;
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* 교대 벽체·흉벽 삽도 (2026-11-03, 시안 ② 벽체·흉벽 카드): 교대축 직각 단면(① 구간)과 정면·배면도. 순수 문자열 함수.
|
||||
* 생성본(교대 모델·부재)의 값으로 그린다. 두 그림 모두 가로·세로 축척이 같다(시안 "축척 같음").
|
||||
*/
|
||||
|
||||
export interface AbutWallDrawing {
|
||||
/** 교대축 길이 L (m), IP1 → IP3. */
|
||||
lengthM: number;
|
||||
/** 교좌면 계단 구간 (s, m는 IP1에서), 구간마다 교좌면 EL. */
|
||||
segments: { sFrom: number; sTo: number; seatEl: number }[];
|
||||
/** 받침 (s, m), 받침 번호, 패드 상면 z (m), 교좌면 EL (m). */
|
||||
bearings: { no: number; s: number; z: number; seatEl: number }[];
|
||||
footingTopEl: number;
|
||||
footingBottomEl: number;
|
||||
/** 흉벽 상단 EL at s = 0 (IP1 끝) and s = L (IP3 끝). */
|
||||
parapetTopStartEl: number;
|
||||
parapetTopEndEl: number;
|
||||
/** t (m, 전면 +): 벽체 전면, 흉벽 배면, 벽체 배면, 저판 앞굽 끝·뒷굽 끝. */
|
||||
wallFrontM: number;
|
||||
parapetBackM: number;
|
||||
wallBackM: number;
|
||||
footingFrontM: number;
|
||||
footingBackM: number;
|
||||
bracketHeightM: number;
|
||||
/** 수축줄눈 위치 s (m from IP1), from core/abut/joints.ts (받침 사이 중간). */
|
||||
shrinkJointsS: number[];
|
||||
/** 시안 치수 줄 "브라켓 폭 + 흉벽 두께 + 교좌면 폭 = 벽체 두께" (mm). */
|
||||
bracketWidthMm: number;
|
||||
parapetThicknessMm: number;
|
||||
seatWidthMm: number;
|
||||
}
|
||||
|
||||
const INK = '#18212b';
|
||||
const MUTED = '#55606e';
|
||||
const CONC = '#d9c9a8';
|
||||
const CONC_DARK = '#c7b48d';
|
||||
const FOOT = '#b9c3ce';
|
||||
const t3 = (v: number) => v.toFixed(3);
|
||||
/** EL 표기 (2026-11-04): "EL.41.995". */
|
||||
const el = (v: number) => `EL.${v.toFixed(3)}`;
|
||||
const label = (x: number, y: number, s: string, anchor = 'middle', fill = INK) =>
|
||||
`<text x="${x.toFixed(1)}" y="${y.toFixed(1)}" text-anchor="${anchor}" font-size="11" fill="${fill}" font-family="JetBrains Mono, monospace">${s}</text>`;
|
||||
const poly = (pts: [number, number][], style: string) => `<polygon points="${pts.map(([x, y]) => `${x.toFixed(1)},${y.toFixed(1)}`).join(' ')}" ${style}/>`;
|
||||
|
||||
/** 흉벽 상단 EL at s (linear between the two ends). */
|
||||
function parapetTopAt(d: AbutWallDrawing, s: number): number {
|
||||
return d.parapetTopStartEl + ((d.parapetTopEndEl - d.parapetTopStartEl) * s) / d.lengthM;
|
||||
}
|
||||
|
||||
/**
|
||||
* 교대축 직각 단면 (① 구간 = 받침 1번이 놓인 구간): 저판, 벽체(기초 상면 ~ 교좌면), 흉벽 배면 단차부(브라켓 상면까지),
|
||||
* 흉벽(흉벽 상단까지), 받침 블럭. 왼쪽이 배면, 오른쪽이 전면.
|
||||
*/
|
||||
export function abutWallSectionSvg(d: AbutWallDrawing): string {
|
||||
const b1 = d.bearings.find((b) => b.no === 1) ?? d.bearings[0];
|
||||
const seg = d.segments.find((g) => b1.s >= g.sFrom && b1.s <= g.sTo) ?? d.segments[0];
|
||||
const sAt = b1.s;
|
||||
const top = parapetTopAt(d, sAt);
|
||||
const bracket = top - d.bracketHeightM;
|
||||
const k = 46; // px per m, both directions
|
||||
const tMin = d.footingBackM - 0.6;
|
||||
const elMax = top + 0.7;
|
||||
const X = (t: number) => (t - tMin) * k;
|
||||
const Y = (el: number) => (elMax - el) * k + 10;
|
||||
const W = (d.footingFrontM + 1.4 - tMin) * k;
|
||||
const H = Y(d.footingBottomEl) + 40;
|
||||
const rect = (t0: number, t1: number, e0: number, e1: number, style: string) =>
|
||||
poly([[X(t0), Y(e0)], [X(t1), Y(e0)], [X(t1), Y(e1)], [X(t0), Y(e1)]], style);
|
||||
const halfBlock = 0.6;
|
||||
const blockT = (d.wallFrontM + 0) / 2; // drawn at the middle of the 교좌면 width
|
||||
return [
|
||||
`<svg viewBox="0 0 ${W.toFixed(0)} ${H.toFixed(0)}" width="100%" role="img" aria-label="교대축 직각 단면 (① 구간): 흉벽 상단 ${t3(top)}, 브라켓 상면 ${t3(bracket)}, 교좌면 ${t3(seg.seatEl)}, 저판 상면 ${t3(d.footingTopEl)}, 기초 하단 ${t3(d.footingBottomEl)}">`,
|
||||
rect(d.footingBackM, d.footingFrontM, d.footingBottomEl, d.footingTopEl, `fill="${FOOT}" stroke="${MUTED}"`),
|
||||
rect(d.wallBackM, d.wallFrontM, d.footingTopEl, seg.seatEl, `fill="${CONC}" stroke="${MUTED}"`),
|
||||
rect(d.wallBackM, d.parapetBackM, seg.seatEl, bracket, `fill="${CONC_DARK}" stroke="${MUTED}"`),
|
||||
rect(d.parapetBackM, 0, seg.seatEl, top, `fill="${CONC_DARK}" stroke="${MUTED}"`),
|
||||
rect(blockT - halfBlock, blockT + halfBlock, seg.seatEl, b1.z, `fill="#9aa7b5" stroke="${INK}"`),
|
||||
`<line x1="${X(0).toFixed(1)}" y1="4" x2="${X(0).toFixed(1)}" y2="${(H - 30).toFixed(1)}" stroke="#c0392b" stroke-dasharray="6 3"/>`,
|
||||
label(X(0), 14, '교대축', 'middle', '#c0392b'),
|
||||
label((X(d.wallBackM) + X(d.wallFrontM)) / 2, Y(top) - 18, `${d.bracketWidthMm} + ${d.parapetThicknessMm} + ${d.seatWidthMm} = ${d.bracketWidthMm + d.parapetThicknessMm + d.seatWidthMm}`),
|
||||
label(X(0) + 6, Y(top) - 4, `흉벽 상단 ${el(top)}`, 'start'),
|
||||
label(X(d.wallBackM) - 6, Y(bracket) + 4, `브라켓 상면 ${el(bracket)}`, 'end'),
|
||||
label(X(d.wallFrontM) + 6, Y(seg.seatEl) + 4, `교좌면 ${el(seg.seatEl)}`, 'start'),
|
||||
label(X(d.wallFrontM) + 6, Y((seg.seatEl + d.footingTopEl) / 2) + 4, `벽체 ${(seg.seatEl - d.footingTopEl).toFixed(3)}`, 'start'),
|
||||
label(X(d.footingFrontM) + 6, Y(d.footingTopEl) + 4, `상면 ${el(d.footingTopEl)}`, 'start'),
|
||||
label(X(d.footingFrontM) + 6, Y(d.footingBottomEl) + 4, `기초 하단 ${el(d.footingBottomEl)}`, 'start'),
|
||||
label(X(d.footingBackM), H - 8, '← 배면', 'start', MUTED),
|
||||
label(X(d.footingFrontM), H - 8, '전면 →', 'end', MUTED),
|
||||
'</svg>',
|
||||
].join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* 정면·배면도: 교좌면 계단(구간별 교좌면 EL), 흉벽 상단 경사, 받침 블럭, 수축줄눈, 저판, 받침 ① 기준선과 벽체 높이 H_w. 왼쪽 IP3(① 쪽), 오른쪽 IP1.
|
||||
*/
|
||||
export function abutWallElevationSvg(d: AbutWallDrawing): string {
|
||||
const k = 46;
|
||||
const top = Math.max(d.parapetTopStartEl, d.parapetTopEndEl);
|
||||
const elMax = top + 0.6;
|
||||
const pad = 40;
|
||||
const X = (s: number) => pad + (d.lengthM - s) * k; // IP3 (s = L) on the left
|
||||
const Y = (el: number) => (elMax - el) * k + 10;
|
||||
const W = d.lengthM * k + 2 * pad;
|
||||
const H = Y(d.footingBottomEl) + 40;
|
||||
const parts: string[] = [];
|
||||
parts.push(poly([[X(d.lengthM), Y(d.footingTopEl)], [X(0), Y(d.footingTopEl)], [X(0), Y(d.footingBottomEl)], [X(d.lengthM), Y(d.footingBottomEl)]], `fill="${FOOT}" stroke="${MUTED}"`));
|
||||
// 흉벽 (behind) from the 교좌면 steps up to the sloped top line.
|
||||
parts.push(poly([[X(d.lengthM), Y(d.footingTopEl)], [X(0), Y(d.footingTopEl)], [X(0), Y(d.parapetTopStartEl)], [X(d.lengthM), Y(d.parapetTopEndEl)]], `fill="${CONC_DARK}" stroke="${MUTED}"`));
|
||||
// 벽체 front: one block per 교좌면 step.
|
||||
for (const g of d.segments) {
|
||||
parts.push(poly([[X(g.sTo), Y(d.footingTopEl)], [X(g.sFrom), Y(d.footingTopEl)], [X(g.sFrom), Y(g.seatEl)], [X(g.sTo), Y(g.seatEl)]], `fill="${CONC}" stroke="${MUTED}"`));
|
||||
}
|
||||
for (const b of d.bearings) {
|
||||
parts.push(poly([[X(b.s + 0.6), Y(b.seatEl)], [X(b.s - 0.6), Y(b.seatEl)], [X(b.s - 0.6), Y(b.z)], [X(b.s + 0.6), Y(b.z)]], `fill="#9aa7b5" stroke="${INK}"`));
|
||||
parts.push(label(X(b.s), Y(b.z) - 6, ['①', '②', '③', '④', '⑤', '⑥', '⑦', '⑧'][b.no - 1] ?? String(b.no)));
|
||||
}
|
||||
d.shrinkJointsS.forEach((s, i) => {
|
||||
const j = i + 1;
|
||||
parts.push(`<line x1="${X(s).toFixed(1)}" y1="${Y(parapetTopAt(d, s)).toFixed(1)}" x2="${X(s).toFixed(1)}" y2="${Y(d.footingTopEl).toFixed(1)}" stroke="${INK}" stroke-dasharray="5 3"/>`);
|
||||
parts.push(label(X(s) + 4, Y(d.footingTopEl) - 8, `수축줄눈 ${j}`, 'start'));
|
||||
});
|
||||
// 벽체 높이 H_w (사용자 도면 2026-11-04): 받침 ① 연직 기준선, 받침 ① 교좌면 ~ 흉벽 상단.
|
||||
const b1 = d.bearings.find((b) => b.no === 1) ?? d.bearings[0];
|
||||
if (b1) {
|
||||
const topB1 = parapetTopAt(d, b1.s);
|
||||
const xb = X(b1.s);
|
||||
parts.push(`<line x1="${xb.toFixed(1)}" y1="${(Y(topB1) - 14).toFixed(1)}" x2="${xb.toFixed(1)}" y2="${(Y(d.footingTopEl) + 6).toFixed(1)}" stroke="#c0392b" stroke-dasharray="10 3 2 3"/>`);
|
||||
const xd = xb - 22;
|
||||
parts.push(`<line x1="${xd.toFixed(1)}" y1="${Y(topB1).toFixed(1)}" x2="${xd.toFixed(1)}" y2="${Y(b1.seatEl).toFixed(1)}" stroke="#c0392b" marker-start="url(#hw-a)" marker-end="url(#hw-a)"/>`);
|
||||
parts.push(`<text x="${(xd - 4).toFixed(1)}" y="${((Y(topB1) + Y(b1.seatEl)) / 2).toFixed(1)}" font-size="11" fill="#c0392b" text-anchor="end">H_w ${Math.round((topB1 - b1.seatEl) * 1000)}</text>`);
|
||||
}
|
||||
const first = d.segments.find((g) => d.lengthM >= g.sFrom && d.lengthM <= g.sTo) ?? d.segments[d.segments.length - 1];
|
||||
const last = d.segments.find((g) => g.sFrom <= 0 && g.sTo >= 0) ?? d.segments[0];
|
||||
parts.push(label(X(d.lengthM), Y(d.parapetTopEndEl) - 6, el(d.parapetTopEndEl), 'start'));
|
||||
parts.push(label(X(0), Y(d.parapetTopStartEl) - 6, el(d.parapetTopStartEl), 'end'));
|
||||
parts.push(label(X(d.lengthM) - 4, Y(first.seatEl) + 4, el(first.seatEl), 'end'));
|
||||
parts.push(label(X(0) + 4, Y(last.seatEl) + 4, el(last.seatEl), 'start'));
|
||||
parts.push(label(X(d.lengthM) - 4, Y(d.footingBottomEl) + 4, el(d.footingBottomEl), 'end'));
|
||||
parts.push(label(X(d.lengthM), H - 8, 'IP3', 'middle', MUTED));
|
||||
parts.push(label(X(0), H - 8, 'IP1', 'middle', MUTED));
|
||||
parts.push(label((X(0) + X(d.lengthM)) / 2, H - 8, `${d.lengthM.toFixed(3)} m`));
|
||||
return [
|
||||
`<svg viewBox="0 0 ${W.toFixed(0)} ${H.toFixed(0)}" width="100%" role="img" aria-label="정면·배면도: 흉벽 상단 ${t3(d.parapetTopEndEl)} (IP3) → ${t3(d.parapetTopStartEl)} (IP1), 교좌면 계단 ${d.segments.length}구간, 수축줄눈 ${d.shrinkJointsS.length}">`,
|
||||
'<defs><marker id="hw-a" viewBox="0 0 10 10" refX="5" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse"><path d="M0,0 L10,5 L0,10 z" fill="#c0392b"/></marker></defs>',
|
||||
...parts,
|
||||
'</svg>',
|
||||
].join('');
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* 교대 날개벽 배근 삽도 (2026-11-04, 시안 ③ 날개벽 카드, 교대날개벽철근.md §10): 전면도 · 배면도 (날개벽 방향 거리 d × 높이),
|
||||
* 헌치 · 모서리 평면. 순수 문자열 함수, 철근은 배근 규칙의 경로(wing.ts paths, 교대 s · t · z)에서 그린다.
|
||||
* 색: 연직 #0F5C66, 수평 #B4531F (등단면 실선 · 변화단면1 파선 · 변화단면2 점선), 헌치 · 모서리 #C0501A, WR6 · WR20 #6B3FA0.
|
||||
*/
|
||||
import type { AbutWingGeom, AbutWingRebar, WingBarPath, WingMark } from '../core/abut/rebar/wing';
|
||||
import { balloon, CONC, MUTED, NO_PICK, opacity, text, WALL, type AbutRebarPick } from './abut-footing-rebar-svg';
|
||||
|
||||
export const ABUT_WING_COLORS = { vertical: '#0F5C66', horizontal: '#B4531F', haunch: '#C0501A', other: '#6B3FA0' } as const;
|
||||
const C = ABUT_WING_COLORS;
|
||||
const COLOR = (m: WingMark): string =>
|
||||
['WR1', 'WR2', 'WR11', 'WR12'].includes(m) ? C.vertical : ['WR3', 'WR4', 'WR5', 'WR13', 'WR14', 'WR15'].includes(m) ? C.horizontal : m === 'WR6' || m === 'WR20' ? C.other : C.haunch;
|
||||
const DASH: Partial<Record<WingMark, string>> = { WR4: '6 3', WR14: '6 3', WR5: '2 2', WR15: '2 2', WR10: '6 3', WR20: '2 2' };
|
||||
|
||||
/** Wing frame of a plan point: d along the wing from the axis line, depth from the front face toward the back. */
|
||||
function frame(g: AbutWingGeom) {
|
||||
const [ws, wt] = g.w;
|
||||
const iw = g.inward[0] * ws + g.inward[1] * wt;
|
||||
const nv = [g.inward[0] - iw * ws, g.inward[1] - iw * wt];
|
||||
const T = Math.hypot(nv[0], nv[1]);
|
||||
const n = [nv[0] / T, nv[1] / T];
|
||||
return {
|
||||
T,
|
||||
d: (s: number, t: number) => (s - g.p0[0]) * ws + (t - g.p0[1]) * wt,
|
||||
depth: (s: number, t: number) => (s - g.p0[0]) * n[0] + (t - g.p0[1]) * n[1],
|
||||
};
|
||||
}
|
||||
|
||||
/** 전면도 (face 'front', 바깥에서 봄) 또는 배면도 ('back', 흙 쪽에서 봄): 가로 d (왼쪽 교대 → 오른쪽 끝), 세로 높이. */
|
||||
export function abutWingFaceSvg(g: AbutWingGeom, r: AbutWingRebar, face: 'front' | 'back', pick: AbutRebarPick = NO_PICK): string {
|
||||
const f = frame(g);
|
||||
const k = 0.075;
|
||||
// 왼쪽 끝 = 벽체 전면 (변화단면2 수평철근이 벽체를 지나 전면 − c까지 감).
|
||||
const dMin = -(g.wallFrontT + 150) / Math.abs(g.w[1]);
|
||||
const zTopMax = g.topStart + g.slope * g.L;
|
||||
const X = (d: number) => 60 + (d - dMin) * k;
|
||||
const Y = (z: number) => 50 + (zTopMax + 100 - z) * k;
|
||||
const parts: string[] = [];
|
||||
const general = g.form === 'general' && g.heelD < g.L;
|
||||
const outline = [
|
||||
[g.wallBackD, g.footTop], [general ? g.heelD : g.L, g.footTop], [g.L, general ? g.bottomEnd : g.footTop], [g.L, zTopMax], [g.wallBackD, g.topStart + g.slope * g.wallBackD],
|
||||
];
|
||||
// 교대 단면 (벽체 · 흉벽, 날개벽 시작 쪽) · 저판.
|
||||
parts.push(`<rect x="${X(dMin)}" y="${Y(g.topStart)}" width="${(g.wallBackD - dMin) * k}" height="${(g.topStart - g.footTop) * k}" fill="${WALL}" stroke="${MUTED}" stroke-dasharray="4 3"/>`);
|
||||
parts.push(`<rect x="${X(dMin)}" y="${Y(g.footTop)}" width="${(g.heelD - dMin) * k}" height="${(g.footTop - g.zB68 + 200) * k}" fill="${CONC}" stroke="${MUTED}"/>`);
|
||||
parts.push(`<polygon points="${outline.map(([d, z]) => `${X(d).toFixed(1)},${Y(z).toFixed(1)}`).join(' ')}" fill="${CONC}" stroke="${MUTED}"/>`);
|
||||
parts.push(text(X((dMin + g.wallBackD) / 2), Y(g.topStart) - 6, '교대 단면', 'middle', MUTED, 10));
|
||||
parts.push(`<line x1="${X(g.heelD)}" y1="${Y(g.footTop) + 4}" x2="${X(g.heelD)}" y2="${Y(g.footTop) + 40}" stroke="${MUTED}" stroke-dasharray="3 3"/>`, text(X(g.heelD), Y(g.footTop) + 52, '뒷굽 끝', 'middle', MUTED, 10));
|
||||
const onFace = (p: WingBarPath) => {
|
||||
const dep = p.pts.map(([s, t]) => f.depth(s, t));
|
||||
const avg = dep.reduce((a, b) => a + b, 0) / dep.length;
|
||||
return face === 'front' ? avg < f.T / 2 : avg >= f.T / 2;
|
||||
};
|
||||
const show: WingMark[] = face === 'front' ? ['WR11', 'WR12', 'WR13', 'WR14', 'WR15', 'WR6'] : ['WR1', 'WR2', 'WR3', 'WR4', 'WR5', 'WR6', 'WR9'];
|
||||
for (const p of r.paths) {
|
||||
if (!show.includes(p.mark) || !onFace(p)) continue;
|
||||
const pts = p.pts.map(([s, t, z]) => `${X(f.d(s, t)).toFixed(1)},${Y(z).toFixed(1)}`).join(' ');
|
||||
const w = ['WR1', 'WR2', 'WR11', 'WR12'].includes(p.mark) ? 0.8 : 1;
|
||||
parts.push(`<polyline points="${pts}" fill="none" stroke="${COLOR(p.mark)}" stroke-width="${w}"${DASH[p.mark] ? ` stroke-dasharray="${DASH[p.mark]}"` : ''} opacity="${opacity(pick, [p.mark])}"/>`);
|
||||
}
|
||||
const v = r.vertical[face];
|
||||
parts.push(text(X(g.L / 2), Y(g.footTop) + 70, `${face === 'front' ? '전면' : '배면'} 연직 (끝 → 교대) ${v.text}`, 'middle', MUTED, 10));
|
||||
parts.push(text(X(g.L) + 6, Y(zTopMax - 400), `${r.levels[face].end}단`, 'start', MUTED, 10), text(X(g.wallBackD) + 6, Y(g.topStart - 400), `${r.levels[face].start}단`, 'start', MUTED, 10));
|
||||
const vm = face === 'front' ? ['WR12', 'WR11'] : ['WR2', 'WR1'];
|
||||
const hm = face === 'front' ? ['WR13', 'WR14', 'WR15'] : ['WR3', 'WR4', 'WR5'];
|
||||
const top = 22;
|
||||
parts.push(balloon(X(g.heelD - 300), top, vm[0], C.vertical, pick), balloon(X(g.heelD + 1500), top, vm[1], C.vertical, pick));
|
||||
parts.push(balloon(X(g.L) + 30, Y(zTopMax - 200), hm[0], C.horizontal, pick), balloon(X(g.L) + 30, Y(g.seat + 400), hm[1], C.horizontal, pick), balloon(X(g.L) + 30, Y(g.footTop + 600), hm[2], C.horizontal, pick));
|
||||
if (g.form === 'general') parts.push(balloon(X(g.L - 800), Y(g.bottomEnd) + 40, 'WR6', C.other, pick));
|
||||
if (face === 'back' && g.haunch > 0) parts.push(balloon(X(g.wallBackD) - 4, top, 'WR9', C.haunch, pick));
|
||||
const W = X(g.L) + 70;
|
||||
const H = Y(g.zB68) + 80;
|
||||
return `<svg viewBox="0 0 ${W.toFixed(0)} ${H.toFixed(0)}" width="100%" style="max-width:${Math.round(W * 1.2)}px" role="img" aria-label="날개벽 ${face === 'front' ? '전면도' : '배면도'}">${parts.join('')}</svg>`;
|
||||
}
|
||||
|
||||
/** 헌치 · 모서리 평면 (교대 s · t, 날개벽이 교대와 만나는 모서리 둘레): 헌치 WR7-1 · 7-2 · 7-3과 WR10의 띠별 한 단, WR9 점. */
|
||||
export function abutWingCornerSvg(g: AbutWingGeom, r: AbutWingRebar, pick: AbutRebarPick = NO_PICK): string {
|
||||
const f = frame(g);
|
||||
// 보는 범위: 모서리 둘레 ± 3 m.
|
||||
const cornerT = g.wallBackT;
|
||||
const sC = g.p0[0] + g.inward[0];
|
||||
const span = 3200;
|
||||
const sMin = sC - span;
|
||||
const sMax = sC + span;
|
||||
const tMin = cornerT - span;
|
||||
const tMax = Math.min(g.wallFrontT, cornerT + span);
|
||||
const k = 0.12;
|
||||
const flip = g.inward[0] < 0 ? -1 : 1; // 날개벽이 왼쪽에 오도록
|
||||
const X = (s: number) => 40 + (flip > 0 ? s - sMin : sMax - s) * k;
|
||||
const Y = (t: number) => 30 + (tMax - t) * k;
|
||||
const parts: string[] = [];
|
||||
// 교대 (벽체 · 흉벽 배면 위쪽이 교대) · 날개벽 띠.
|
||||
parts.push(`<rect x="${Math.min(X(sMin), X(sMax))}" y="${Y(tMax)}" width="${(sMax - sMin) * k}" height="${(tMax - g.wallBackT) * k}" fill="${WALL}" stroke="${MUTED}"/>`);
|
||||
parts.push(`<line x1="${Math.min(X(sMin), X(sMax))}" y1="${Y(g.parapetBackT)}" x2="${Math.max(X(sMin), X(sMax))}" y2="${Y(g.parapetBackT)}" stroke="${MUTED}" stroke-dasharray="4 3"/>`);
|
||||
const P = (d: number, dep: number) => {
|
||||
const [ws, wt] = g.w;
|
||||
const iw = g.inward[0] * ws + g.inward[1] * wt;
|
||||
const n = [(g.inward[0] - iw * ws) / f.T, (g.inward[1] - iw * wt) / f.T];
|
||||
return [g.p0[0] + d * ws + dep * n[0], g.p0[1] + d * wt + dep * n[1]];
|
||||
};
|
||||
const dEnd = Math.min(g.L, (cornerT - tMin) / Math.abs(g.w[1]));
|
||||
const wing = [P(0, 0), P(dEnd, 0), P(dEnd, f.T), P(0, f.T)];
|
||||
parts.push(`<polygon points="${wing.map(([s, t]) => `${X(s).toFixed(1)},${Y(t).toFixed(1)}`).join(' ')}" fill="${CONC}" stroke="${MUTED}"/>`);
|
||||
parts.push(text(X(P(dEnd * 0.7, f.T / 2)[0]), Y(P(dEnd * 0.7, f.T / 2)[1]), '날개벽', 'middle', MUTED, 10));
|
||||
parts.push(text(Math.min(X(sMin), X(sMax)) + 6, Y(g.wallBackT) - 6, '벽체 배면 (흉벽 배면 점선)', 'start', MUTED, 10));
|
||||
// 한 단씩: 각 띠의 첫 단, WR10 첫 단 · 흉벽 구간 첫 단.
|
||||
const firstOf = (mark: WingMark) => r.paths.find((p) => p.mark === mark);
|
||||
const wr10Up = r.paths.find((p) => p.mark === 'WR10' && p.pts[0][2] > g.bracketTop);
|
||||
for (const p of [firstOf('WR7-1'), firstOf('WR7-2'), firstOf('WR7-3'), firstOf('WR10'), wr10Up, firstOf('WR3'), firstOf('WR13')]) {
|
||||
if (!p) continue;
|
||||
const pts = p.pts.map(([s, t]) => `${X(s).toFixed(1)},${Y(t).toFixed(1)}`).join(' ');
|
||||
parts.push(`<polyline points="${pts}" fill="none" stroke="${COLOR(p.mark)}" stroke-width="1.6"${DASH[p.mark] ? ` stroke-dasharray="${DASH[p.mark]}"` : ''} opacity="${opacity(pick, [p.mark])}"/>`);
|
||||
}
|
||||
for (const p of r.paths.filter((q) => q.mark === 'WR9')) parts.push(`<circle cx="${X(p.pts[1][0]).toFixed(1)}" cy="${Y(p.pts[1][1]).toFixed(1)}" r="3" fill="${C.haunch}" opacity="${opacity(pick, ['WR9'])}"/>`);
|
||||
const wr7 = firstOf('WR7-3');
|
||||
const at = wr7 ? [X(wr7.pts[2][0]), Y(wr7.pts[2][1])] : [X(sC), Y(cornerT)];
|
||||
parts.push(balloon(at[0] + 40, at[1] + 40, 'WR7', C.haunch, pick), balloon(at[0] + 80, at[1] + 40, 'WR10', C.haunch, pick), balloon(at[0] + 120, at[1] + 40, 'WR9', C.haunch, pick));
|
||||
parts.push(text(40, Y(tMin) + 18, `헌치 ${Math.round(g.haunch)} · WR7 띠: 위 ${r.haunchLevels['WR7-1']} · 가운데 ${r.haunchLevels['WR7-2']} · 아래 ${r.haunchLevels['WR7-3']}단 · 날개벽 두께 ${Math.round(f.T)}, 수평철근 사이 WR20`, 'start', MUTED, 10));
|
||||
const W = (sMax - sMin) * k + 80;
|
||||
const H = Y(tMin) + 30;
|
||||
return `<svg viewBox="0 0 ${W.toFixed(0)} ${H.toFixed(0)}" width="100%" style="max-width:${Math.round(W * 1.3)}px" role="img" aria-label="날개벽 헌치 · 모서리 평면">${parts.join('')}</svg>`;
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
/**
|
||||
* 교대 날개벽 삽도 (2026-11-04, 시안 ② 날개벽 카드): 평면(좌우 날개벽과 접속슬래브), 측면(날개벽 축 방향), 날개벽–흉벽 모서리 평면.
|
||||
* 순수 문자열 함수. 평면·측면은 생성본 값으로 그리고 가로·세로 축척이 같다. 모서리 상세는 치수를 나타내는 모식도다.
|
||||
*/
|
||||
import type { AbutPlanOutline } from './abut-scene';
|
||||
|
||||
const INK = '#18212b';
|
||||
const MUTED = '#55606e';
|
||||
const WING = '#d6bb8c';
|
||||
const WALL = '#b7c7d3';
|
||||
const SLAB = '#f3eee4';
|
||||
const text = (x: number, y: number, s: string, anchor = 'start', fill = INK) =>
|
||||
`<text x="${x.toFixed(1)}" y="${y.toFixed(1)}" text-anchor="${anchor}" font-size="11" fill="${fill}" font-family="JetBrains Mono, monospace">${s}</text>`;
|
||||
const poly = (pts: [number, number][], style: string) => `<polygon points="${pts.map(([x, y]) => `${x.toFixed(1)},${y.toFixed(1)}`).join(' ')}" ${style}/>`;
|
||||
|
||||
export interface AbutWingPlanDrawing {
|
||||
lengthM: number;
|
||||
outline: AbutPlanOutline;
|
||||
/** 좌 = ① 쪽 끝 (sEnd), 우 = 반대 끝. 이름에 형식을 붙여 표시한다. */
|
||||
leftLabel: string;
|
||||
rightLabel: string;
|
||||
leftSEnd: number;
|
||||
slabWidthM: number;
|
||||
}
|
||||
|
||||
/** 평면: 벽체(skew), 좌우 날개벽, 그 사이 접속슬래브. 왼쪽이 ① 쪽(좌), 위가 전면. */
|
||||
export function abutWingPlanSvg(d: AbutWingPlanDrawing): string {
|
||||
const pts = [...d.outline.wall, ...d.outline.slab, ...d.outline.wings.flatMap((w) => w.poly)];
|
||||
// ① 쪽(좌)을 왼쪽에 둔다: 좌가 s = L이면 s를 뒤집는다.
|
||||
const flip = d.leftSEnd !== 0;
|
||||
const xs = pts.map(([s]) => (flip ? -s : s));
|
||||
const ts = pts.map(([, t]) => t);
|
||||
const k = 30;
|
||||
const xMin = Math.min(...xs) - 0.6;
|
||||
const tMax = Math.max(...ts) + 0.4;
|
||||
const X = (s: number) => 20 + ((flip ? -s : s) - xMin) * k;
|
||||
const Y = (t: number) => 18 + (tMax - t) * k;
|
||||
const W = 40 + (Math.max(...xs) - xMin + 0.6) * k;
|
||||
const H = Y(Math.min(...ts) - 0.4) + 30;
|
||||
const map = (p: [number, number][]) => p.map(([s, t]) => [X(s), Y(t)] as [number, number]);
|
||||
const parts = [
|
||||
poly(map(d.outline.slab), `fill="${SLAB}" stroke="#b49a6b" stroke-dasharray="5 3"`),
|
||||
poly(map(d.outline.wall), `fill="${WALL}" stroke="${MUTED}"`),
|
||||
...d.outline.wings.map((w) => poly(map(w.poly), `fill="${WING}" stroke="#9c8763"`)),
|
||||
];
|
||||
const slabC = d.outline.slab.reduce((a, [s, t]) => [a[0] + s / d.outline.slab.length, a[1] + t / d.outline.slab.length], [0, 0]);
|
||||
parts.push(text(X(slabC[0]), Y(slabC[1]), `접속슬래브 폭 ${d.slabWidthM.toFixed(3)} m`, 'middle'));
|
||||
for (const w of d.outline.wings) {
|
||||
const far = w.poly[1];
|
||||
parts.push(text(X(far[0]), Y(far[1]) + 16, w.side === 'left' ? d.leftLabel : d.rightLabel, 'middle'));
|
||||
}
|
||||
parts.push(text(X(d.lengthM / 2), 12, '흉벽 · 벽체 (위 = 전면)', 'middle', MUTED));
|
||||
return [
|
||||
`<svg viewBox="0 0 ${W.toFixed(0)} ${H.toFixed(0)}" width="100%" role="img" aria-label="평면: ${d.leftLabel}, ${d.rightLabel}, 접속슬래브 폭 ${d.slabWidthM.toFixed(3)} m">`,
|
||||
...parts,
|
||||
'</svg>',
|
||||
].join('');
|
||||
}
|
||||
|
||||
export interface AbutWingSideDrawing {
|
||||
name: string;
|
||||
form: 'general' | 'short';
|
||||
lengthM: number;
|
||||
endHeightM: number;
|
||||
heelDistanceM: number;
|
||||
topLine: { dM: number; el: number }[];
|
||||
bottomLine: { dM: number; el: number }[];
|
||||
bands: { name: string; zTopEl: number; zBottomEl: number }[];
|
||||
seatEl: number;
|
||||
footingTopEl: number;
|
||||
/** 흉벽 상단 EL at this wing's end, and the 브라켓 높이 (m): 브라켓 상면 = 흉벽 상단 − 브라켓 높이. */
|
||||
parapetTopEl: number;
|
||||
bracketHeightM: number;
|
||||
/** 단면 위치 t (m, 전면 +): 벽체 배면 · 흉벽 배면 · 벽체 전면, 저판 앞굽 끝 · 뒷굽 끝, 저판 두께. */
|
||||
wallBackM: number;
|
||||
parapetBackM: number;
|
||||
wallFrontM: number;
|
||||
footingFrontM: number;
|
||||
footingBackM: number;
|
||||
footingThicknessM: number;
|
||||
}
|
||||
|
||||
/** EL 표기 (2026-11-04): "EL.41.995". */
|
||||
const el = (v: number) => `EL.${v.toFixed(3)}`;
|
||||
|
||||
/**
|
||||
* 측면 (날개벽 축 방향으로 잘라 본 모습): 벽체 쪽이 오른쪽, 날개벽 끝이 왼쪽.
|
||||
* 벽체 · 흉벽 · 브라켓 단차와 저판을 단면으로 함께 그린다. 가로 위치는 t를 날개벽 거리로 바꾼 값(dM = −t / k, k = 뒷굽 / 뒷굽 거리).
|
||||
*/
|
||||
/** 치수선 (2026-11-04): 점 a, b에서 치수선 위치 at(가로면 y, 세로면 x)까지 보조선, 치수선, 양 끝 사선 틱, 치수. after = 치수를 선의 아래 · 오른쪽에. */
|
||||
export function dimLine(a: [number, number], b: [number, number], at: number, dir: 'h' | 'v', label: string, after = false): string {
|
||||
const line = (x1: number, y1: number, x2: number, y2: number) =>
|
||||
`<line x1="${x1.toFixed(1)}" y1="${y1.toFixed(1)}" x2="${x2.toFixed(1)}" y2="${y2.toFixed(1)}" stroke="${INK}" stroke-width="0.8"/>`;
|
||||
const tick = (x: number, y: number) => line(x - 3, y + 3, x + 3, y - 3);
|
||||
if (dir === 'h') {
|
||||
const ext = (x: number, y: number) => line(x, y + Math.sign(at - y) * 3, x, at + Math.sign(at - y) * 3);
|
||||
const lab = text((a[0] + b[0]) / 2, after ? at + 13 : at - 4, label, 'middle');
|
||||
return [ext(a[0], a[1]), ext(b[0], b[1]), line(a[0], at, b[0], at), tick(a[0], at), tick(b[0], at), lab].join('');
|
||||
}
|
||||
const ext = (x: number, y: number) => line(x + Math.sign(at - x) * 3, y, at + Math.sign(at - x) * 3, y);
|
||||
const lab = after ? text(at + 4, (a[1] + b[1]) / 2 + 4, label, 'start') : text(at - 4, (a[1] + b[1]) / 2 + 4, label, 'end');
|
||||
return [ext(a[0], a[1]), ext(b[0], b[1]), line(at, a[1], at, b[1]), tick(at, a[1]), tick(at, b[1]), lab].join('');
|
||||
}
|
||||
|
||||
export function abutWingSideSvg(d: AbutWingSideDrawing): string {
|
||||
const k = 40;
|
||||
const kt = Math.abs(d.footingBackM) / d.heelDistanceM; // |t| per m along the wing
|
||||
const dmOf = (tM: number) => -tM / kt;
|
||||
const bracketTop = d.parapetTopEl - d.bracketHeightM;
|
||||
const footingBottom = d.footingTopEl - d.footingThicknessM;
|
||||
const els = [...d.topLine.map((p) => p.el), ...d.bottomLine.map((p) => p.el), d.parapetTopEl, footingBottom];
|
||||
const elMax = Math.max(...els) + 0.8;
|
||||
const elMin = Math.min(...els) - 0.5;
|
||||
const dmRight = dmOf(d.footingFrontM); // 저판 앞굽 끝 (rightmost)
|
||||
const X = (dM: number) => 40 + (d.lengthM - dM) * k; // wall (dM 0) toward the right
|
||||
const Y = (y: number) => 14 + (elMax - y) * k;
|
||||
const W = X(dmRight) + 110;
|
||||
const H = Y(elMin) + 34;
|
||||
const rect = (dm0: number, dm1: number, e0: number, e1: number, style: string) =>
|
||||
poly([[X(dm0), Y(e0)], [X(dm1), Y(e0)], [X(dm1), Y(e1)], [X(dm0), Y(e1)]], style);
|
||||
const outline: [number, number][] = [
|
||||
...d.topLine.map((p) => [X(p.dM), Y(p.el)] as [number, number]),
|
||||
...[...d.bottomLine].reverse().map((p) => [X(p.dM), Y(p.el)] as [number, number]),
|
||||
];
|
||||
const wb = dmOf(d.wallBackM);
|
||||
const pb = dmOf(d.parapetBackM);
|
||||
const wf = dmOf(d.wallFrontM);
|
||||
const parts = [
|
||||
poly(outline, `fill="${WING}" stroke="#9c8763"`),
|
||||
// 저판 (뒷굽 끝 ~ 앞굽 끝), 벽체 (기초 상면 ~ 교좌면), 브라켓 단차 (벽체 배면 ~ 흉벽 배면, 브라켓 상면까지), 흉벽 (흉벽 상단까지).
|
||||
rect(dmOf(d.footingBackM), dmRight, footingBottom, d.footingTopEl, `fill="#c9d1da" stroke="${MUTED}"`),
|
||||
rect(wb, wf, d.footingTopEl, d.seatEl, `fill="${WALL}" stroke="${MUTED}"`),
|
||||
rect(wb, pb, d.seatEl, bracketTop, `fill="#9fb1c0" stroke="${MUTED}"`),
|
||||
rect(pb, 0, d.seatEl, d.parapetTopEl, `fill="#9fb1c0" stroke="${MUTED}"`),
|
||||
];
|
||||
// 수평 단 경계 (등단면 / 변화단면) as dashed lines across the wing.
|
||||
for (const b of d.bands.slice(0, -1)) {
|
||||
parts.push(`<line x1="${X(d.lengthM).toFixed(1)}" y1="${Y(b.zBottomEl).toFixed(1)}" x2="${X(wb).toFixed(1)}" y2="${Y(b.zBottomEl).toFixed(1)}" stroke="${MUTED}" stroke-dasharray="4 3"/>`);
|
||||
}
|
||||
for (const b of d.bands) {
|
||||
const mid = (b.zTopEl + b.zBottomEl) / 2;
|
||||
parts.push(text(X(d.lengthM * 0.55), Y(mid) + 4, b.name, 'middle', MUTED));
|
||||
}
|
||||
// 치수선: 길이 (날개벽 끝 ~ 교좌면 끝), 끝단 높이 (일반형), 뒷굽 끝 (교좌면 끝 ~ 뒷굽 끝).
|
||||
const endTop = d.topLine[d.topLine.length - 1].el;
|
||||
const endBottom = d.bottomLine[d.bottomLine.length - 1].el;
|
||||
parts.push(dimLine([X(d.lengthM), Y(endTop)], [X(0), Y(d.parapetTopEl)], Y(elMax) + 12, 'h', `${Math.round(d.lengthM * 1000)} (교좌면 끝에서, 날개벽 축 방향)`));
|
||||
if (d.form === 'general') parts.push(dimLine([X(d.lengthM), Y(endTop)], [X(d.lengthM), Y(endBottom)], X(d.lengthM) - 12, 'v', `${Math.round(d.endHeightM * 1000)}`));
|
||||
parts.push(text(X(0) + 4, Y(d.parapetTopEl) + 4, `흉벽 상단 ${el(d.parapetTopEl)}`, 'start'));
|
||||
parts.push(text(X(pb) + 4, Y(bracketTop) - 4, `브라켓 상면 ${el(bracketTop)}`, 'start'));
|
||||
parts.push(text(X(wf) + 4, Y(d.seatEl) + 4, `교좌면 ${el(d.seatEl)}`, 'start'));
|
||||
parts.push(text(X(dmRight) + 4, Y(d.footingTopEl) + 4, el(d.footingTopEl), 'start'));
|
||||
parts.push(text(X(dmRight) + 4, Y(footingBottom) + 4, `기초 하단 ${el(footingBottom)}`, 'start'));
|
||||
parts.push(dimLine([X(0), Y(footingBottom)], [X(d.heelDistanceM), Y(footingBottom)], Y(footingBottom) + 18, 'h', ''));
|
||||
parts.push(text((X(0) + X(d.heelDistanceM)) / 2, Y(footingBottom) + 31, `뒷굽 끝 ${d.heelDistanceM.toFixed(3)} m`, 'middle'));
|
||||
return [
|
||||
`<svg viewBox="0 0 ${W.toFixed(0)} ${H.toFixed(0)}" width="100%" role="img" aria-label="${d.name} 측면: 길이 ${Math.round(d.lengthM * 1000)}, 흉벽 상단 ${el(d.parapetTopEl)}, 브라켓 상면 ${el(bracketTop)}, 뒷굽 끝 ${d.heelDistanceM.toFixed(3)} m">`,
|
||||
...parts,
|
||||
'</svg>',
|
||||
].join('');
|
||||
}
|
||||
|
||||
export interface AbutWingCornerDrawing {
|
||||
parapetMm: number;
|
||||
bracketMm: number;
|
||||
wingThicknessMm: number;
|
||||
haunchMm: number;
|
||||
/** 접속슬래브 간격: 날개벽과 20, 흉벽과 5. 모따기 300 × 300 (spec §4.8). */
|
||||
slabGapWingMm: number;
|
||||
slabGapParapetMm: number;
|
||||
chamferMm: number;
|
||||
}
|
||||
|
||||
/** 좌 날개벽–흉벽 모서리 평면 (모식도): 흉벽, 브라켓(슬래브 아래), 날개벽, 헌치, 접속슬래브(모따기, 간격). */
|
||||
export function abutWingCornerSvg(d: AbutWingCornerDrawing): string {
|
||||
const k = 0.14; // px per mm
|
||||
const x0 = 70; // 날개벽 내측면
|
||||
const y0 = 60; // 흉벽 배면
|
||||
const X = (mm: number) => x0 + mm * k;
|
||||
const Y = (mm: number) => y0 + mm * k;
|
||||
const span = 2200; // drawn extent from the corner (mm)
|
||||
const h = d.haunchMm;
|
||||
const c = d.chamferMm;
|
||||
const sx = d.slabGapWingMm;
|
||||
const sy = d.slabGapParapetMm;
|
||||
const parts = [
|
||||
// 흉벽 (above the 배면 line) and the 교대축 at its front face.
|
||||
poly([[X(-d.wingThicknessMm), Y(-d.parapetMm)], [X(span), Y(-d.parapetMm)], [X(span), Y(0)], [X(-d.wingThicknessMm), Y(0)]], `fill="${WALL}" stroke="${MUTED}"`),
|
||||
`<line x1="${X(-d.wingThicknessMm - 200).toFixed(1)}" y1="${Y(-d.parapetMm).toFixed(1)}" x2="${X(span).toFixed(1)}" y2="${Y(-d.parapetMm).toFixed(1)}" stroke="#c0392b" stroke-dasharray="8 3 2 3"/>`,
|
||||
// 브라켓 (under the slab, dashed).
|
||||
`<rect x="${X(0).toFixed(1)}" y="${Y(0).toFixed(1)}" width="${(span * k).toFixed(1)}" height="${(d.bracketMm * k).toFixed(1)}" fill="none" stroke="${MUTED}" stroke-dasharray="4 3"/>`,
|
||||
// 날개벽 (left, running back = down).
|
||||
poly([[X(-d.wingThicknessMm), Y(0)], [X(0), Y(0)], [X(0), Y(span)], [X(-d.wingThicknessMm), Y(span)]], `fill="${WING}" stroke="#9c8763"`),
|
||||
];
|
||||
if (h > 0) parts.push(poly([[X(0), Y(0)], [X(h), Y(0)], [X(0), Y(h)]], `fill="${WING}" stroke="#9c8763"`));
|
||||
// 접속슬래브 with the corner cut (모따기) and the gaps.
|
||||
parts.push(
|
||||
poly(
|
||||
[[X(sx + c), Y(sy)], [X(span), Y(sy)], [X(span), Y(span)], [X(sx), Y(span)], [X(sx), Y(sy + c)]],
|
||||
`fill="${SLAB}" stroke="#b49a6b"`,
|
||||
),
|
||||
);
|
||||
parts.push(text(X(-d.wingThicknessMm / 2), Y(span) - 6, `날개벽 ${d.wingThicknessMm}`, 'middle'));
|
||||
parts.push(text(X(span / 2), Y(-d.parapetMm / 2) + 4, `흉벽 ${d.parapetMm}`, 'middle'));
|
||||
parts.push(text(X(span) - 4, Y(-d.parapetMm) - 6, '교대축 (흉벽 전면)', 'end', '#9b2c1f'));
|
||||
parts.push(text(X(span) - 4, Y(d.bracketMm) - 4, `브라켓 ${d.bracketMm} (슬래브 아래)`, 'end', MUTED));
|
||||
if (h > 0) parts.push(text(X(h) + 6, Y(h / 2) + 4, `헌치 ${h} × ${h}`, 'start'));
|
||||
parts.push(text(X(sx + c) + 8, Y(sy + c) + 14, `슬래브 모따기 ${c} × ${c}`, 'start'));
|
||||
parts.push(text(X(span / 2), Y(span) - 6, `접속슬래브 · 날개벽과 ${sx} · 흉벽과 ${sy}`, 'middle'));
|
||||
const W = X(span) + 20;
|
||||
const H = Y(span) + 16;
|
||||
return [
|
||||
`<svg viewBox="0 0 ${W.toFixed(0)} ${H.toFixed(0)}" width="100%" role="img" aria-label="날개벽–흉벽 모서리: 헌치 ${h} × ${h}, 브라켓 ${d.bracketMm}, 슬래브 모따기 ${c} × ${c}, 간격 ${sx} / ${sy}">`,
|
||||
...parts,
|
||||
'</svg>',
|
||||
].join('');
|
||||
}
|
||||
@@ -1,19 +1,13 @@
|
||||
import type { PierModel } from '../core/geometry';
|
||||
import { bearingEdgeDistances, pairEccentricities } from '../core/bearing-layout';
|
||||
|
||||
/** Drawing values (A, B of the solplate, mm) of the front and back bearings, to overlay on the model (승인 2026-10-26). */
|
||||
export interface BearingPlanOverlay {
|
||||
front: { A: number; B: number };
|
||||
back: { A: number; B: number };
|
||||
}
|
||||
import { BLOCK_DEPTH_MM } from '../core/bearing-table';
|
||||
|
||||
/** Number of pairs the 상세 보기 chips can show (①–⑩ in the mockup). */
|
||||
export const PAIR_CHIP_LABELS = ['①', '②', '③', '④', '⑤', '⑥', '⑦', '⑧', '⑨', '⑩'];
|
||||
|
||||
/**
|
||||
* 교량받침 상세 · 평면 for one pair (승인 2026-10-26): the bearings keep their actual 교각축 positions (the offset
|
||||
* between the two bearings is shown as it is), and the blocks and solplates follow them. The model's solplates are solid, the drawing values
|
||||
* dashed. 일체 draws one 2500 × 1200 outline; 분리 draws two 1200 × 1200 outlines. Dimensions: C1 (bearing to block edge),
|
||||
* between the two bearings is shown as it is), and the blocks and solplates follow them. 일체 draws one 2500 × 1200 outline; 분리 draws two 1200 × 1200 outlines. Dimensions: C1 (bearing to block edge),
|
||||
* C2 (between the bearings), C3 (block span). C4 and C5 are the outer margins along the 교축.
|
||||
*/
|
||||
/**
|
||||
@@ -24,7 +18,7 @@ function rect(cx: number, cy: number, w: number, h: number, skewDeg: number, att
|
||||
return `<rect x="${cx - w / 2}" y="${cy - h / 2}" width="${w}" height="${h}" transform="rotate(${-skewDeg} ${cx} ${cy})" ${attrs}></rect>`;
|
||||
}
|
||||
|
||||
export function bearingPlanSvg(m: PierModel, pairIndex: number, overlay?: BearingPlanOverlay): string {
|
||||
export function bearingPlanSvg(m: PierModel, pairIndex: number): string {
|
||||
const pair = pairEccentricities(m.blocks)[pairIndex];
|
||||
if (!pair) return '';
|
||||
const f = m.blocks.find((b) => b.no === pair.frontNo && b.row === '전열')!;
|
||||
@@ -55,18 +49,9 @@ export function bearingPlanSvg(m: PierModel, pairIndex: number, overlay?: Bearin
|
||||
}
|
||||
}
|
||||
|
||||
// Solplates: the model's plan (A across the 교축, B along the 교각축), centred on each bearing.
|
||||
// Solplates: the pier's plan (L across the 교축, W along the 교각축), centred on each bearing.
|
||||
for (const b of [f, k]) {
|
||||
if (b.plan) {
|
||||
const { A, B } = b.plan;
|
||||
parts.push(rect(X(b.n), Y(b.s), A, B, b.rowSkewDeg, `fill="#EEF3F7" stroke="#2F6DB5" stroke-width="10"`));
|
||||
}
|
||||
}
|
||||
// Drawing values overlaid (dashed) on the same bearings (#1 겹쳐 보이게).
|
||||
if (overlay) {
|
||||
for (const [b, v] of [[f, overlay.front], [k, overlay.back]] as const) {
|
||||
parts.push(rect(X(b.n), Y(b.s), v.A, v.B, b.rowSkewDeg, `fill="none" stroke="#B4531F" stroke-width="10" stroke-dasharray="40 25"`));
|
||||
}
|
||||
parts.push(rect(X(b.n), Y(b.s), b.plan.L, b.plan.W, b.rowSkewDeg, `fill="#EEF3F7" stroke="#2F6DB5" stroke-width="10"`));
|
||||
}
|
||||
|
||||
// Bearing centres and numbers.
|
||||
@@ -104,6 +89,8 @@ export function bearingPlanSvg(m: PierModel, pairIndex: number, overlay?: Bearin
|
||||
const highEdge = ib ? Y(ib.s) + 600 : Y(k.s) + 600;
|
||||
parts.push(vdim(lowEdge, Y(f.s), `C4 ${Math.round(d(f.no).leftMm)}`));
|
||||
parts.push(vdim(Y(k.s), highEdge, `C5 ${Math.round(d(k.no).rightMm)}`));
|
||||
// C6: block depth along the 교각축 (1200 for 분리 and 일체 alike).
|
||||
parts.push(`<text x="${xRight + 60}" y="${highEdge + 110}" font-size="90" fill="#18212B">C6 ${BLOCK_DEPTH_MM} (교각축 방향 블록 폭)</text>`);
|
||||
|
||||
return `<svg viewBox="-1900 -1300 3800 2600" width="100%" style="max-width: 560px; height: auto" role="img" aria-label="교량받침 상세 평면: 쌍 ${pair.frontNo}·${pair.backNo}, 블록 한 줄, 교축·교각축 방향, C1·C2·C3, C4·C5, 도면값 겹쳐 보기">${parts.join('')}</svg>`;
|
||||
return `<svg viewBox="-1900 -1300 3800 2600" width="100%" style="max-width: 560px; height: auto" role="img" aria-label="교량받침 삽도 평면: 쌍 ${pair.frontNo}·${pair.backNo}, 교축·교각축 방향, C1–C6">${parts.join('')}</svg>`;
|
||||
}
|
||||
@@ -1,27 +1,13 @@
|
||||
import type { PierModel } from '../core/geometry';
|
||||
import { pairEccentricities } from '../core/bearing-layout';
|
||||
import { pairCopingSpans } from '../core/bearing-table';
|
||||
|
||||
/**
|
||||
* Drawing values of one bearing (도면값), overlaid as dashed outlines (승인 2026-10-26).
|
||||
* All lengths in mm; elx in m.
|
||||
* 교량받침 삽도 · 단면 (elevation) for one pair (승인 2026-10-26, 모델값 2026-11-03): the model's blocks, mortar, bearing
|
||||
* body and solplate per bearing. x is the 교축 (n), y the EL (up). Labels per bearing: EL.X (mortar top), h2 · h3 · h4 · E,
|
||||
* h1 (block). Below: S1 (bearing centre to the coping edge), S2 (between the bearings), S1, and the coping width B.
|
||||
*/
|
||||
export interface BearingDrawingRef {
|
||||
elx?: number;
|
||||
h1: number;
|
||||
h2: number;
|
||||
h3: number;
|
||||
h4: number;
|
||||
E: number;
|
||||
A: number;
|
||||
B: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 교량받침 상세 · 단면 (elevation) for one pair (승인 2026-10-26): the model's blocks, mortar, bearing body,
|
||||
* and solplate per bearing, with the drawing heights overlaid as dashed outlines. x is the 교축 (n), y the EL (up).
|
||||
* Labels: EL.X (mortar top), h1 (block), h2 (mortar), h3 (bearing), h4 (solplate), E = h1 + h2 + h3 + h4.
|
||||
*/
|
||||
export function bearingSectionSvg(m: PierModel, pairIndex: number, refs?: { front?: BearingDrawingRef; back?: BearingDrawingRef }): string {
|
||||
export function bearingSectionSvg(m: PierModel, pairIndex: number): string {
|
||||
const pair = pairEccentricities(m.blocks)[pairIndex];
|
||||
if (!pair) return '';
|
||||
const f = m.blocks.find((b) => b.no === pair.frontNo && b.row === '전열')!;
|
||||
@@ -46,37 +32,38 @@ export function bearingSectionSvg(m: PierModel, pairIndex: number, refs?: { fron
|
||||
}
|
||||
|
||||
// Mortar, bearing body and solplate per bearing, from the model.
|
||||
for (const [b, ref] of [[f, refs?.front], [k, refs?.back]] as const) {
|
||||
for (const b of [f, k]) {
|
||||
const pad = 1000;
|
||||
parts.push(`<rect x="${X(b.n) - pad / 2}" y="${Y(b.padTop)}" width="${pad}" height="${50}" fill="#B7C7D3" stroke="#6E8496" stroke-width="6"></rect>`);
|
||||
if (b.plan) {
|
||||
const { A, margin, h3, T } = b.plan;
|
||||
const L = A + margin;
|
||||
const bodyTop = b.padTop + h3 / 1000;
|
||||
parts.push(`<rect x="${X(b.n) - (L - 200) / 2}" y="${Y(bodyTop)}" width="${L - 200}" height="${h3}" fill="#8898AA" stroke="#5B6676" stroke-width="6"></rect>`);
|
||||
const drop = (L * Math.abs(b.plan.S)) / 200;
|
||||
const t1 = T + drop;
|
||||
const t2 = T - drop;
|
||||
const x0 = X(b.n) - L / 2;
|
||||
const x1 = X(b.n) + L / 2;
|
||||
parts.push(`<polygon points="${x0},${Y(bodyTop)} ${x1},${Y(bodyTop)} ${x1},${Y(bodyTop + t2 / 1000)} ${x0},${Y(bodyTop + t1 / 1000)}" fill="#F3D9B8" stroke="#B4531F" stroke-width="6"></polygon>`);
|
||||
}
|
||||
if (ref) {
|
||||
// Drawing heights from the block bottom, dashed (overlay).
|
||||
const base = b.bottom;
|
||||
const dBlockTop = base + (ref.h1 ?? 0) / 1000;
|
||||
const dPad = dBlockTop + ref.h2 / 1000;
|
||||
const dBody = dPad + ref.h3 / 1000;
|
||||
const dSol = dBody + ref.h4 / 1000;
|
||||
parts.push(`<rect x="${X(b.n) - 600}" y="${Y(dBlockTop)}" width="1200" height="${(dBlockTop - base) * 1000}" fill="none" stroke="#B4531F" stroke-width="8" stroke-dasharray="40 25"></rect>`);
|
||||
parts.push(`<line x1="${X(b.n) - 700}" y1="${Y(dSol)}" x2="${X(b.n) + 700}" y2="${Y(dSol)}" stroke="#B4531F" stroke-width="8" stroke-dasharray="40 25"></line>`);
|
||||
parts.push(`<line x1="${X(b.n) - 700}" y1="${Y(dBody)}" x2="${X(b.n) + 700}" y2="${Y(dBody)}" stroke="#B4531F" stroke-width="8" stroke-dasharray="40 25"></line>`);
|
||||
}
|
||||
// EL.X at the mortar top, bearing number.
|
||||
const { L, h3, T, T1, T2 } = b.plan;
|
||||
const bodyTop = b.padTop + h3 / 1000;
|
||||
parts.push(`<rect x="${X(b.n) - (L - 200) / 2}" y="${Y(bodyTop)}" width="${L - 200}" height="${h3}" fill="#8898AA" stroke="#5B6676" stroke-width="6"></rect>`);
|
||||
const x0 = X(b.n) - L / 2;
|
||||
const x1 = X(b.n) + L / 2;
|
||||
parts.push(`<polygon points="${x0},${Y(bodyTop)} ${x1},${Y(bodyTop)} ${x1},${Y(bodyTop + T2 / 1000)} ${x0},${Y(bodyTop + T1 / 1000)}" fill="#F3D9B8" stroke="#B4531F" stroke-width="6"></polygon>`);
|
||||
// EL.X at the mortar top, bearing number, and the heights of this bearing.
|
||||
const E = Math.round((b.top - b.bottom) * 1000) + Math.round((b.padTop - b.top) * 1000) + h3 + T;
|
||||
parts.push(`<circle cx="${X(b.n)}" cy="${Y(b.padTop)}" r="30" fill="#18212B"></circle>`);
|
||||
parts.push(`<text x="${X(b.n) + 60}" y="${Y(b.padTop) - 60}" font-size="90" fill="#18212B">${b.no} · EL.X ${b.padTop.toFixed(3)}</text>`);
|
||||
parts.push(`<text x="${X(b.n) + 60}" y="${Y(b.padTop) - 260}" font-size="80" fill="#55606E">h2 ${Math.round((b.padTop - b.top) * 1000)} · h3 ${h3} · h4 ${T} · E ${E}</text>`);
|
||||
parts.push(`<text x="${X(b.n)}" y="${Y(b.bottom) + 160}" text-anchor="middle" font-size="90" fill="#55606E">h1 ${Math.round((b.top - b.bottom) * 1000)}</text>`);
|
||||
}
|
||||
return `<svg viewBox="-2000 -1200 4000 2000" width="100%" style="max-width: 560px; height: auto" role="img" aria-label="교량받침 상세 단면: 블록, 모르타르, 받침, 솔플레이트, EL.X, 도면 높이 점선">${parts.join('')}</svg>`;
|
||||
}
|
||||
|
||||
// S1 · S2 · S1 along 교축 below the blocks: bearing centre to the coping edge (S1), between the bearings (S2).
|
||||
const spans = pairCopingSpans(m, pairIndex);
|
||||
if (spans) {
|
||||
const lo = Math.min(f.n, k.n);
|
||||
const xLo = X(lo);
|
||||
const xHi = xLo + spans.S2;
|
||||
const xEdge0 = xLo - spans.S1Low;
|
||||
const xEdge1 = xHi + spans.S1High;
|
||||
const yDim = Math.max(Y(f.bottom), Y(k.bottom)) + 420;
|
||||
const dim = (x1: number, x2: number, label: string) =>
|
||||
`<line x1="${x1}" y1="${yDim}" x2="${x2}" y2="${yDim}" stroke="#55606E" stroke-width="6"></line><line x1="${x1}" y1="${yDim - 50}" x2="${x1}" y2="${yDim + 50}" stroke="#55606E" stroke-width="6"></line><line x1="${x2}" y1="${yDim - 50}" x2="${x2}" y2="${yDim + 50}" stroke="#55606E" stroke-width="6"></line><text x="${(x1 + x2) / 2}" y="${yDim + 110}" text-anchor="middle" font-size="90" fill="#18212B">${label}</text>`;
|
||||
parts.push(dim(xEdge0, xLo, `S1 ${spans.S1Low}`));
|
||||
parts.push(dim(xLo, xHi, `S2 ${spans.S2}`));
|
||||
parts.push(dim(xHi, xEdge1, `S1 ${spans.S1High}`));
|
||||
parts.push(`<text x="${(xEdge0 + xEdge1) / 2}" y="${yDim + 280}" text-anchor="middle" font-size="90" fill="#55606E">코핑 폭 B ${spans.copingWidth} (받침중심 ↔ 코핑 연단: S1 · S2)</text>`);
|
||||
}
|
||||
return `<svg viewBox="-2000 -1200 4000 2000" width="100%" style="max-width: 560px; height: auto" role="img" aria-label="교량받침 삽도 단면: 블록, 모르타르, 받침, 솔플레이트, EL.X, S1·S2 코핑 치수">${parts.join('')}</svg>`;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { PierModel } from '../core/geometry';
|
||||
import type { CastLayout } from '../core/rebar/cast-layout';
|
||||
|
||||
/** Front and section projections of the same centre lines used in the 3D scene. */
|
||||
export function castRebarSvg(model: PierModel, layout: CastLayout): string {
|
||||
const bars = layout.bars.filter(b => b.columnIndex === 0);
|
||||
if (!bars.length) return '<p>입력 오류를 확인하세요. 생성된 기둥·말뚝 철근이 없습니다.</p>';
|
||||
const pts = bars.flatMap(b => b.pts);
|
||||
const bottom = pts.reduce((a, p) => Math.min(a, p[1]), Infinity);
|
||||
const top = pts.reduce((a, p) => Math.max(a, p[1]), -Infinity);
|
||||
const diameter = model.columnDiameterMm / 1000;
|
||||
const scale = Math.min(180 / diameter, 480 / (top - bottom));
|
||||
const x = (v: number) => 130 + (v - model.columns[0].s) * scale;
|
||||
const y = (v: number) => 35 + (top - v) * scale;
|
||||
const colour = (kind: string) => kind === 'main' ? '#0f5c66' : kind === 'coupler' ? '#0b3f46' : '#6b3fa0';
|
||||
const front = bars.map(b => `<polyline points="${b.pts.map(p => `${x(p[0]).toFixed(2)},${y(p[1]).toFixed(2)}`).join(' ')}" fill="none" stroke="${colour(b.kind)}" stroke-width="${b.kind === 'coupler' ? 2 : 0.7}"/>`).join('');
|
||||
const sectionHeight = model.groundEl + Math.min(1, (model.columns[0].top - model.groundEl) / 2);
|
||||
const sectionScale = 140 / diameter;
|
||||
const section = bars.filter(b => b.kind === 'main' && b.pts[0][1] <= sectionHeight && b.pts.at(-1)![1] >= sectionHeight).map(b => {
|
||||
const p = b.pts[0];
|
||||
return `<circle cx="${365 + (p[0] - model.columns[0].s) * sectionScale}" cy="${160 + p[2] * sectionScale}" r="${Math.max(1, b.diameterMm / 2000 * sectionScale)}" fill="#0f5c66"/>`;
|
||||
}).join('');
|
||||
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 470 560" role="img" aria-label="첫 기둥 및 현장타설말뚝 배근 정면·기둥 단면" style="width:100%;max-width:560px">
|
||||
<text x="20" y="20" font-size="12">정면 · 실제 생성 경로</text>
|
||||
<rect x="${x(model.columns[0].s - diameter / 2)}" y="${y(top)}" width="${diameter * scale}" height="${(top - bottom) * scale}" fill="none" stroke="#d9d6cc"/>
|
||||
${front}<line x1="20" x2="245" y1="${y(model.groundEl)}" y2="${y(model.groundEl)}" stroke="#5b6878" stroke-dasharray="4 3"/>
|
||||
<text x="20" y="${y(model.groundEl) - 5}" font-size="11">지반고 EL ${model.groundEl.toFixed(3)}</text>
|
||||
<text x="285" y="40" font-size="12">기둥 주철근 단면</text><circle cx="365" cy="160" r="70" fill="none" stroke="#d9d6cc"/>${section}
|
||||
<text x="20" y="548" font-size="11">단부·저부 P6 미확정 사항은 알림 참조 · 길이 단위 mm</text></svg>`;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import * as THREE from 'three';
|
||||
|
||||
/**
|
||||
* Camera that contains a sphere (2026-11-03): keeps the view direction of `position → target`, moves the target to the
|
||||
* sphere centre, and backs off until the sphere fits both the vertical and the horizontal field of view.
|
||||
*/
|
||||
export function fitCamera(
|
||||
position: THREE.Vector3,
|
||||
target: THREE.Vector3,
|
||||
sphere: THREE.Sphere,
|
||||
fovDeg: number,
|
||||
aspect: number,
|
||||
margin = 1.1,
|
||||
): { position: THREE.Vector3; target: THREE.Vector3 } {
|
||||
const halfV = THREE.MathUtils.degToRad(fovDeg / 2);
|
||||
// The narrower of the two half-angles decides how far back the camera must stand.
|
||||
const halfH = Math.atan(Math.tan(halfV) * aspect);
|
||||
const halfFov = Math.min(halfV, halfH);
|
||||
const fit = (sphere.radius / Math.sin(halfFov)) * margin;
|
||||
const dir = position.clone().sub(target);
|
||||
const current = dir.length();
|
||||
const dist = Math.max(current, fit);
|
||||
dir.normalize();
|
||||
return {
|
||||
position: sphere.center.clone().add(dir.multiplyScalar(dist)),
|
||||
target: sphere.center.clone(),
|
||||
};
|
||||
}
|
||||
+132
-61
@@ -14,73 +14,15 @@ const TOP_PAD = 24;
|
||||
* Horizontal positions are schematic (no dimensions): piles are spread across the footing.
|
||||
*/
|
||||
export function groundSectionSvg(input: PierInput, model: PierModel): string {
|
||||
const rows = input.strataRows ?? [];
|
||||
const ground = input.groundEl;
|
||||
const footTop = Math.max(...model.footings.map((f) => f.top));
|
||||
const footBottom = Math.min(...model.footings.map((f) => f.bottom));
|
||||
const pileTips = model.piles.map((p) => p.bottom);
|
||||
const lowestRow = rows.length ? rows[rows.length - 1].bottomEl : ground - 1;
|
||||
// 하상: the 설계홍수위 (solid) and the 세굴선 replace the groundwater (dashed, 승인 2026-10-26).
|
||||
const position = input.groundPosition ?? '육상';
|
||||
const river = position === '하상';
|
||||
const sea = position === '해상';
|
||||
const level = river
|
||||
? (Number.isFinite(input.floodLevelEl) ? input.floodLevelEl : undefined)
|
||||
: sea
|
||||
? (Number.isFinite(input.highWaterEl) ? input.highWaterEl : undefined)
|
||||
: (input.groundWaterOn && Number.isFinite(input.groundWaterEl) ? input.groundWaterEl : undefined);
|
||||
// 해상: the 저조위 is drawn too (dashed), on the MSL datum.
|
||||
const lowLevel = sea && Number.isFinite(input.lowWaterEl) ? input.lowWaterEl : undefined;
|
||||
const groundLabel = sea ? '해저면 EL' : '지반고';
|
||||
const scour = river && Number.isFinite(input.scourDepthM) && (input.scourDepthM as number) >= 0 ? (input.scourDepthM as number) : undefined;
|
||||
const scourEl = scour !== undefined ? ground - scour : undefined;
|
||||
|
||||
const yMin = Math.min(footBottom, lowestRow, ...pileTips, level ?? ground, lowLevel ?? ground, scourEl ?? ground) - 1;
|
||||
const yMax = Math.max(ground, footTop, input.copingTopEl) + 0.5;
|
||||
const { yMin } = groundRange(input, [footBottom, ...pileTips]);
|
||||
const yMax = Math.max(input.groundEl, footTop, input.copingTopEl) + 0.5;
|
||||
const scale = PLOT_H / (yMax - yMin);
|
||||
const y = (el: number) => TOP_PAD + (yMax - el) * scale;
|
||||
const H = TOP_PAD + PLOT_H + 16;
|
||||
|
||||
const parts: string[] = [];
|
||||
// Layers: each band runs from the upper boundary (ground for the first) down to its 하단 EL.
|
||||
let upper = ground;
|
||||
rows.forEach((row, i) => {
|
||||
const top = y(upper);
|
||||
const bottom = y(row.bottomEl);
|
||||
parts.push(`<rect x="0" y="${top}" width="${WIDTH}" height="${bottom - top}" fill="${LAYER_FILLS[i % LAYER_FILLS.length]}"></rect>`);
|
||||
parts.push(`<text x="8" y="${(top + bottom) / 2 + 4}" font-size="12" fill="#18212B">${escapeText(row.name)}</text>`);
|
||||
if (row.name === SUPPORT_STRATUM) {
|
||||
parts.push(`<line x1="0" y1="${top}" x2="${WIDTH}" y2="${top}" stroke="#5B4A2E" stroke-width="2"></line>`);
|
||||
// Caption just under the line, so it does not collide with the label of the band above.
|
||||
parts.push(`<text x="8" y="${top + 14}" font-size="12" fill="#18212B">연암층 상면 EL ${upper.toFixed(3)}</text>`);
|
||||
}
|
||||
upper = row.bottomEl;
|
||||
});
|
||||
if (rows.length === 0 || upper > yMin) {
|
||||
parts.push(`<rect x="0" y="${y(upper)}" width="${WIDTH}" height="${y(yMin) - y(upper)}" fill="${LAYER_FILLS[rows.length % LAYER_FILLS.length]}"></rect>`);
|
||||
}
|
||||
|
||||
// Ground line and label.
|
||||
parts.push(`<line x1="0" y1="${y(ground)}" x2="${WIDTH}" y2="${y(ground)}" stroke="#8A6D3B" stroke-width="1.5"></line>`);
|
||||
parts.push(`<text x="8" y="${y(ground) - 6}" font-size="12" fill="#18212B">${groundLabel} ${ground.toFixed(3)}</text>`);
|
||||
|
||||
// Groundwater (육상, when on) as a dashed line; the 설계홍수위 (하상) as a solid line.
|
||||
if (level !== undefined) {
|
||||
const dash = river ? '' : ' stroke-dasharray="6 4"';
|
||||
const label = river ? '설계홍수위 DHWL' : sea ? '고조위 HWL' : '지하수위';
|
||||
parts.push(`<line x1="0" y1="${y(level)}" x2="${WIDTH}" y2="${y(level)}" stroke="#2F6DB5" stroke-width="${river ? 2 : 1}"${dash}></line>`);
|
||||
parts.push(`<text x="8" y="${y(level) - 4}" font-size="12" fill="#123E70">${label} ${level.toFixed(3)}</text>`);
|
||||
}
|
||||
// 해상: 저조위 (dashed, MSL datum).
|
||||
if (lowLevel !== undefined) {
|
||||
parts.push(`<line x1="0" y1="${y(lowLevel)}" x2="${WIDTH}" y2="${y(lowLevel)}" stroke="#2F6DB5" stroke-dasharray="6 4"></line>`);
|
||||
parts.push(`<text x="8" y="${y(lowLevel) + 12}" font-size="12" fill="#123E70">저조위 LWL ${lowLevel.toFixed(3)}</text>`);
|
||||
}
|
||||
// 세굴선 (하상·해상): the bed after scour.
|
||||
if (scourEl !== undefined) {
|
||||
parts.push(`<line x1="0" y1="${y(scourEl)}" x2="${WIDTH}" y2="${y(scourEl)}" stroke="#8A6D3B" stroke-dasharray="3 3"></line>`);
|
||||
parts.push(`<text x="8" y="${y(scourEl) + 12}" font-size="12" fill="#5B4A2E">세굴선 ${scourEl.toFixed(3)}</text>`);
|
||||
}
|
||||
const parts = groundBands(input, y, yMin, WIDTH, (v) => v.toFixed(3));
|
||||
|
||||
// Footing, column stub, and piles.
|
||||
const cx = WIDTH / 2;
|
||||
@@ -100,6 +42,135 @@ export function groundSectionSvg(input: PierInput, model: PierModel): string {
|
||||
return `<svg viewBox="0 0 ${WIDTH} ${H}" width="100%" style="max-width: ${WIDTH}px; height: auto" role="img" aria-label="지반·지층 단면: 지반면, 지층, 지하수위, 기초와 말뚝">${parts.join('')}</svg>`;
|
||||
}
|
||||
|
||||
/** The 지반·지층 values the bands read (a pier input, or an abutment's 지반 values with its 지반고). */
|
||||
type GroundBandInput = Pick<PierInput, 'groundEl' | 'groundPosition' | 'groundWaterOn' | 'groundWaterEl' | 'floodLevelEl' | 'scourDepthM' | 'highWaterEl' | 'lowWaterEl' | 'strataRows'>;
|
||||
|
||||
/** The water line (지하수위 / 설계홍수위 / 고조위), the 저조위 and the 세굴선 of the input, as ELs. */
|
||||
function groundLevels(input: GroundBandInput) {
|
||||
const position = input.groundPosition ?? '육상';
|
||||
const river = position === '하상';
|
||||
const sea = position === '해상';
|
||||
const level = river
|
||||
? (Number.isFinite(input.floodLevelEl) ? input.floodLevelEl : undefined)
|
||||
: sea
|
||||
? (Number.isFinite(input.highWaterEl) ? input.highWaterEl : undefined)
|
||||
: (input.groundWaterOn && Number.isFinite(input.groundWaterEl) ? input.groundWaterEl : undefined);
|
||||
const lowLevel = sea && Number.isFinite(input.lowWaterEl) ? input.lowWaterEl : undefined;
|
||||
const scour = river && Number.isFinite(input.scourDepthM) && (input.scourDepthM as number) >= 0 ? (input.scourDepthM as number) : undefined;
|
||||
const scourEl = scour !== undefined ? input.groundEl - scour : undefined;
|
||||
return { river, sea, level, lowLevel, scourEl };
|
||||
}
|
||||
|
||||
/** Lowest EL to draw: 1 m below the deepest of the layers, the structure ELs given, and the water and scour lines. */
|
||||
function groundRange(input: GroundBandInput, structureEls: number[]): { yMin: number } {
|
||||
const rows = input.strataRows ?? [];
|
||||
const g = input.groundEl;
|
||||
const { level, lowLevel, scourEl } = groundLevels(input);
|
||||
const lowestRow = rows.length ? rows[rows.length - 1].bottomEl : g - 1;
|
||||
return { yMin: Math.min(lowestRow, ...structureEls, level ?? g, lowLevel ?? g, scourEl ?? g) - 1 };
|
||||
}
|
||||
|
||||
/**
|
||||
* 지층 띠, 지반선, 수위선 (지하수위 점선 · 설계홍수위 실선 · 고조위 / 저조위), 세굴선 (2026-11-04: shared by the pier and the
|
||||
* abutment 삽도). `fmt` writes an EL (the pier writes "28.931", the abutment "EL.28.931").
|
||||
*/
|
||||
function groundBands(input: GroundBandInput, y: (el: number) => number, yMin: number, width: number, fmt: (el: number) => string): string[] {
|
||||
const rows = input.strataRows ?? [];
|
||||
const ground = input.groundEl;
|
||||
const { river, sea, level, lowLevel, scourEl } = groundLevels(input);
|
||||
const groundLabel = sea ? '해저면 EL' : '지반고';
|
||||
const parts: string[] = [];
|
||||
// Layers: each band runs from the upper boundary (ground for the first) down to its 하단 EL.
|
||||
let upper = ground;
|
||||
rows.forEach((row, i) => {
|
||||
const top = y(upper);
|
||||
const bottom = y(row.bottomEl);
|
||||
parts.push(`<rect x="0" y="${top}" width="${width}" height="${bottom - top}" fill="${LAYER_FILLS[i % LAYER_FILLS.length]}"></rect>`);
|
||||
parts.push(`<text x="8" y="${(top + bottom) / 2 + 4}" font-size="12" fill="#18212B">${escapeText(row.name)}</text>`);
|
||||
if (row.name === SUPPORT_STRATUM) {
|
||||
parts.push(`<line x1="0" y1="${top}" x2="${width}" y2="${top}" stroke="#5B4A2E" stroke-width="2"></line>`);
|
||||
// Caption just under the line, so it does not collide with the label of the band above.
|
||||
parts.push(`<text x="8" y="${top + 14}" font-size="12" fill="#18212B">연암층 상면 ${fmt(upper).startsWith('EL') ? '' : 'EL '}${fmt(upper)}</text>`);
|
||||
}
|
||||
upper = row.bottomEl;
|
||||
});
|
||||
if (rows.length === 0 || upper > yMin) {
|
||||
parts.push(`<rect x="0" y="${y(upper)}" width="${width}" height="${y(yMin) - y(upper)}" fill="${LAYER_FILLS[rows.length % LAYER_FILLS.length]}"></rect>`);
|
||||
}
|
||||
// Ground line and label.
|
||||
parts.push(`<line x1="0" y1="${y(ground)}" x2="${width}" y2="${y(ground)}" stroke="#8A6D3B" stroke-width="1.5"></line>`);
|
||||
parts.push(`<text x="8" y="${y(ground) - 6}" font-size="12" fill="#18212B">${groundLabel} ${fmt(ground)}</text>`);
|
||||
// Groundwater (육상, when on) as a dashed line; the 설계홍수위 (하상) as a solid line.
|
||||
if (level !== undefined) {
|
||||
const dash = river ? '' : ' stroke-dasharray="6 4"';
|
||||
const label = river ? '설계홍수위 DHWL' : sea ? '고조위 HWL' : '지하수위';
|
||||
parts.push(`<line x1="0" y1="${y(level)}" x2="${width}" y2="${y(level)}" stroke="#2F6DB5" stroke-width="${river ? 2 : 1}"${dash}></line>`);
|
||||
parts.push(`<text x="8" y="${y(level) - 4}" font-size="12" fill="#123E70">${label} ${fmt(level)}</text>`);
|
||||
}
|
||||
// 해상: 저조위 (dashed, MSL datum).
|
||||
if (lowLevel !== undefined) {
|
||||
parts.push(`<line x1="0" y1="${y(lowLevel)}" x2="${width}" y2="${y(lowLevel)}" stroke="#2F6DB5" stroke-dasharray="6 4"></line>`);
|
||||
parts.push(`<text x="8" y="${y(lowLevel) + 12}" font-size="12" fill="#123E70">저조위 LWL ${fmt(lowLevel)}</text>`);
|
||||
}
|
||||
// 세굴선 (하상·해상): the bed after scour.
|
||||
if (scourEl !== undefined) {
|
||||
parts.push(`<line x1="0" y1="${y(scourEl)}" x2="${width}" y2="${y(scourEl)}" stroke="#8A6D3B" stroke-dasharray="3 3"></line>`);
|
||||
parts.push(`<text x="8" y="${y(scourEl) + 12}" font-size="12" fill="#5B4A2E">세굴선 ${fmt(scourEl)}</text>`);
|
||||
}
|
||||
return parts;
|
||||
}
|
||||
|
||||
/** 교대 지반 삽도 입력 (2026-11-04): the 지반 values and the generated 저판 · 벽체 · 흉벽 · 말뚝 (t in m, 전면 +). */
|
||||
export interface AbutGroundDrawing {
|
||||
ground: GroundBandInput;
|
||||
footingTopEl: number;
|
||||
footingBottomEl: number;
|
||||
seatEl: number;
|
||||
parapetTopEl: number;
|
||||
bracketHeightM: number;
|
||||
wallFrontM: number;
|
||||
wallBackM: number;
|
||||
parapetBackM: number;
|
||||
footingFrontM: number;
|
||||
footingBackM: number;
|
||||
pileRowsT: number[];
|
||||
pileBottomEl?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 교대 지반·지층 단면 (삽도, 2026-11-04): 교대축 직각 단면(저판, 벽체, 브라켓 단차, 흉벽, 말뚝 열)을 지층 · 수위 위에 그린다.
|
||||
* 세로는 EL 한 축척, 가로는 단면 모양을 보이는 모식 축척이다. 왼쪽이 배면.
|
||||
*/
|
||||
export function abutGroundSectionSvg(d: AbutGroundDrawing): string {
|
||||
const g = d.ground;
|
||||
const { yMin } = groundRange(g, [d.footingBottomEl, ...(d.pileBottomEl === undefined ? [] : [d.pileBottomEl])]);
|
||||
const yMax = Math.max(g.groundEl, d.parapetTopEl) + 0.8;
|
||||
const scale = PLOT_H / (yMax - yMin);
|
||||
const y = (el: number) => TOP_PAD + (yMax - el) * scale;
|
||||
const H = TOP_PAD + PLOT_H + 16;
|
||||
const fmt = (v: number) => `EL.${v.toFixed(3)}`;
|
||||
const parts = groundBands(g, y, yMin, WIDTH, fmt);
|
||||
const kx = 150 / (d.footingFrontM - d.footingBackM);
|
||||
const cx = WIDTH / 2;
|
||||
const X = (t: number) => cx + t * kx;
|
||||
const rect = (t0: number, t1: number, e0: number, e1: number, style: string) =>
|
||||
`<rect x="${X(t0)}" y="${y(e1)}" width="${X(t1) - X(t0)}" height="${y(e0) - y(e1)}" ${style}></rect>`;
|
||||
const bracketTop = d.parapetTopEl - d.bracketHeightM;
|
||||
if (d.pileBottomEl !== undefined) {
|
||||
for (const t of d.pileRowsT) parts.push(rect(t - 0.15, t + 0.15, d.pileBottomEl, d.footingBottomEl, 'fill="#7D8A97"'));
|
||||
}
|
||||
parts.push(rect(d.footingBackM, d.footingFrontM, d.footingBottomEl, d.footingTopEl, 'fill="#B7C7D3" stroke="#6E8496"'));
|
||||
parts.push(rect(d.wallBackM, d.wallFrontM, d.footingTopEl, d.seatEl, 'fill="#D8C7AA" stroke="#9C8763"'));
|
||||
parts.push(rect(d.wallBackM, d.parapetBackM, d.seatEl, bracketTop, 'fill="#D8C7AA" stroke="#9C8763"'));
|
||||
parts.push(rect(d.parapetBackM, 0, d.seatEl, d.parapetTopEl, 'fill="#D8C7AA" stroke="#9C8763"'));
|
||||
// Right-aligned at the drawing edge so the longer EL labels are not cut off.
|
||||
parts.push(`<text x="${WIDTH - 4}" y="${y(d.footingBottomEl) + 14}" font-size="12" fill="#18212B" text-anchor="end">기초 하단 ${fmt(d.footingBottomEl)}</text>`);
|
||||
if (d.pileBottomEl !== undefined) parts.push(`<text x="${WIDTH - 4}" y="${y(d.pileBottomEl) + 14}" font-size="12" fill="#18212B" text-anchor="end">말뚝 선단 ${fmt(d.pileBottomEl)}</text>`);
|
||||
parts.push(`<text x="${X(0) + 4}" y="${y(d.parapetTopEl) + 4}" font-size="12" fill="#18212B">흉벽 상단 ${fmt(d.parapetTopEl)}</text>`);
|
||||
parts.push(`<text x="${WIDTH - 4}" y="${H - 4}" font-size="11" fill="#55606E" text-anchor="end">← 배면 · 전면 →</text>`);
|
||||
return `<svg viewBox="0 0 ${WIDTH} ${H}" width="100%" style="max-width: ${WIDTH + 80}px; height: auto" role="img" aria-label="교대 지반·지층 단면: 지반면, 지층, 수위, 저판 · 벽체 · 흉벽 · 말뚝">${parts.join('')}</svg>`;
|
||||
}
|
||||
|
||||
function escapeText(s: string): string {
|
||||
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
+61
-8
@@ -5,6 +5,8 @@ import { bottomPiece, MAIN, topSplicePieces, widthLayout } from '../core/rebar/c
|
||||
import { C14_INSET, c14Bars, CAP, COPING_REBAR, SHEAR_LOOP_STEP, shearBars, shearBarsMulti, shearUHeight, copingSideBars, SIDE_HOOK_LEG, stairBars, undersideAt } from '../core/rebar/coping';
|
||||
import { sideRowLevels } from '../core/coping-geom';
|
||||
import { columnRebar, columnInputFor } from '../core/rebar/column';
|
||||
import { castRebarLayout } from '../core/rebar/cast-layout';
|
||||
import { multicolCopingBars } from '../core/rebar/multicol-coping';
|
||||
import { barDepths, barsAcross } from '../core/rebar/bearing-block';
|
||||
import { blockFramePoint } from '../core/geometry';
|
||||
import { INTEGRATED_BLOCK } from '../core/integrated-block';
|
||||
@@ -49,7 +51,7 @@ const MEMBER_LABELS: Record<RebarMemberKey, string> = { bearing: '받침블럭',
|
||||
const hex = (v: number) => `#${v.toString(16).padStart(6, '0')}`;
|
||||
/** Rebar is drawn a little deeper than the 6.7 colours so it reads through the half-transparent members (승인 2026-10-23). */
|
||||
const REBAR_SHADE = 0.8;
|
||||
const rebarColor = (v: number) => new THREE.Color(v).multiplyScalar(REBAR_SHADE);
|
||||
export const rebarColor = (v: number) => new THREE.Color(v).multiplyScalar(REBAR_SHADE);
|
||||
|
||||
const COLOR = {
|
||||
coping: 0xb4531f,
|
||||
@@ -65,6 +67,28 @@ const COLOR = {
|
||||
|
||||
const mm = (v: number) => v / 1000;
|
||||
|
||||
/** Arc-length interpolation avoids a linear scan per vertex on long spiral paths. */
|
||||
class CastPolyline extends THREE.Curve<THREE.Vector3> {
|
||||
private cumulative = [0];
|
||||
constructor(private points: [number, number, number][]) {
|
||||
super();
|
||||
for (let i = 1; i < points.length; i++) this.cumulative.push(this.cumulative[i - 1] + Math.hypot(...points[i].map((v, j) => v - points[i - 1][j])));
|
||||
}
|
||||
getPoint(t: number, target = new THREE.Vector3()): THREE.Vector3 {
|
||||
const distance = Math.max(0, Math.min(1, t)) * this.getLength();
|
||||
let lo = 1, hi = this.points.length - 1;
|
||||
while (lo < hi) { const mid = (lo + hi) >> 1; if (this.cumulative[mid] < distance) lo = mid + 1; else hi = mid; }
|
||||
const fraction = (distance - this.cumulative[lo - 1]) / (this.cumulative[lo] - this.cumulative[lo - 1] || 1);
|
||||
const a = this.points[lo - 1], b = this.points[lo];
|
||||
return target.set(a[0] + (b[0] - a[0]) * fraction, a[1] + (b[1] - a[1]) * fraction, a[2] + (b[2] - a[2]) * fraction);
|
||||
}
|
||||
getPointAt(t: number, target = new THREE.Vector3()): THREE.Vector3 { return this.getPoint(t, target); }
|
||||
getTangentAt(t: number, target = new THREE.Vector3()): THREE.Vector3 {
|
||||
return target.copy(this.getPoint(Math.min(1, t + 1e-5))).sub(this.getPoint(Math.max(0, t - 1e-5))).normalize();
|
||||
}
|
||||
getLength(): number { return this.cumulative.at(-1)!; }
|
||||
}
|
||||
|
||||
export function buildRebar3D(m: PierModel, common: Material = DEFAULT_MATERIAL, byMember: FckByMember = {}): RebarScene {
|
||||
const mCoping = materialFor(common, byMember, 'coping');
|
||||
const mColumn = materialFor(common, byMember, 'column');
|
||||
@@ -140,7 +164,13 @@ export function buildRebar3D(m: PierModel, common: Material = DEFAULT_MATERIAL,
|
||||
};
|
||||
|
||||
// Coping (straight coping only; a bent coping is not drawn in 3D yet).
|
||||
if (straight) {
|
||||
if (straight && m.columns.length > 1) {
|
||||
for (const b of multicolCopingBars(m, mCoping)) {
|
||||
if (/^C[1-8](-\d+)?$/.test(b.name)) addPath(b.name, 0, b.pts, b.diameterMm, COLOR.coping);
|
||||
else for (let i = 1; i < b.pts.length; i++) addBar(b.name, 0, b.pts[i - 1], b.pts[i], b.diameterMm, COLOR.shear);
|
||||
}
|
||||
}
|
||||
if (straight && m.columns.length <= 1) {
|
||||
const w = widthLayout(B * 1000, m.copingRebar);
|
||||
const xs = [w.yE, ...Array.from({ length: w.n + 1 }, (_, i) => w.yE + w.g + w.spacing * i), B * 1000 - w.yE].map((v) => mm(v) - B / 2);
|
||||
const cover = 0.1;
|
||||
@@ -248,7 +278,7 @@ export function buildRebar3D(m: PierModel, common: Material = DEFAULT_MATERIAL,
|
||||
const c14Hook = mm(hookLength('main90', NOMINAL_DIAMETER.H19));
|
||||
const c14Na = -B / 2 + mm(C14_INSET);
|
||||
const c14Nb = B / 2 - mm(C14_INSET);
|
||||
for (const b of c14Bars(L * 1000, topEl, [...side.c12, ...side.c13], (x) => undersideAt(L * 1000, x, m.endBottom, m.levelBottom, m.copingGeom.levelWidth), m.copingRebar.mainLayerGap / 1000)) {
|
||||
for (const b of c14Bars(L * 1000, topEl, [...side.c13, ...side.c12], (x) => undersideAt(L * 1000, x, m.endBottom, m.levelBottom, m.copingGeom.levelWidth), m.copingRebar.mainLayerGap / 1000)) {
|
||||
const xm = mm(b.x);
|
||||
addBar('C14', 0, [xm, b.z, c14Na], [xm, b.z, c14Nb], NOMINAL_DIAMETER.H19, COLOR.coping);
|
||||
// 연직 발 below the coping underside is left out (코핑철근.md §8).
|
||||
@@ -279,9 +309,7 @@ export function buildRebar3D(m: PierModel, common: Material = DEFAULT_MATERIAL,
|
||||
}
|
||||
|
||||
// Columns: P1 / P2 main bars on their radii, ties P3 / P5 / P4 as rings, P6 crossties across the column.
|
||||
const colInput = columnInputFor(m);
|
||||
const col = columnRebar(colInput, mColumn);
|
||||
const zBot = col.zBot; // mm
|
||||
if (m.footings.length > 0) {
|
||||
const ring = (name: string, k: number, sc: number, y: number, r: number, d: number) => {
|
||||
const N = 36;
|
||||
for (let i = 0; i < N; i++) {
|
||||
@@ -290,7 +318,10 @@ export function buildRebar3D(m: PierModel, common: Material = DEFAULT_MATERIAL,
|
||||
addBar(name, k, [sc + r * Math.cos(a0), y, r * Math.sin(a0)], [sc + r * Math.cos(a1), y, r * Math.sin(a1)], d, COLOR.tie);
|
||||
}
|
||||
};
|
||||
for (const c of m.columns) {
|
||||
for (const [columnIndex, c] of m.columns.entries()) {
|
||||
const colInput = columnInputFor(m, undefined, columnIndex);
|
||||
const col = columnRebar(colInput, mColumn);
|
||||
const zBot = col.zBot;
|
||||
const sc = c.s;
|
||||
const base = c.bottom;
|
||||
const rt = mm(col.tieRadius);
|
||||
@@ -376,6 +407,23 @@ export function buildRebar3D(m: PierModel, common: Material = DEFAULT_MATERIAL,
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
if (m.castInput) {
|
||||
for (const bar of castRebarLayout(m, mColumn).bars) {
|
||||
if (bar.pts.length === 2) {
|
||||
addBar(bar.name, bar.k, bar.pts[0], bar.pts[1], bar.diameterMm, bar.kind === 'coupler' ? COLOR.couplerMain : COLOR.column);
|
||||
} else {
|
||||
records.push({ name: bar.name, k: bar.k, pts: bar.pts, dMm: bar.diameterMm });
|
||||
const path = new CastPolyline(bar.pts);
|
||||
const geometry = new THREE.TubeGeometry(path, Math.min(20000, bar.pts.length), mm(bar.diameterMm) / 2, 6, false);
|
||||
geometry.applyMatrix4(Ms[bar.k]);
|
||||
const mesh = new THREE.Mesh(geometry, new THREE.MeshStandardMaterial({ color: rebarColor(COLOR.tie), roughness: 0.45, metalness: 0.1 }));
|
||||
mesh.userData = { name: bar.name, roleColor: COLOR.tie, dMm: bar.diameterMm, member: 'column' };
|
||||
group.add(mesh);
|
||||
const list = byName.get(bar.name) ?? []; list.push(mesh); byName.set(bar.name, list);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Footing bars B1–B8 (교각축 · 교각축 직각), the grids from §3 and §6 of 확대기초철근 (1).md.
|
||||
for (const f of m.footings) {
|
||||
const Lf = f.length;
|
||||
@@ -555,6 +603,11 @@ export function buildRebar3D(m: PierModel, common: Material = DEFAULT_MATERIAL,
|
||||
}
|
||||
}
|
||||
|
||||
return { group, byName, records, ...rebarControls(byName) };
|
||||
}
|
||||
|
||||
/** Highlight, colour and legend over bars grouped by 철근표 number (교각 · 교대 3D 철근 공통). */
|
||||
export function rebarControls(byName: Map<string, THREE.Mesh[]>): Pick<RebarScene, 'show' | 'setColorMode' | 'legend'> {
|
||||
const show = (names: ReadonlySet<string> | null, isolate: boolean) => {
|
||||
// 커플러 as a name selects every coupler bar.
|
||||
const isOn = (key: string) => names === null || names.has(key) || (names.has('커플러') && key.includes('커플러'));
|
||||
@@ -592,5 +645,5 @@ export function buildRebar3D(m: PierModel, common: Material = DEFAULT_MATERIAL,
|
||||
const items = DIAMETER_GROUPS.map((g, i) => ({ label: g.label, color: hex(DIAMETER_COLORS[i]) })).filter((_, i) => present.has(i));
|
||||
return present.has(-1) ? [...items, { label: '기타', color: hex(OTHER_COLOR) }] : items;
|
||||
};
|
||||
return { group, byName, records, show, setColorMode, legend };
|
||||
return { show, setColorMode, legend };
|
||||
}
|
||||
+20
-16
@@ -152,14 +152,14 @@ export function buildPierScene(m: PierModel, terrain?: TerrainGrid, groundEl: nu
|
||||
pad.rotateY(turn);
|
||||
pad.translate(b.s, b.padTop - 0.025, b.n);
|
||||
add('pads', pad, 1, b.k);
|
||||
if (b.plan) {
|
||||
// 교량받침 본체 (승인 2026-10-23): the solplate plan less 100 mm on each side, from the mortar top up to the solplate.
|
||||
const body = new THREE.BoxGeometry((b.plan.B + b.plan.margin) / 1000 - 0.2, b.plan.h3 / 1000, (b.plan.A + b.plan.margin) / 1000 - 0.2);
|
||||
{
|
||||
// 교량받침 본체: the solplate plan less 100 mm on each side, from the mortar top up to the solplate.
|
||||
const body = new THREE.BoxGeometry(b.plan.W / 1000 - 0.2, b.plan.h3 / 1000, b.plan.L / 1000 - 0.2);
|
||||
body.rotateY(turn);
|
||||
body.translate(b.s, b.padTop + b.plan.h3 / 2000, b.n);
|
||||
add('bearings', body, 1, b.k);
|
||||
// Solplate: a wedge that tapers across the 교각축 직각 (T1 at the (−) side), on the bearing (승인 2026-10-23).
|
||||
const sp = solplateGeometry(b, b.plan.h3, { A: b.plan.A, B: b.plan.B, margin: b.plan.margin, T: b.plan.T, slopePct: b.plan.S });
|
||||
// Solplate: a wedge that tapers across the 교각축 직각 (T1 at the (−) side), on the bearing.
|
||||
const sp = solplateGeometry(b, b.plan);
|
||||
add('solplates', wedgeGeometry(sp.corners), 1, b.k);
|
||||
}
|
||||
}
|
||||
@@ -208,17 +208,21 @@ const REBAR_VIEW_ALPHA = 0.5;
|
||||
|
||||
/** Turns the ③ rebar view on or off. Off restores each part's own opacity. Depth writes stop while on, so bars behind a member stay visible. */
|
||||
export function setRebarView(scene: PierScene, on: boolean): void {
|
||||
for (const group of Object.values(scene.parts)) {
|
||||
group.traverse((o) => {
|
||||
if (!(o instanceof THREE.Mesh)) return;
|
||||
const mat = o.material as THREE.MeshStandardMaterial;
|
||||
const base = mat.userData.baseOpacity as number;
|
||||
mat.opacity = on ? REBAR_VIEW_ALPHA : base;
|
||||
mat.transparent = on || base < 1;
|
||||
mat.depthWrite = !on;
|
||||
mat.needsUpdate = true;
|
||||
});
|
||||
}
|
||||
for (const group of Object.values(scene.parts)) setRebarViewOn(group, on);
|
||||
}
|
||||
|
||||
/** {@link setRebarView} for any group (교대 3D). A material without a recorded base opacity takes its current one as the base. */
|
||||
export function setRebarViewOn(root: THREE.Object3D, on: boolean): void {
|
||||
root.traverse((o) => {
|
||||
if (!(o instanceof THREE.Mesh)) return;
|
||||
const mat = o.material as THREE.MeshStandardMaterial;
|
||||
mat.userData.baseOpacity ??= mat.opacity;
|
||||
const base = mat.userData.baseOpacity as number;
|
||||
mat.opacity = on ? REBAR_VIEW_ALPHA : base;
|
||||
mat.transparent = on || base < 1;
|
||||
mat.depthWrite = !on && base >= 1;
|
||||
mat.needsUpdate = true;
|
||||
});
|
||||
}
|
||||
|
||||
export type ViewKind = 'iso' | 'front' | 'side' | 'top';
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
import type { BlockModel } from '../core/geometry';
|
||||
|
||||
/** Drawing values of one solplate (도면값), overlaid as dashed outlines. mm. */
|
||||
export interface SolplateDrawingRef {
|
||||
L: number;
|
||||
W: number;
|
||||
T1: number;
|
||||
T2: number;
|
||||
S: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 솔플레이트 상세 (승인 2026-10-26) for one bearing: the plan (L across the 교축, W along the 교각축) and the
|
||||
* taper in side view (T1 at the (−) side, T2 at the (+) side, S % · T). Model solid, drawing dashed.
|
||||
*/
|
||||
export function solplateDetailSvg(block: BlockModel, ref?: SolplateDrawingRef): string {
|
||||
const plan = block.plan;
|
||||
if (!plan) return '';
|
||||
const L = plan.A + plan.margin;
|
||||
const W = plan.B + plan.margin;
|
||||
const drop = (L * Math.abs(plan.S)) / 200;
|
||||
const t1 = plan.T + drop;
|
||||
const t2 = plan.T - drop;
|
||||
const parts: string[] = [];
|
||||
// Plan (left): L across, W along.
|
||||
parts.push(`<rect x="${-L / 2}" y="${-W / 2}" width="${L}" height="${W}" fill="#F3D9B8" stroke="#B4531F" stroke-width="8"></rect>`);
|
||||
parts.push(`<text x="0" y="${-W / 2 - 40}" text-anchor="middle" font-size="60" fill="#18212B">L ${L.toFixed(0)}</text>`);
|
||||
parts.push(`<text x="${L / 2 + 40}" y="0" font-size="60" fill="#18212B">W ${W.toFixed(0)}</text>`);
|
||||
if (ref) {
|
||||
parts.push(`<rect x="${-ref.L / 2}" y="${-ref.W / 2}" width="${ref.L}" height="${ref.W}" fill="none" stroke="#B4531F" stroke-width="6" stroke-dasharray="40 25"></rect>`);
|
||||
}
|
||||
// Side view (right): thickness T1 at x = −L/2 and T2 at x = +L/2, shown with the taper.
|
||||
const ox = L / 2 + 500;
|
||||
const sc = 4; // exaggerated thickness for legibility, labelled
|
||||
parts.push(`<polygon points="${ox - L / 2},0 ${ox + L / 2},0 ${ox + L / 2},${-t2 * sc} ${ox - L / 2},${-t1 * sc}" fill="#F3D9B8" stroke="#B4531F" stroke-width="8"></polygon>`);
|
||||
parts.push(`<text x="${ox - L / 2}" y="${-t1 * sc - 40}" font-size="60" fill="#18212B">T1 ${t1.toFixed(0)}</text>`);
|
||||
parts.push(`<text x="${ox + L / 2}" y="${-t2 * sc - 40}" text-anchor="end" font-size="60" fill="#18212B">T2 ${t2.toFixed(0)}</text>`);
|
||||
parts.push(`<text x="${ox}" y="120" text-anchor="middle" font-size="60" fill="#55606E">S ${plan.S} % · T ${plan.T} (두께 과장 ×${sc})</text>`);
|
||||
if (ref) {
|
||||
const r1 = ref.T1;
|
||||
const r2 = ref.T2;
|
||||
parts.push(`<polygon points="${ox - ref.L / 2},0 ${ox + ref.L / 2},0 ${ox + ref.L / 2},${-r2 * sc} ${ox - ref.L / 2},${-r1 * sc}" fill="none" stroke="#B4531F" stroke-width="6" stroke-dasharray="40 25"></polygon>`);
|
||||
}
|
||||
const minX = -L / 2 - 200;
|
||||
const maxX = ox + L / 2 + 200;
|
||||
return `<svg viewBox="${minX} -900 ${maxX - minX} 1300" width="100%" style="max-width: 560px; height: auto" role="img" aria-label="솔플레이트 상세: 평면 L × W, 두께 T1 · T2 (두께 과장)">${parts.join('')}</svg>`;
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import * as XLSX from 'xlsx';
|
||||
import * as THREE from 'three';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { bearingRotDeg, buildAbut } from '../src/core/abut/geometry';
|
||||
import { parseAbutments, toAbutInput } from '../src/core/abut/parse';
|
||||
import { buildMembers } from '../src/core/abut/members';
|
||||
import { buildWings } from '../src/core/abut/wings';
|
||||
import { buildAbutGroup } from '../src/view/abut-scene';
|
||||
import { abutElevationRows, resolveBearingRule } from '../src/core/abut/bearing-table';
|
||||
|
||||
const RAG = fileURLToPath(new URL('../../../RAG/운곡교P1입력.xlsx', import.meta.url));
|
||||
const rec = parseAbutments(XLSX.read(readFileSync(RAG), { type: 'buffer' })).abutments[0];
|
||||
const model = buildAbut(toAbutInput(rec));
|
||||
|
||||
describe('교대 받침 방위각 (엑셀 받침 시트 6번째 열)', () => {
|
||||
it('reads the 방위각 of every bearing (75°56′55.9″ … 75°58′08.4″ in the workbook frame)', () => {
|
||||
expect(rec.bearings.every((b) => b.azimuthDeg !== undefined && Number.isFinite(b.azimuthDeg))).toBe(true);
|
||||
// model-xy = 90 − workbook value: 75.94886° → 14.05114°
|
||||
expect(rec.bearings.find((b) => b.no === 1)!.azimuthDeg).toBeCloseTo(90 - (75 + 56 / 60 + 55.9 / 3600), 6);
|
||||
});
|
||||
|
||||
it('turns each block like the pier rule (≈ 4.42° from the axis normal); against the wing (skew) line the rest is 0.63° (시안 AW01)', () => {
|
||||
const front = { e: model.nLeft.e * model.frontSide, n: model.nLeft.n * model.frontSide };
|
||||
// 날개벽 방향: CAD 각 lineDeg → 진북 = 90 − CAD (시안: 진북 194.68°, CAD 255.32°).
|
||||
const wingRot = bearingRotDeg(90 - model.wing!.lineDeg, model.u, front);
|
||||
for (const b of model.bearings) {
|
||||
expect(Math.abs(b.rotDeg)).toBeGreaterThan(4.3);
|
||||
expect(Math.abs(b.rotDeg)).toBeLessThan(4.5);
|
||||
}
|
||||
const first = model.bearings.find((b) => b.no === 1)!;
|
||||
expect(Math.abs(first.rotDeg - wingRot)).toBeCloseTo(0.63, 1);
|
||||
});
|
||||
|
||||
it('without the 방위각 the blocks stay on the axis (rotation 0)', () => {
|
||||
const flat = buildAbut({ ...toAbutInput(rec), bearings: toAbutInput(rec).bearings.map((b) => ({ ...b, azimuthDeg: undefined })) });
|
||||
expect(flat.bearings.every((b) => b.rotDeg === 0)).toBe(true);
|
||||
});
|
||||
|
||||
it('the 3D block turns with the bearing: its footprint is no longer axis-aligned', () => {
|
||||
const input = toAbutInput(rec);
|
||||
const members = buildMembers(model, { bottomEl: input.foundationBottomEl, pileLengthMm: input.pileLengthMm });
|
||||
const group = buildAbutGroup(model, members, buildWings(model, members));
|
||||
const block = (group.getObjectByName('blocks') as THREE.Group).children[0] as THREE.Mesh;
|
||||
const box = new THREE.Box3().setFromObject(block);
|
||||
// a turned 1.2 m square is wider than 1.2 m along s
|
||||
expect(box.max.x - box.min.x).toBeGreaterThan(1.2 + 1e-4);
|
||||
});
|
||||
|
||||
it('the ELEVATION table shows the 방위각 (CAD = the workbook value, 진북) and the block turn', () => {
|
||||
const [r] = abutElevationRows(model, resolveBearingRule(), rec.bearingHeightMm).filter((x) => x.no === 1);
|
||||
expect(r.azimuthCadDeg).toBeCloseTo(75 + 56 / 60 + 55.9 / 3600, 6);
|
||||
expect(r.azimuthNorthDeg).toBeCloseTo(90 - (75 + 56 / 60 + 55.9 / 3600), 6);
|
||||
expect(r.rotDeg).toBeCloseTo(model.bearings.find((b) => b.no === 1)!.rotDeg, 9);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { abutBearingPlanSvg, abutBearingSectionSvg, abutSolplateSvg, type AbutBearingDetail } from '../src/view/abut-bearing-svg';
|
||||
|
||||
// 운곡교 A1 받침 ① (교대 받침 표: 블럭 125 = 75 + 50, H 275 → T 25, h3 250, L 1000 × W 1100, T1 27.5 / T2 22.5, 회전 −4.42°).
|
||||
const d: AbutBearingDetail = {
|
||||
no: 1, blockMm: 1200, padMm: 1000, L: 1000, W: 1100,
|
||||
h1: 75, h2: 50, h3: 250, h4: 25, T1: 27.5, T2: 22.5, rotDeg: -4.42, S: -0.5, seatEl: 44.367, z: 44.492,
|
||||
};
|
||||
|
||||
describe('교대 받침 삽도 (시안 ② 교량받침 카드)', () => {
|
||||
it('평면: block, pad and solplate L × W, turned by the bearing azimuth', () => {
|
||||
const svg = abutBearingPlanSvg(d);
|
||||
expect(svg).toContain('솔플 1000 × 1100');
|
||||
expect(svg).toContain('패드 1000 (점선)');
|
||||
expect(svg).toContain('rotate(4.420 150 130)');
|
||||
expect(svg.match(/<rect /g)).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('단면: the four layers and E = h1 + h2 + h3 + h4 = 400', () => {
|
||||
const svg = abutBearingSectionSvg(d);
|
||||
for (const s of ['h1 75', 'h2 50', 'h3 250', 'h4 25', 'E = 400', '교좌면 EL.44.367', 'z EL.44.492']) expect(svg).toContain(s);
|
||||
expect(svg.match(/<rect /g)).toHaveLength(4);
|
||||
});
|
||||
|
||||
it('솔플레이트: L, W, T1 → T2 and the slope', () => {
|
||||
const svg = abutSolplateSvg(d);
|
||||
for (const s of ['T1 27.5', 'T2 22.5', 'L 1000 · W 1100', 'S -0.500 %']) expect(svg).toContain(s);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,99 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import * as XLSX from 'xlsx';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { buildAbut } from '../src/core/abut/geometry';
|
||||
import { parseAbutments, toAbutInput } from '../src/core/abut/parse';
|
||||
import { buildMembers } from '../src/core/abut/members';
|
||||
import {
|
||||
ABUT_BEARING_RULE_DEFAULT,
|
||||
abutBearingChecks,
|
||||
abutElevationRows,
|
||||
abutSolplateRows,
|
||||
resolveBearingRule,
|
||||
} from '../src/core/abut/bearing-table';
|
||||
import { applyBearingRule } from '../src/core/abut/edit';
|
||||
import { checkStoredAbutments } from '../src/core/project';
|
||||
import { p1Pairs } from './helpers';
|
||||
|
||||
const RAG = fileURLToPath(new URL('../../../RAG/운곡교P1입력.xlsx', import.meta.url));
|
||||
const rec = parseAbutments(XLSX.read(readFileSync(RAG), { type: 'buffer' })).abutments[0];
|
||||
const input = toAbutInput(rec);
|
||||
const model = buildAbut(input);
|
||||
const members = buildMembers(model, { bottomEl: input.foundationBottomEl, pileLengthMm: input.pileLengthMm });
|
||||
const rule = resolveBearingRule();
|
||||
const H = rec.bearingHeightMm;
|
||||
// Bridge context as in the app: A1 ①–⑤ pair with P1 전열 ①–⑤ (2026-11-06), 교대 종단경사 i from the sheet.
|
||||
const ctx = { pairs: p1Pairs(), slopeI: rec.slopeI };
|
||||
|
||||
describe('교대 받침 카드 (솔플레이트 결정 2026-11-06, h3 = 받침높이 − T_ref)', () => {
|
||||
it('defaults: A 700 × B 640 + 100, T_ref 30, T_min 22; H 275 from the 교대 sheet → h3 245', () => {
|
||||
expect(ABUT_BEARING_RULE_DEFAULT).toMatchObject({ tRefMm: 30, tMinMm: 22, marginMm: 100, aMm: 700, bMm: 640 });
|
||||
expect(H).toBe(275);
|
||||
const [r] = abutSolplateRows(model, rule, H, ctx);
|
||||
expect([r.L, r.W, r.T, r.T1, r.T2]).toEqual([800, 740, 30, 38, 22]);
|
||||
expect(abutElevationRows(model, rule, H, ctx)[0].h3).toBe(245);
|
||||
});
|
||||
|
||||
it('ELEVATION: block 125 = 75 + 50, E = 75 + 50 + 245 + 30 = 400 (the girder underside of the sheet)', () => {
|
||||
const rows = abutElevationRows(model, rule, H, ctx);
|
||||
expect(rows).toHaveLength(5);
|
||||
for (const r of rows) {
|
||||
expect([r.h1, r.h2, r.h3, r.h4, r.E]).toEqual([75, 50, 245, 30, 400]);
|
||||
expect(r.solplateTopEl).toBeCloseTo(r.seatEl + 0.4, 6);
|
||||
}
|
||||
const first = rows.find((r) => r.no === 1)!;
|
||||
expect(first.seg).toBe('①');
|
||||
expect(first.seatEl).toBeCloseTo(44.367, 3);
|
||||
expect(first.z).toBeCloseTo(44.492, 3);
|
||||
});
|
||||
|
||||
it('솔플레이트 TABLE: S from the P1 pair (−1.908 %, 계산), T1 38 on the 오르막 (A1) side; W08 within 1 mm', () => {
|
||||
const rows = abutSolplateRows(model, rule, H, ctx);
|
||||
expect(rows.map((r) => r.S.toFixed(3))).toEqual(['-1.908', '-1.906', '-1.904', '-1.904', '-1.902']);
|
||||
expect(rows.every((r) => r.source === '계산' && r.pair?.startsWith('P1-'))).toBe(true);
|
||||
expect(rows[0].check).toBe('16 ≈ 15.3 일치');
|
||||
expect(rows.every((r) => r.azimuthSec !== undefined && r.azimuthSec <= 0.5)).toBe(true);
|
||||
expect(abutSolplateRows(model, rule, H, { slopeI: rec.slopeI })[0].azimuthSec).toBeUndefined();
|
||||
});
|
||||
|
||||
it('without a pair the 교대 종단경사 i (−0.0191) is used: S −1.91 % (입력), T 30', () => {
|
||||
const [r] = abutSolplateRows(model, rule, H, { slopeI: rec.slopeI });
|
||||
expect(r.source).toBe('입력');
|
||||
expect(r.S).toBeCloseTo(-1.91, 9);
|
||||
expect(r.T).toBe(30);
|
||||
});
|
||||
|
||||
it('S typed −3 % → T 34; the block concrete loses 4 mm and E stays 400 (거더 하면 고정)', () => {
|
||||
const steep = resolveBearingRule({ slopePct: -3 });
|
||||
expect(abutSolplateRows(model, steep, H, ctx)[0]).toMatchObject({ T: 34, T1: 46, T2: 22, source: '수정됨' });
|
||||
const r = abutElevationRows(model, steep, H, ctx)[0];
|
||||
expect([r.h1, r.h4, r.E]).toEqual([71, 34, 400]);
|
||||
});
|
||||
|
||||
it('checks: W02, W04 (흉벽 ≈ 145 · 전면 255), W03 and I01 all clear for A1', () => {
|
||||
const lines = abutBearingChecks(model, members, rule, H, ctx);
|
||||
expect(lines).toHaveLength(4);
|
||||
expect(lines[0]).toContain('W02 없음');
|
||||
// 시안 145 is bearing ① (lateral 0.745); the smallest gap over the five bearings is 144 (lateral 0.744).
|
||||
expect(lines[1]).toMatch(/흉벽 14[45]/);
|
||||
expect(lines[1]).toContain('전면 255');
|
||||
expect(lines[1]).toContain('W04 없음');
|
||||
expect(lines[2]).toContain('W03 없음');
|
||||
expect(lines[3]).toContain('I01 없음');
|
||||
});
|
||||
|
||||
it('the edit keeps only changed values, clears optional ones, refuses non-positive sizes and h3 ≤ 0, and the project file accepts it', () => {
|
||||
const edited = applyBearingRule(applyBearingRule(rec, 'aMm', 650), 'tMm', 32);
|
||||
expect(edited.bearingRule).toEqual({ aMm: 650, tMm: 32 });
|
||||
expect(applyBearingRule(edited, 'tMm', undefined).bearingRule).toEqual({ aMm: 650 });
|
||||
expect(() => applyBearingRule(rec, 'aMm', 0)).toThrow('0보다');
|
||||
expect(() => applyBearingRule(rec, 'tRefMm', 300)).toThrow('h3');
|
||||
expect(() => checkStoredAbutments([edited])).not.toThrow();
|
||||
expect(() => checkStoredAbutments([{ ...edited, bearingRule: { aMm: 'x' } }])).toThrow('받침 규칙');
|
||||
});
|
||||
|
||||
it('values stored by the 2026-11-03 C안 (lengthMm, widthMm) are ignored', () => {
|
||||
expect(resolveBearingRule({ lengthMm: 900, widthMm: 1000 } as Record<string, unknown>)).toEqual(ABUT_BEARING_RULE_DEFAULT);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,152 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import * as XLSX from 'xlsx';
|
||||
import * as THREE from 'three';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { buildAbut } from '../src/core/abut/geometry';
|
||||
import { parseAbutments, toAbutInput } from '../src/core/abut/parse';
|
||||
import { buildMembers } from '../src/core/abut/members';
|
||||
import { buildWings } from '../src/core/abut/wings';
|
||||
import { buildAbutGroup } from '../src/view/abut-scene';
|
||||
|
||||
const RAG = fileURLToPath(new URL('../../../RAG/운곡교P1입력.xlsx', import.meta.url));
|
||||
const rec = parseAbutments(XLSX.read(readFileSync(RAG), { type: 'buffer' })).abutments[0];
|
||||
const input = toAbutInput(rec);
|
||||
const model = buildAbut(input);
|
||||
const members = buildMembers(model, { bottomEl: input.foundationBottomEl, pileLengthMm: input.pileLengthMm });
|
||||
const wings = buildWings(model, members);
|
||||
const group = buildAbutGroup(model, members, wings);
|
||||
|
||||
const named = (name: string) => group.getObjectByName(name) as THREE.Group | undefined;
|
||||
|
||||
describe('교대 3D: 받침블럭과 접속슬래브 (spec §4.2, §4.8)', () => {
|
||||
it('has a block and a pad for each of the 5 bearings', () => {
|
||||
expect(named('blocks')?.children).toHaveLength(2 * model.bearings.length);
|
||||
});
|
||||
|
||||
it('draws the 접속슬래브 as one slab', () => {
|
||||
expect(named('slab')?.children).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('the slab does not overlap either wing in plan (spec §4.8: 날개벽 안쪽면 + 20)', () => {
|
||||
// Axis frame: s along IP1 → IP3, t positive to the 전면, as toAxis() in abut-scene.ts.
|
||||
const axis = (p: { e: number; n: number }) => {
|
||||
const d = { e: p.e - model.ip1.e, n: p.n - model.ip1.n };
|
||||
const f = { e: model.nLeft.e * model.frontSide, n: model.nLeft.n * model.frontSide };
|
||||
return { s: d.e * model.u.e + d.n * model.u.n, t: d.e * f.e + d.n * f.n };
|
||||
};
|
||||
const inside = (x: number, y: number, poly: { s: number; t: number }[]) => {
|
||||
let c = false;
|
||||
for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) {
|
||||
if (poly[i].t > y !== poly[j].t > y && x < ((poly[j].s - poly[i].s) * (y - poly[i].t)) / (poly[j].t - poly[i].t) + poly[i].s) c = !c;
|
||||
}
|
||||
return c;
|
||||
};
|
||||
const pos = (named('slab') as THREE.Group).children[0] as THREE.Mesh;
|
||||
const attr = pos.geometry.getAttribute('position');
|
||||
const polys = wings.map((w) => w.corners.map(axis));
|
||||
for (let i = 0; i < attr.count; i++) {
|
||||
for (const poly of polys) expect(inside(attr.getX(i), attr.getZ(i), poly)).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('the slab reaches 7 m behind the 흉벽 back face and is 450 thick', () => {
|
||||
const box = new THREE.Box3().setFromObject(named('slab') as THREE.Group);
|
||||
expect(box.min.z).toBeCloseTo(members.shape.parapetBackM - 0.005 - 7, 2);
|
||||
expect(box.max.y - box.min.y).toBeGreaterThan(0.45);
|
||||
});
|
||||
|
||||
it('the 흉벽 and the 벽체 only touch (no shared volume) and the back ledge is vertical-step free', () => {
|
||||
const volume = (a: THREE.Object3D, b: THREE.Object3D) => {
|
||||
const box = new THREE.Box3().setFromObject(a).intersect(new THREE.Box3().setFromObject(b));
|
||||
return Math.max(0, box.max.x - box.min.x) * Math.max(0, box.max.y - box.min.y) * Math.max(0, box.max.z - box.min.z);
|
||||
};
|
||||
// Same 교좌면 step only: the skewed prisms of neighbouring steps share a face, and their bounding boxes overlap slightly.
|
||||
const walls = (named('wall') as THREE.Group).children;
|
||||
const parapets = (named('parapet') as THREE.Group).children;
|
||||
walls.forEach((w, k) => {
|
||||
expect(volume(w, parapets[2 * k])).toBeLessThan(1e-9);
|
||||
expect(volume(w, parapets[2 * k + 1])).toBeLessThan(1e-9);
|
||||
});
|
||||
// back face: the ledge (t −0.8 … −0.5) and the 흉벽 back (−0.5) meet at one horizontal edge, not a vertical offset
|
||||
const backs = (named('wall') as THREE.Group).children.map((w) => new THREE.Box3().setFromObject(w).min.z);
|
||||
expect(Math.min(...backs)).toBeCloseTo(members.shape.wallBackM, 6);
|
||||
});
|
||||
|
||||
it('the 날개벽 meets the 흉벽 back face above the 브라켓 and the 벽체 back face below it, and never enters them', () => {
|
||||
// All 벽체 and 흉벽 parts lie at t ≥ 벽체 배면 (−0.8). A wing vertex with t ≤ −0.8 therefore has no volume in common with them.
|
||||
const z = (g: THREE.Object3D) => {
|
||||
const pos = (g as THREE.Mesh).geometry.getAttribute('position');
|
||||
let max = -Infinity;
|
||||
for (let i = 0; i < pos.count; i++) max = Math.max(max, pos.getZ(i));
|
||||
return max;
|
||||
};
|
||||
// Upper part (above the 브라켓 상면) meets the 흉벽 back face (−0.5); the lower part meets the 벽체 back face (−0.8).
|
||||
const backTop = (s: number) => members.parapetTopStartEl + ((members.parapetTopEndEl - members.parapetTopStartEl) * s) / model.length - members.shape.bracketHeightM;
|
||||
for (const w of (named('wings') as THREE.Group).children) {
|
||||
expect(z(w)).toBeLessThanOrEqual(members.shape.parapetBackM + 1e-6);
|
||||
const pos = (w as THREE.Mesh).geometry.getAttribute('position');
|
||||
for (let i = 0; i < pos.count; i++) {
|
||||
if (pos.getZ(i) > members.shape.wallBackM + 1e-6) expect(pos.getY(i)).toBeGreaterThanOrEqual(backTop(pos.getX(i)) - 1e-6);
|
||||
}
|
||||
}
|
||||
for (const w of (named('wall') as THREE.Group).children) expect(new THREE.Box3().setFromObject(w).min.z).toBeGreaterThanOrEqual(members.shape.wallBackM - 1e-6);
|
||||
});
|
||||
|
||||
it('the upper 헌치 and the 접속슬래브 stay on the 브라켓 상면 over the back strip, not in it', () => {
|
||||
const backTop = (s: number) => members.parapetTopStartEl + ((members.parapetTopEndEl - members.parapetTopStartEl) * s) / model.length - members.shape.bracketHeightM;
|
||||
for (const h of (named('haunches') as THREE.Group).children) {
|
||||
const b = new THREE.Box3().setFromObject(h);
|
||||
if (b.max.z < members.shape.parapetBackM + 1e-6) continue; // lower 헌치, behind the 벽체
|
||||
expect(b.min.y).toBeGreaterThanOrEqual(Math.max(backTop(b.min.x), backTop(b.max.x)) - 1e-6);
|
||||
}
|
||||
const pos = ((named('slab') as THREE.Group).children[0] as THREE.Mesh).geometry.getAttribute('position');
|
||||
for (let i = 0; i < pos.count; i++) {
|
||||
if (pos.getZ(i) >= members.shape.wallBackM) expect(pos.getY(i)).toBeGreaterThanOrEqual(backTop(pos.getX(i)) - 1e-6);
|
||||
}
|
||||
});
|
||||
|
||||
it('the 헌치 sits on the 날개벽 내측면 (the face between the two wings), on the centre side, and does not enter the wing', () => {
|
||||
const axis = (p: { e: number; n: number }) => {
|
||||
const d = { e: p.e - model.ip1.e, n: p.n - model.ip1.n };
|
||||
const f = { e: model.nLeft.e * model.frontSide, n: model.nLeft.n * model.frontSide };
|
||||
return { s: d.e * model.u.e + d.n * model.u.n, t: d.e * f.e + d.n * f.n };
|
||||
};
|
||||
// Inner face of each wing: s = sEnd + a·0.45 + (ws/wt)·t (a = +1 at IP1, −1 at IP3). Haunch vertices must lie on the centre side of it.
|
||||
const inner = wings.map((w) => {
|
||||
const p0 = axis(w.corners[0]);
|
||||
const p1 = axis(w.corners[1]);
|
||||
const a = w.sEnd === 0 ? 1 : -1;
|
||||
return { sEnd: w.sEnd, a, k: (p1.s - p0.s) / (p1.t - p0.t), tRoot: p0.t };
|
||||
});
|
||||
for (const h of (named('haunches') as THREE.Group).children) {
|
||||
const pos = (h as THREE.Mesh).geometry.getAttribute('position');
|
||||
for (let i = 0; i < pos.count; i++) {
|
||||
const x = pos.getX(i);
|
||||
const z = pos.getZ(i);
|
||||
const w = x < model.length / 2 ? inner[0] : inner[1];
|
||||
const sIn = w.sEnd + w.a * 0.45 + w.k * (z - w.tRoot);
|
||||
expect(w.a * (x - sIn)).toBeGreaterThanOrEqual(-1e-6);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('the 저판 plan is a skew parallelogram: its back corners sit on the wing outer lines, so the 날개벽 sits flush on the 저판 edge', () => {
|
||||
const axis = (p: { e: number; n: number }) => {
|
||||
const d = { e: p.e - model.ip1.e, n: p.n - model.ip1.n };
|
||||
const f = { e: model.nLeft.e * model.frontSide, n: model.nLeft.n * model.frontSide };
|
||||
return { s: d.e * model.u.e + d.n * model.u.n, t: d.e * f.e + d.n * f.n };
|
||||
};
|
||||
const p0 = axis(wings[0].corners[0]);
|
||||
const p1 = axis(wings[0].corners[1]);
|
||||
const k = (p1.s - p0.s) / (p1.t - p0.t); // s per m of t along the wing
|
||||
const pos = ((named('footing') as THREE.Group).children[0] as THREE.Mesh).geometry.getAttribute('position');
|
||||
const back = [...Array(pos.count).keys()].map((i) => ({ x: pos.getX(i), z: pos.getZ(i) })).filter((p) => Math.abs(p.z - members.shape.footingBackM) < 1e-6);
|
||||
expect(back.length).toBeGreaterThanOrEqual(2);
|
||||
for (const p of back) {
|
||||
const onLeft = Math.abs(p.x - k * p.z) < 1e-6;
|
||||
const onRight = Math.abs(p.x - (model.length + k * p.z)) < 1e-6;
|
||||
expect(onLeft || onRight).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
Loaded 100 of 182 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user