Compare commits
137
Commits
7182b6cbb4
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
50bdc3d691 | ||
|
|
fc3cc3b018 | ||
|
|
ac646d554c | ||
|
|
ed84761449 | ||
|
|
33aa2d9a61 | ||
|
|
8f808f453e | ||
|
|
6a15a13fc7 | ||
|
|
51209cccf9 | ||
|
|
0b3087e752 | ||
|
|
7340f8c1a1 | ||
|
|
9f336e2be4 | ||
|
|
a39a1ba8d2 | ||
|
|
9777938aa8 | ||
|
|
bd84ac6176 | ||
|
|
0fb39076b8 | ||
|
|
b84288c48a | ||
|
|
d7a61df7a9 | ||
|
|
8aedc9b969 | ||
|
|
4ef8412d89 | ||
|
|
38854cbe4c | ||
|
|
7d36c4b64a | ||
|
|
ef596650cc | ||
|
|
fc520ec03d | ||
|
|
b2bebe47eb | ||
|
|
d740406a8c | ||
|
|
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/`에 있습니다.
|
||||
+108
-28
@@ -11,22 +11,27 @@
|
||||
<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>
|
||||
<button id="nav-quantity" class="step" aria-label="⑤ 재료표·수량산출" type="button">⑤ 재료표·수량</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 +48,9 @@
|
||||
|
||||
<main class="grid" id="view-shape">
|
||||
<aside class="panel piers" aria-labelledby="pier-title">
|
||||
<p id="pier-title" class="lbl">교각 목록</p>
|
||||
<button id="project-ground-open" class="btn-o" type="button">프로젝트 지반모델</button>
|
||||
<p id="project-ground-status" class="pnote">모델 없음</p>
|
||||
<p id="pier-title" class="lbl">교각·교대 목록</p>
|
||||
<div class="pier-tools">
|
||||
<label>형식
|
||||
<select id="type-filter"></select>
|
||||
@@ -54,7 +61,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 +78,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 +187,44 @@
|
||||
|
||||
<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="center-col workspace" id="plan-workspace" data-stage="plan">
|
||||
<div class="workspace-tools" aria-label="계획 화면 배치 조절">
|
||||
<div class="layout-switch" role="group" aria-label="모델과 입력창 배치">
|
||||
<button type="button" class="btn small" data-layout="stack" aria-pressed="true">상하</button>
|
||||
<button type="button" class="btn small" data-layout="side" aria-pressed="false">좌우</button>
|
||||
</div>
|
||||
<label class="model-height-control" for="plan-model-height"><span data-size-label>모델 높이</span>
|
||||
<input id="plan-model-height" type="range" min="180" max="560" step="20" value="400" />
|
||||
<output id="plan-model-height-value" for="plan-model-height">400 px</output>
|
||||
</label>
|
||||
<button id="plan-model-toggle" class="btn small" type="button" aria-expanded="true" aria-controls="slot-plan">모델 접기</button>
|
||||
<span class="layout-note muted small" aria-live="polite"></span>
|
||||
</div>
|
||||
<div class="viewer-slot" id="slot-plan"></div>
|
||||
<div class="workspace-splitter" role="separator" tabindex="0" aria-controls="slot-plan" aria-label="모델 크기 조절"></div>
|
||||
<div class="workspace-pane">
|
||||
<!-- ② 교대 부재 카드 (시안, 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>
|
||||
@@ -217,6 +274,7 @@
|
||||
</footer>
|
||||
</section>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<aside class="panel results" aria-labelledby="changes-title">
|
||||
@@ -238,6 +296,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,35 +313,54 @@
|
||||
<ul id="rebar-tabs" class="members"></ul>
|
||||
<p class="pnote">형상은 ② 형상계획 기준입니다. 형상이 바뀌면 "형상 바뀜"으로 표시합니다.</p>
|
||||
</aside>
|
||||
<div class="center-col">
|
||||
<div class="center-col workspace review-workspace" id="review-workspace" data-stage="rebar">
|
||||
<div class="workspace-tools" aria-label="검토 공간 조절">
|
||||
<div class="layout-switch" role="group" aria-label="모델과 철근표 배치">
|
||||
<button type="button" class="btn small" data-layout="stack" aria-pressed="true">상하</button>
|
||||
<button type="button" class="btn small" data-layout="side" aria-pressed="false">좌우</button>
|
||||
</div>
|
||||
<label class="model-height-control" for="review-model-height"><span data-size-label>모델 높이</span>
|
||||
<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>
|
||||
<span class="layout-note muted small" aria-live="polite"></span>
|
||||
</div>
|
||||
<div class="viewer-slot" id="slot-rebar"></div>
|
||||
<div class="workspace-splitter" role="separator" tabindex="0" aria-controls="slot-rebar" aria-label="모델 크기 조절"></div>
|
||||
<div class="workspace-pane">
|
||||
<section class="panel viewer" aria-labelledby="rebar-title">
|
||||
<div class="viewer-head"><h2 id="rebar-title">철근표</h2></div>
|
||||
<div class="viewer-head"><h2 id="rebar-title">철근표</h2><button id="rebar-materials" class="btn-o" type="button">재료표·수량산출 보기</button></div>
|
||||
<p id="rebar-note" class="num"></p>
|
||||
<div id="rebar-table"></div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
<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 +433,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 +457,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>
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { existsSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import type { Plugin } from 'vite';
|
||||
import { resolvePython,describeSpawnError } from './python-runtime.ts';
|
||||
export const GROUND_LINK_API='/__pierzainer/ground-link';
|
||||
export function startGroundBat(bat:string,context:string,cwd:string){
|
||||
return spawn(process.env.ComSpec??'cmd.exe',['/d','/s','/c',`""${bat}""`],{cwd,windowsHide:true,windowsVerbatimArguments:true,env:{...process.env,PIERZAINER_GROUND_CONTEXT:context},stdio:'ignore'});
|
||||
@@ -42,10 +43,10 @@ export function groundLinkPlugin(root:string):Plugin{
|
||||
if(!existsSync(drawing)||!/\.(dwg|dxf)$/i.test(drawing)||drawing!==original&&!cached(drawing))throw new Error('종평면도를 선택하세요.');
|
||||
if(!existsSync(piers)||!/\.xlsx$/i.test(piers)||!cached(piers))throw new Error('교각 Excel을 선택하세요.');
|
||||
const id=randomUUID(),output=path.join(base,'data/output',`app_${id}`),job={output,logs:'',state:'running'};jobs.set(id,job);
|
||||
const python=existsSync(path.join(base,'.venv/Scripts/python.exe'))?path.join(base,'.venv/Scripts/python.exe'):'python';
|
||||
const python=resolvePython(root);
|
||||
const child=spawn(python,['-B','-u','-X','utf8','-m','pierzainer_ground.web_job',drawing,piers,output],{cwd:base,windowsHide:true,stdio:['ignore','pipe','pipe']});
|
||||
const append=(data:Buffer)=>{job.logs=(job.logs+data.toString('utf8')).slice(-200000);};child.stdout.on('data',append);child.stderr.on('data',append);
|
||||
child.once('error',error=>{job.state='error';job.logs+='\n'+error.message;});child.once('close',code=>{job.state=code===0?'done':'error';});reply(200,{id});return;
|
||||
child.once('error',error=>{job.state='error';job.logs+='\n'+describeSpawnError(error,python);});child.once('close',code=>{job.state=code===0?'done':'error';});reply(200,{id});return;
|
||||
}
|
||||
if(action==='result'){
|
||||
const job=jobs.get(body.id);if(!job)throw new Error('지반 생성 작업을 찾을 수 없습니다.');
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import './ground-popup.css';
|
||||
const API='/__pierzainer/ground-link';
|
||||
let importGround: ((text: string, name: string) => Promise<void>) | undefined;
|
||||
export function registerGroundImporter(importer: (text: string, name: string) => Promise<void>) { importGround = importer; }
|
||||
let pending:{file:File;folder?:string}|undefined,staged:Promise<string|undefined>=Promise.resolve(undefined),version=0;
|
||||
const request=async(action:string,body:object)=>{const r=await fetch(`${API}/${action}`,{method:'POST',headers:{'Content-Type':'application/json','X-Pierzainer-Action':'ground-link'},body:JSON.stringify(body)});const value=await r.json();if(!r.ok)throw new Error(value.error??'지반 프로그램 연동 실패');return value;};
|
||||
const base64=(file:File)=>new Promise<string>((resolve,reject)=>{const reader=new FileReader();reader.onerror=()=>reject(new Error('Excel 파일을 읽지 못했습니다.'));reader.onload=()=>resolve(String(reader.result).split(',')[1]);reader.readAsDataURL(file);});
|
||||
@@ -9,6 +11,10 @@ document.addEventListener('click',event=>{if(!(event.target as Element).closest(
|
||||
|
||||
let opening=false,running=false,launchSerial=0;
|
||||
let popup:HTMLDialogElement;
|
||||
window.addEventListener('pierzainer:project-changed',()=>{
|
||||
version++;launchSerial++;running=false;opening=false;
|
||||
if(popup){popup.close();popup.querySelector<HTMLElement>('[data-status]')!.textContent='프로젝트가 변경되어 이전 작업의 결과를 연결하지 않습니다.';}
|
||||
});
|
||||
function getPopup(){
|
||||
if(popup)return popup;
|
||||
popup=document.createElement('dialog');popup.className='ground-popup';popup.setAttribute('aria-labelledby','ground-popup-title');
|
||||
@@ -28,13 +34,13 @@ window.addEventListener('pierzainer:open-ground',async()=>{
|
||||
const drawingInput=dialog.querySelector<HTMLInputElement>('[data-drawing]')!,excelInput=dialog.querySelector<HTMLInputElement>('[data-excel]')!;
|
||||
drawingInput.value='';excelInput.value='';drawingInput.disabled=excelInput.disabled=false;dialog.querySelector('[data-drawing-name]')!.textContent='파일 확인 중…';dialog.querySelector('[data-excel-name]')!.textContent='파일 확인 중…';run.disabled=true;status.textContent='입력 파일 확인 중…';logs.textContent='';dialog.showModal();
|
||||
let stageId:string|undefined,drawing='';
|
||||
try{stageId=await staged;const prepared=await request('prepare',{stageId});drawing=prepared.drawing;
|
||||
try{stageId=await staged;const prepared=await request('prepare',{stageId});if(snapshot!==version||ticket!==launchSerial)return;drawing=prepared.drawing;
|
||||
const display=(kind:string,name:string,connected:boolean)=>{dialog.querySelector(`[data-${kind}-name]`)!.textContent=name;dialog.querySelector(`[data-pick="${kind}"]`)!.textContent=connected?'파일 변경':kind==='drawing'?'종평면도 선택':'Excel 선택';};
|
||||
const drawingName=drawing.split(/[\\/]/).pop()||'종평면도를 선택하세요.';const excelName=prepared.excelName||'교각 Excel을 선택하세요.';
|
||||
display('drawing',drawingName,!!drawing);display('excel',excelName,prepared.excelSelected);
|
||||
drawingInput.onchange=()=>display('drawing',drawingInput.files?.[0]?.name||drawingName,!!drawingInput.files?.[0]||!!drawing);
|
||||
excelInput.onchange=()=>display('excel',excelInput.files?.[0]?.name||excelName,!!excelInput.files?.[0]||prepared.excelSelected);status.textContent='입력을 확인하고 지반 생성을 누르세요.';run.disabled=false;
|
||||
}catch(error){status.textContent=(error as Error).message;opening=false;return;}
|
||||
}catch(error){if(snapshot!==version||ticket!==launchSerial)return;status.textContent=(error as Error).message;opening=false;return;}
|
||||
run.onclick=async()=>{
|
||||
if(snapshot!==version){status.textContent='프로젝트가 변경되었습니다. 닫고 다시 열어 주세요.';return;}
|
||||
run.disabled=true;drawingInput.disabled=excelInput.disabled=true;
|
||||
@@ -43,23 +49,26 @@ window.addEventListener('pierzainer:open-ground',async()=>{
|
||||
status.textContent='입력 파일 전달 중…';const chosen=drawingInput.files?.[0],excel=excelInput.files?.[0];
|
||||
if(chosen)drawing=await upload(chosen);if(!drawing)throw new Error('종평면도를 선택하세요.');
|
||||
const excelPath=excel?await upload(excel):undefined;
|
||||
if(snapshot!==version||ticket!==launchSerial)return;
|
||||
if(!excelPath&&!stageId)throw new Error('교각 Excel을 선택하세요.');
|
||||
const job=await request('launch',{stageId,drawing,excel:excelPath});running=true;status.textContent='지반·지도 생성 중… 닫아도 생성은 계속됩니다.';
|
||||
const job=await request('launch',{stageId,drawing,excel:excelPath});if(snapshot!==version||ticket!==launchSerial)return;running=true;status.textContent='지반·지도 생성 중… 닫아도 생성은 계속됩니다.';
|
||||
const deadline=Date.now()+30*60*1000;
|
||||
const poll=async()=>{
|
||||
if(snapshot!==version||ticket!==launchSerial){running=false;return;}
|
||||
if(snapshot!==version||ticket!==launchSerial)return;
|
||||
try{
|
||||
const result=await request('result',{id:job.id});logs.textContent=result.logs??'';logs.scrollTop=logs.scrollHeight;
|
||||
if(snapshot!==version||ticket!==launchSerial)return;
|
||||
if(result.state==='error')throw new Error('지반 생성 실패. 진행 로그를 확인하세요.');
|
||||
if(result.ready){
|
||||
const input=document.querySelector<HTMLInputElement>('#ground-import');
|
||||
status.textContent=input?'지반 생성 완료. 프로젝트에 불러왔습니다.':'지반 생성 완료. 지반·지층 화면으로 돌아오면 불러옵니다.';
|
||||
if(input){const transfer=new DataTransfer();transfer.items.add(new File([result.text],result.filename,{type:'application/json'}));input.files=transfer.files;input.dispatchEvent(new Event('change',{bubbles:true}));running=false;run.disabled=false;drawingInput.disabled=excelInput.disabled=false;return;}
|
||||
if(!importGround)throw new Error('프로젝트 지반모델 불러오기를 사용할 수 없습니다.');
|
||||
await importGround(result.text,result.filename);
|
||||
status.textContent='지반 생성 완료. 프로젝트 공통 모델로 불러왔습니다. 관리창에서 비교 후 적용하세요.';
|
||||
running=false;run.disabled=false;drawingInput.disabled=excelInput.disabled=false;return;
|
||||
}
|
||||
if(Date.now()>deadline)throw new Error('응답 대기 시간이 지났습니다. 결과 폴더를 확인하세요.');setTimeout(poll,2000);
|
||||
}catch(error){status.textContent=(error as Error).message;running=false;run.disabled=false;drawingInput.disabled=excelInput.disabled=false;}
|
||||
}catch(error){if(snapshot!==version||ticket!==launchSerial)return;status.textContent=(error as Error).message;running=false;run.disabled=false;drawingInput.disabled=excelInput.disabled=false;}
|
||||
};setTimeout(poll,1000);
|
||||
}catch(error){status.textContent=(error as Error).message;running=false;run.disabled=false;drawingInput.disabled=excelInput.disabled=false;}
|
||||
}catch(error){if(snapshot!==version||ticket!==launchSerial)return;status.textContent=(error as Error).message;running=false;run.disabled=false;drawingInput.disabled=excelInput.disabled=false;}
|
||||
};
|
||||
});
|
||||
// Closing hides the panel; the current job and its polling continue.
|
||||
|
||||
@@ -29,6 +29,18 @@ class TerrainData:
|
||||
stats: dict = field(default_factory=dict)
|
||||
|
||||
|
||||
def _exe_path_arg(exe: str, file: Path) -> str:
|
||||
"""Windows .exe를 WSL/Linux에서 부를 때 경로를 Windows 형식으로 바꾼다."""
|
||||
import os
|
||||
if os.name == "nt" or not exe.lower().endswith(".exe"):
|
||||
return str(file)
|
||||
wslpath = shutil.which("wslpath")
|
||||
if not wslpath:
|
||||
return str(file)
|
||||
result = subprocess.run([wslpath, "-w", str(Path(file).resolve())], check=False, capture_output=True, text=True)
|
||||
return result.stdout.strip() if result.returncode == 0 and result.stdout.strip() else str(file)
|
||||
|
||||
|
||||
def convert_dwg_to_dxf(dwg: str | Path, out_dir: str | Path) -> Path:
|
||||
"""LibreDWG의 dwg2dxf 로 DWG를 DXF로 바꾼다."""
|
||||
exe = shutil.which("dwg2dxf")
|
||||
@@ -55,7 +67,7 @@ def convert_dwg_to_dxf(dwg: str | Path, out_dir: str | Path) -> Path:
|
||||
temporary=out.with_name(out.stem+'.converting.dxf')
|
||||
try:
|
||||
temporary.unlink(missing_ok=True)
|
||||
result=subprocess.run([exe, "-y", "-o", str(temporary), str(dwg)], check=False, capture_output=True, timeout=300)
|
||||
result=subprocess.run([exe, "-y", "-o", _exe_path_arg(exe,temporary), _exe_path_arg(exe,dwg)], check=False, capture_output=True, timeout=300)
|
||||
if result.returncode!=0 or not temporary.is_file() or temporary.stat().st_size==0:
|
||||
raise RuntimeError(f"DWG 변환에 실패했습니다: {dwg}")
|
||||
temporary.replace(out)
|
||||
|
||||
@@ -12,11 +12,52 @@ import numpy as np
|
||||
from ezdxf import recover
|
||||
from pyproj import Transformer
|
||||
from pierzainer_ground.dxf_terrain import _densify,read_source_document
|
||||
from pierzainer_ground.config import GroundConfig
|
||||
from pierzainer_ground.dxf_terrain import read_terrain_dxf
|
||||
from pierzainer_ground.dtm import DTM
|
||||
|
||||
ROOT=Path(__file__).resolve().parent.parent
|
||||
|
||||
|
||||
def reject_height_outliers(entities, radius=15.0, max_dz=10.0, min_levels=3, same=1e-3):
|
||||
"""Drop height entities that disagree with the surrounding surveyed surface.
|
||||
|
||||
Drawings often keep plan symbols, hatching or design outlines at a stray
|
||||
constant elevation (e.g. EL 5.624 or -112.719 among 20-40 m contours).
|
||||
Each entity's height is compared with the median of the *distinct* nearby
|
||||
height levels (each level votes once, so a dense stray group of short
|
||||
ticks cannot outvote real contours); the worst height level is removed
|
||||
first and the test is repeated, so a large stray group cannot mask itself.
|
||||
Sparse areas fall back to a 3x search radius.
|
||||
Heights within `same` (1 mm) form one level, absorbing CAD float noise.
|
||||
Returns (keep flags, [(index, z, deviation)]).
|
||||
"""
|
||||
from scipy.spatial import cKDTree
|
||||
n=len(entities)
|
||||
keep=np.ones(n,bool)
|
||||
if n<3:return keep.tolist(),[]
|
||||
xy=np.vstack([e[0] for e in entities]);owner=np.concatenate([np.full(len(e[0]),i) for i,e in enumerate(entities)])
|
||||
zs=np.array([float(e[1]) for e in entities]);sample_z=zs[owner]
|
||||
tree=cKDTree(xy);rejected=[]
|
||||
near=lambda e,r:np.unique(np.concatenate(tree.query_ball_point(e[0],r)) if len(e[0]) else np.array([],int)).astype(int)
|
||||
neighbours=[near(e,radius) for e in entities];wide=[near(e,3*radius) for e in entities]
|
||||
for _ in range(64):
|
||||
alive=keep[owner];dev=np.zeros(n)
|
||||
for i in np.flatnonzero(keep):
|
||||
nb=neighbours[i];nb=nb[alive[nb]&(np.abs(sample_z[nb]-zs[i])>same)]
|
||||
if len(np.unique(np.round(sample_z[nb]/same)))<min_levels:nb=wide[i];nb=nb[alive[nb]&(np.abs(sample_z[nb]-zs[i])>same)]
|
||||
levels=np.unique(np.round(sample_z[nb]/same))*same
|
||||
# Peaks/pits legitimately exceed the median; require the height to
|
||||
# lie clearly outside the whole local range as well.
|
||||
outside=max(levels[0]-zs[i],zs[i]-levels[-1]) if len(levels) else 0.0
|
||||
if len(levels)>=min_levels and outside>max_dz/2:dev[i]=zs[i]-float(np.median(levels))
|
||||
worst=int(np.argmax(np.abs(dev)))
|
||||
if abs(dev[worst])<=max_dz:break
|
||||
group=np.flatnonzero(keep&(np.abs(zs-zs[worst])<=same)&(np.abs(dev)>max_dz))
|
||||
keep[group]=False;rejected.extend((int(i),float(zs[i]),float(dev[i])) for i in group)
|
||||
return keep.tolist(),rejected
|
||||
|
||||
|
||||
def read_survey_block(source, project, doc=None):
|
||||
"""Read actual height geometry in an explicitly selected survey block."""
|
||||
if doc is None:
|
||||
@@ -24,6 +65,33 @@ def read_survey_block(source, project, doc=None):
|
||||
block=doc.blocks.get(project['terrain_block'])
|
||||
if block is None:
|
||||
raise ValueError(f"측량 블록이 없습니다: {project['terrain_block']}")
|
||||
# A plan's modelspace mixes contours with borehole symbols and design
|
||||
# outlines that also carry Z. Prefer the established semantic reader when
|
||||
# this drawing declares known survey layers; retain the block reader for
|
||||
# original survey XREFs with their own layer conventions.
|
||||
cfg=GroundConfig(cad_axes=project.get('cad_axes','YX'),use_profile_points=False,
|
||||
z_min=project.get('z_min',0),z_max=project.get('z_max',200),
|
||||
contour_step=project['contour_step_m'],dedup_xy=project['dedup_m'])
|
||||
allowed=cfg.layers.spot_block+cfg.layers.spot_text+cfg.layers.contour
|
||||
if project.get('layers'):
|
||||
allowed=tuple(layer for layer in allowed if layer in project['layers'])
|
||||
if (block.name.lower()=='*model_space' and project.get('prefer_recognized_layers',True)
|
||||
and any(e.dxf.layer in allowed for e in block)):
|
||||
cfg.allowed_terrain_layers=allowed
|
||||
terrain=read_terrain_dxf(source,cfg,document=doc)
|
||||
a=terrain.points
|
||||
if project.get('exclude_zero'):
|
||||
a=a[np.abs(a[:,2])>1e-6]
|
||||
if len(a)<3:
|
||||
raise ValueError('인식된 측량 레이어에서 실제 표고점·등고선을 3점 이상 읽지 못했습니다.')
|
||||
if project.get('terrain_crs') and project.get('crs'):
|
||||
east,north=Transformer.from_crs(project['terrain_crs'],project['crs'],always_xy=True).transform(a[:,1],a[:,0])
|
||||
a=np.column_stack([north,east,a[:,2]])
|
||||
if not np.isfinite(a).all():
|
||||
raise ValueError('측량 레이어 좌표 변환 결과가 유효하지 않습니다.')
|
||||
sources=terrain.stats['by_source']
|
||||
return a,{'semantic_layer_filter':1,'survey_points':sources['spot'],
|
||||
'spot_text_points':sources['text'],'contour_points':sources['contour']},terrain.stats['contours']
|
||||
to_model=(Transformer.from_crs(project['terrain_crs'],project['crs'],always_xy=True)
|
||||
if project.get('terrain_crs') and project.get('crs') else None)
|
||||
points=[]; contours=[]; types=Counter()
|
||||
@@ -54,9 +122,11 @@ def read_survey_block(source, project, doc=None):
|
||||
if e.is_closed: xy.append(xy[0])
|
||||
contours.append((xy,float(a[0,2]),e.dxf.handle)); types['constant_height_polylines']+=1
|
||||
# POINT entities have priority over densified contour vertices.
|
||||
for xy,z,handle in contours:
|
||||
samples=_densify(xy,project['contour_step_m'])
|
||||
points.extend((x,y,z) for x,y in samples)
|
||||
sampled=[(np.asarray(_densify(xy,project['contour_step_m']),dtype=float).reshape(-1,2),z) for xy,z,handle in contours]
|
||||
sampled=[(np.array([[x,y]]),z) for x,y,z in points]+sampled
|
||||
keep,rejected=reject_height_outliers(sampled,project.get('outlier_radius_m',15.0),project.get('outlier_dz_m',10.0)) if project.get('reject_outliers',True) else ([True]*len(sampled),[])
|
||||
if rejected:types['rejected_height_outliers']=len(rejected)
|
||||
points=[(x,y,z) for (xy,z),ok in zip(sampled,keep) if ok for x,y in xy]
|
||||
a=np.array(points,dtype=float)
|
||||
if len(a)<3:
|
||||
raise ValueError('선택한 블록에서 실제 표고점·등고선을 3점 이상 읽지 못했습니다.')
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { describe,it,expect } from 'vitest';
|
||||
import path from 'node:path';
|
||||
import { resolvePython,describeSpawnError } from './python-runtime';
|
||||
|
||||
const root='/repo',venv=path.join(root,'pierzainer_ground','.venv');
|
||||
const has=(...files:string[])=>(file:string)=>files.includes(file);
|
||||
|
||||
describe('resolvePython',()=>{
|
||||
it('uses the Windows venv on win32',()=>{
|
||||
const exe=path.join(venv,'Scripts','python.exe');
|
||||
expect(resolvePython(root,'win32',has(exe),{})).toBe(exe);
|
||||
});
|
||||
it('uses the POSIX venv on Linux/WSL',()=>{
|
||||
const bin=path.join(venv,'bin','python');
|
||||
expect(resolvePython(root,'linux',has(bin),{})).toBe(bin);
|
||||
});
|
||||
it('falls back to PIERZAINER_PYTHON, then python3 / python',()=>{
|
||||
expect(resolvePython(root,'linux',has(),{PIERZAINER_PYTHON:'/opt/py'})).toBe('/opt/py');
|
||||
expect(resolvePython(root,'linux',has(),{})).toBe('python3');
|
||||
expect(resolvePython(root,'win32',has(),{})).toBe('python');
|
||||
});
|
||||
it('explains ENOENT instead of the raw spawn message',()=>{
|
||||
const error=Object.assign(new Error('spawn python ENOENT'),{code:'ENOENT'});
|
||||
expect(describeSpawnError(error,'python')).toContain('Python 실행 파일을 찾지 못했습니다');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
import { existsSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
/**
|
||||
* Python used by the local dev-server tools (ground engine, quantity workbook, DWG export).
|
||||
* Order: Windows venv → POSIX venv (WSL/Linux) → PIERZAINER_PYTHON → python3/python on PATH.
|
||||
*/
|
||||
export function resolvePython(root:string,platform:NodeJS.Platform=process.platform,exists:(file:string)=>boolean=existsSync,env:NodeJS.ProcessEnv=process.env):string{
|
||||
const venv=path.join(root,'pierzainer_ground','.venv');
|
||||
const candidates=platform==='win32'?[path.join(venv,'Scripts','python.exe')]:[path.join(venv,'bin','python'),path.join(venv,'Scripts','python.exe')];
|
||||
const found=candidates.find(file=>exists(file));if(found)return found;
|
||||
if(env.PIERZAINER_PYTHON)return env.PIERZAINER_PYTHON;
|
||||
return platform==='win32'?'python':'python3';
|
||||
}
|
||||
|
||||
/** Human-readable reason for a failed spawn (ENOENT otherwise shows only "spawn python ENOENT"). */
|
||||
export function describeSpawnError(error:NodeJS.ErrnoException,python:string):string{
|
||||
if(error.code==='ENOENT')return `Python 실행 파일을 찾지 못했습니다(${python}). pierzainer_ground/.venv를 만들고 requirements.txt를 설치하거나 PIERZAINER_PYTHON을 지정하세요.`;
|
||||
return error.message;
|
||||
}
|
||||
@@ -25,4 +25,46 @@ class SurveyPolylineTests(unittest.TestCase):
|
||||
doc=ezdxf.new();doc.modelspace().add_polyline3d([(1,1,20),(2,2,30),(3,3,40)])
|
||||
with self.assertRaises(ValueError):read_survey_block(Path('unused.dxf'),self.options(),doc=doc)
|
||||
|
||||
def contours(self,msp,levels):
|
||||
for k,z in enumerate(levels):
|
||||
msp.add_lwpolyline([(0,10*k),(200,10*k)],dxfattribs={'elevation':z})
|
||||
|
||||
def test_stray_constant_height_symbols_are_rejected(self):
|
||||
# Ungok case: dense short ticks at EL 5.624 and a few at -112.719 among 20-40 m contours.
|
||||
doc=ezdxf.new();msp=doc.modelspace();self.contours(msp,[28,29,30,31,32,33])
|
||||
for i in range(60):msp.add_lwpolyline([(40+2*i,20),(40+2*i,22)],dxfattribs={'elevation':5.624})
|
||||
for i in range(4):msp.add_lwpolyline([(100+i,25),(100+i,27)],dxfattribs={'elevation':-112.719})
|
||||
points,counts,_=read_survey_block(Path('unused.dxf'),self.options(),doc=doc)
|
||||
self.assertEqual(counts['rejected_height_outliers'],64)
|
||||
self.assertGreaterEqual(points[:,2].min(),28)
|
||||
|
||||
def test_real_peak_above_contours_is_kept(self):
|
||||
doc=ezdxf.new();msp=doc.modelspace();self.contours(msp,[70,72,74,76,78,80])
|
||||
msp.add_lwpolyline([(100,25),(100,27)],dxfattribs={'elevation':84})
|
||||
points,counts,_=read_survey_block(Path('unused.dxf'),self.options(),doc=doc)
|
||||
self.assertNotIn('rejected_height_outliers',counts);self.assertEqual(points[:,2].max(),84)
|
||||
|
||||
def test_known_plan_layers_exclude_near_height_bore_and_base(self):
|
||||
doc=ezdxf.new();msp=doc.modelspace()
|
||||
for y,z in [(0,30),(100,32)]:
|
||||
msp.add_lwpolyline([(0,y),(100,y)],dxfattribs={'layer':'CAA001','elevation':z})
|
||||
doc.blocks.new('SPOT').add_circle((0,0),.1)
|
||||
msp.add_blockref('SPOT',(20,50,31),dxfattribs={'layer':'CA002'})
|
||||
msp.add_text('30.5',dxfattribs={'layer':'CA0021','insert':(80,50)})
|
||||
for layer in ['CV-BORE-SYMB','CZ-BASE','CF-BRDG-NEWC']:
|
||||
msp.add_lwpolyline([(45,45),(55,45),(55,55)],dxfattribs={'layer':layer,'elevation':27.5})
|
||||
points,counts,_=read_survey_block(Path('unused.dxf'),self.options(),doc=doc)
|
||||
self.assertGreaterEqual(points[:,2].min(),30)
|
||||
self.assertEqual(counts['semantic_layer_filter'],1)
|
||||
self.assertEqual(counts['survey_points'],1)
|
||||
self.assertEqual(counts['spot_text_points'],1)
|
||||
self.assertTrue(any(np.array_equal(p,[50,20,31]) for p in points))
|
||||
self.assertTrue(any(np.array_equal(p,[50,80,30.5]) for p in points))
|
||||
|
||||
def test_known_layers_do_not_fall_back_to_symbols_when_empty(self):
|
||||
doc=ezdxf.new();msp=doc.modelspace()
|
||||
msp.add_lwpolyline([(0,0),(10,0)],dxfattribs={'layer':'CAA001','elevation':0})
|
||||
msp.add_lwpolyline([(0,0),(10,0),(10,10)],dxfattribs={'layer':'CV-BORE-SYMB','elevation':28})
|
||||
with self.assertRaises(ValueError):read_survey_block(Path('unused.dxf'),self.options(),doc=doc)
|
||||
|
||||
if __name__=='__main__':unittest.main()
|
||||
Regular → Executable
File mode changed.
Regular → Executable
File mode changed.
Regular → Executable
File mode changed.
Regular → Executable
File mode changed.
Regular → Executable
File mode changed.
Regular → Executable
File mode changed.
Regular → Executable
File mode changed.
Regular → Executable
File mode changed.
Regular → Executable
File mode changed.
Regular → Executable
File mode changed.
@@ -0,0 +1,64 @@
|
||||
// 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 preview=process.argv.includes('--horizontal-preview');
|
||||
const lowerPreview=process.argv.includes('--lower-preview');
|
||||
const out = path.resolve(__dirname, lowerPreview?'../.verify/landing-lower-20261109':process.argv.includes('--all')?'../.verify/landing-hero-20261109':'../.verify/landing-horizontal-20261109');
|
||||
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: process.env.PIERZAINER_URL || 'http://127.0.0.1:5173/' });
|
||||
await until(`typeof window.openSampleProject==='function'`);
|
||||
const projectPath=process.env.OKSEONG_PROJECT||path.resolve(__dirname,'../.verify/ground-abutments-20261109/saved.pzr.json');
|
||||
const projectText=fs.readFileSync(projectPath,'utf8');
|
||||
const hash=require('node:crypto').createHash('sha256').update(projectText).digest('hex');
|
||||
await ev(`window.__heroProject=JSON.parse(${JSON.stringify(projectText)})`);
|
||||
const variants=lowerPreview ? [['lower-33',.65,0,30,true,true],['lower-27',.5,0,30,true,true],['lower-19',.35,0,30,true,true],['lower-27-cutaway',.5,0,30,true,true,8]] : preview ? [['horizontal',.85,0,30,true],['horizontal-smooth',.85,0,30,true,true]] : process.argv.includes('--all') ? [['low',.35,0],['balanced',.55,.12],['high',.8,-.12],['trim30',.55,.12,30],['trim20',.55,.12,20],['refined',.85,.12,30,true]] : [['horizontal-smooth',.85,0,30,true,true]];
|
||||
for(const [name,elevation,along,margin,refined,smooth,cutaway] of variants){
|
||||
const result=await ev(`(async()=>{const {renderLandingHero}=await import('/scripts/render-landing-hero.ts');return renderLandingHero(window.__heroProject,${elevation},${along},${margin},${refined},${smooth},${cutaway});})()`);
|
||||
for(const type of ['png','webp'])fs.writeFileSync(path.join(out,name+'.'+type),Buffer.from(result[type].split(',')[1],'base64'));
|
||||
fs.writeFileSync(path.join(out,name+'.json'),JSON.stringify({...result.metadata,sourceSha256:hash},null,2));
|
||||
check(name+' includes six piers and two abutments',result.metadata.piers===6&&result.metadata.abutments===2&&result.metadata.points.length===8);
|
||||
check(name+' all projected markers stay inside image',result.metadata.points.every(p=>p.x>0&&p.x<result.metadata.width&&p.y>0&&p.y<result.metadata.height));
|
||||
check(name+' light samples actual terrain densely',result.metadata.terrainFlow.filter(Boolean).length>100&&result.metadata.terrainFlow.every(p=>!p||Math.abs(p.world[1]-p.el-.12)<.0001));
|
||||
if(refined)check('both road extensions contain continuous terrain-supported sections',result.metadata.roads.length===2&&result.metadata.roads.every(r=>r.missingSections===0&&r.validSections===81&&r.triangles>0));
|
||||
if(smooth){
|
||||
check('outline trims real pixels without filling missing terrain',result.metadata.outline.addedOutsideSourcePixels===0&&result.metadata.outline.retainedTerrainRatio>.8&&result.metadata.outline.retainedTerrainRatio<.99);
|
||||
check('smooth outline preserves all eight structure tops',await ev(`(()=>{const c=document.createElement('canvas');c.width=${result.metadata.width*2};c.height=${result.metadata.height*2};return new Promise(resolve=>{const i=new Image();i.onload=()=>{const ctx=c.getContext('2d');ctx.drawImage(i,0,0);resolve(${JSON.stringify(result.metadata.points)}.every(p=>{let n=0;for(let x=-8;x<=8;x++)for(let y=0;y<30;y++)n+=ctx.getImageData(Math.round(p.x*2+x),Math.round(p.y*2+y),1,1).data[3]>0;return n>15;}));};i.src=${JSON.stringify(result.png)};});})()`));
|
||||
}
|
||||
}
|
||||
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,42 @@
|
||||
"""Create a review sheet from native Three.js captures; validate local structure crops."""
|
||||
import json
|
||||
from pathlib import Path
|
||||
from PIL import Image, ImageChops, ImageDraw, ImageFont
|
||||
|
||||
root = Path(__file__).resolve().parents[1]
|
||||
out = root / '.verify/landing-horizontal-20261109'
|
||||
metadata = json.loads((out / 'horizontal-smooth.json').read_text())
|
||||
raw = Image.open(out / 'horizontal.png').convert('RGBA')
|
||||
smooth = Image.open(out / 'horizontal-smooth.png').convert('RGBA')
|
||||
checks = []
|
||||
for point in metadata['points']:
|
||||
x, y = point['x'] * 2, point['y'] * 2
|
||||
box = (int(x - 48), int(y - 8), int(x + 48), int(y + 120))
|
||||
assert ImageChops.difference(raw.crop(box), smooth.crop(box)).getbbox() is None, point['name']
|
||||
checks.append(point['name'] + ' local structure pixels unchanged')
|
||||
assert metadata['along'] == 0
|
||||
assert metadata['outline']['addedOutsideSourcePixels'] == 0
|
||||
checks.extend(['horizontal camera has zero along-axis component', 'no pixels added outside source'])
|
||||
|
||||
sheet = Image.new('RGB', (1600, 1050), '#f5f8fb')
|
||||
draw = ImageDraw.Draw(sheet)
|
||||
font = ImageFont.truetype('/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf', 23)
|
||||
small = ImageFont.truetype('/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf', 17)
|
||||
previous = out / 'before-horizontal/okseong-model-hero.png'
|
||||
panels = [
|
||||
('PREVIOUS / diagonal view, rough survey boundary', previous if previous.exists() else root / '랜딩페이지/assets/okseong-model-hero.png'),
|
||||
('PROPOSAL / horizontal orthographic view, inward curved boundary', out / 'horizontal-smooth.png'),
|
||||
]
|
||||
for i, (label, path) in enumerate(panels):
|
||||
y = 25 + i * 510
|
||||
draw.rounded_rectangle((20, y, 1580, y + 480), radius=20, fill='white', outline='#dce4ed')
|
||||
draw.text((45, y + 20), label, font=font, fill='#19334d')
|
||||
image = Image.open(path).convert('RGBA')
|
||||
image = image.crop(image.getbbox())
|
||||
image.thumbnail((1500, 380), Image.Resampling.LANCZOS)
|
||||
sheet.paste(image, ((1600 - image.width) // 2, y + 80 + (380 - image.height) // 2), image)
|
||||
if i == 1:
|
||||
draw.text((45, y + 445), '6 piers + 2 abutments preserved / original survey unchanged / preview only', font=small, fill='#52667a')
|
||||
sheet.save(out / 'comparison.png')
|
||||
(out / 'review-report.json').write_text(json.dumps({'result': 'PASS', 'checks': checks}, indent=2))
|
||||
print(f'PASS {len(checks)} review checks; {out / "comparison.png"}')
|
||||
@@ -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,57 @@
|
||||
import * as THREE from 'three';
|
||||
|
||||
/** Rounded capture footprint; the underlying survey grid remains untouched. */
|
||||
export function roundedTerrainPlanes(axis: THREE.Vector3, start: number, end: number, halfWidth: number, radius: number) {
|
||||
const lateral = new THREE.Vector3(-axis.z, 0, axis.x);
|
||||
const corners = [[end-radius,halfWidth-radius,0],[start+radius,halfWidth-radius,90],[start+radius,-halfWidth+radius,180],[end-radius,-halfWidth+radius,270]];
|
||||
const points: [number,number][]=[];
|
||||
for(const [s,t,angle] of corners) for(let i=0;i<=8;i++) {
|
||||
const a=(angle+i*90/8)*Math.PI/180;
|
||||
points.push([s+radius*Math.cos(a),t+radius*Math.sin(a)]);
|
||||
}
|
||||
return points.map((p,i)=>{
|
||||
const q=points[(i+1)%points.length],ds=q[0]-p[0],dt=q[1]-p[1];
|
||||
const normal=axis.clone().multiplyScalar(-dt).addScaledVector(lateral,ds).normalize();
|
||||
const origin=axis.clone().multiplyScalar(p[0]).addScaledVector(lateral,p[1]);
|
||||
return new THREE.Plane(normal,-normal.dot(origin));
|
||||
});
|
||||
}
|
||||
|
||||
/** Landing-only road ribbons, joining the photographed road to ground at each abutment. */
|
||||
export function buildRoadExtensions(terrain: THREE.Mesh, anchors: {name:string;kind:string;point:THREE.Vector3}[], axis: THREE.Vector3, top: number) {
|
||||
const piers=anchors.filter(a=>a.kind==='pier').toSorted((a,b)=>a.point.dot(axis)-b.point.dot(axis));
|
||||
const abuts=anchors.filter(a=>a.kind==='abutment').toSorted((a,b)=>a.point.dot(axis)-b.point.dot(axis));
|
||||
if(piers.length<2||abuts.length!==2)throw new Error('Road extensions require two abutments and at least two piers');
|
||||
const group=new THREE.Group();group.name='landing-road-extensions';
|
||||
const ray=new THREE.Raycaster(),down=new THREE.Vector3(0,-1,0),widthM=8;
|
||||
const sample=(x:number,z:number)=>{
|
||||
ray.set(new THREE.Vector3(x,top+100,z),down);
|
||||
return ray.intersectObject(terrain,false)[0]?.point;
|
||||
};
|
||||
const descriptions=[];
|
||||
const ends=[{name:'A1–P1',points:[abuts[0].point,piers[0].point,piers[1].point],from:0,to:.58},
|
||||
{name:'P6–A2',points:[piers.at(-2)!.point,piers.at(-1)!.point,abuts[1].point],from:.42,to:1}];
|
||||
for(const end of ends){
|
||||
const curve=new THREE.CatmullRomCurve3(end.points.map(p=>new THREE.Vector3(p.x,0,p.z)),false,'catmullrom',.2);
|
||||
const steps=80,positions:number[]=[],colors:number[]=[],indices:number[]=[];
|
||||
let previous=-1,validSections=0,missingSections=0;
|
||||
for(let i=0;i<=steps;i++){
|
||||
const t=i/steps,u=end.from+(end.to-end.from)*t,p=curve.getPoint(u),tangent=curve.getTangent(u).normalize();
|
||||
const cross=new THREE.Vector3(-tangent.z,0,tangent.x);
|
||||
const offsets=Array.from({length:17},(_,j)=>-widthM/2+widthM*j/16);
|
||||
const hits=offsets.map(o=>sample(p.x+cross.x*o,p.z+cross.z*o));
|
||||
if(hits.some(h=>!h)){previous=-1;missingSections++;continue;}
|
||||
const first=positions.length/3;
|
||||
const blend=end.from===0?1-THREE.MathUtils.smoothstep(t,.72,1):THREE.MathUtils.smoothstep(t,0,.28);
|
||||
const color=new THREE.Color('#dcd8c7').multiplyScalar(1+Math.sin(i*2.33)*.018);
|
||||
hits.forEach((hit,j)=>{positions.push(hit!.x,hit!.y+.15,hit!.z);colors.push(color.r,color.g,color.b,blend*(j===0||j===offsets.length-1?0:.96));});
|
||||
if(previous>=0)for(let j=0;j<offsets.length-1;j++)indices.push(previous+j,first+j,previous+j+1,previous+j+1,first+j,first+j+1);
|
||||
previous=first;validSections++;
|
||||
}
|
||||
const geometry=new THREE.BufferGeometry();geometry.setAttribute('position',new THREE.Float32BufferAttribute(positions,3));geometry.setAttribute('color',new THREE.Float32BufferAttribute(colors,4));geometry.setIndex(indices);geometry.computeVertexNormals();
|
||||
const mesh=new THREE.Mesh(geometry,new THREE.MeshBasicMaterial({vertexColors:true,side:THREE.DoubleSide,transparent:true,depthWrite:false,polygonOffset:true,polygonOffsetFactor:-2,polygonOffsetUnits:-2}));
|
||||
mesh.name=end.name;mesh.renderOrder=2;group.add(mesh);
|
||||
descriptions.push({name:end.name,widthM,validSections,missingSections,triangles:indices.length/3,visualizationOnly:true});
|
||||
}
|
||||
return {group,descriptions};
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
/** Capture-only silhouette trimming. Samples real alpha; never fills missing survey data. */
|
||||
export function smoothLandingOutline(source: HTMLCanvasElement, terrain: HTMLCanvasElement, preserveExposedStructures = false) {
|
||||
const width=source.width,height=source.height,scale=width/2172;
|
||||
const alpha=terrain.getContext('2d')!.getImageData(0,0,width,height).data;
|
||||
const tops:number[]=[],bottoms:number[]=[];
|
||||
for(let x=0;x<width;x++) {
|
||||
let top=height,bottom=-1;
|
||||
for(let y=0;y<height;y++)if(alpha[(y*width+x)*4+3]>240){top=Math.min(top,y);bottom=y;}
|
||||
tops.push(top);bottoms.push(bottom);
|
||||
}
|
||||
const support=tops.map((top,x)=>bottoms[x]-top>60*scale?x:-1).filter(x=>x>=0);
|
||||
if(!support.length)throw new Error('No continuous terrain silhouette');
|
||||
const left=support[0]+Math.ceil(12*scale),right=support.at(-1)!-Math.ceil(12*scale);
|
||||
const inset=Math.ceil(4*scale),radius=Math.ceil(18*scale),average=Math.ceil(8*scale);
|
||||
const eroded=(values:number[],upper:boolean)=>values.map((_,x)=>{
|
||||
const local=values.slice(Math.max(0,x-radius-average),Math.min(width,x+radius+average+1));
|
||||
return (upper?Math.max(...local):Math.min(...local))+(upper?inset:-inset);
|
||||
});
|
||||
const upper=eroded(tops,true),lower=eroded(bottoms,false);
|
||||
const mean=(values:number[],x:number)=>{
|
||||
const samples=values.slice(Math.max(0,x-average),Math.min(width,x+average+1));
|
||||
return samples.reduce((a,b)=>a+b,0)/samples.length;
|
||||
};
|
||||
const points:[number,number][]=[];
|
||||
const cap=Math.ceil(24*scale),step=Math.ceil(12*scale);
|
||||
for(let x=left+cap;x<=right-cap;x+=step)points.push([x,mean(upper,x)]);
|
||||
points.push([right-cap,mean(upper,right-cap)],[right,mean(upper,right-cap)+cap],[right,mean(lower,right-cap)-cap],[right-cap,mean(lower,right-cap)]);
|
||||
for(let x=right-cap-step;x>=left+cap;x-=step)points.push([x,mean(lower,x)]);
|
||||
points.push([left+cap,mean(lower,left+cap)],[left,mean(lower,left+cap)-cap],[left,mean(upper,left+cap)+cap],[left+cap,mean(upper,left+cap)]);
|
||||
const mid=(a:[number,number],b:[number,number])=>[(a[0]+b[0])/2,(a[1]+b[1])/2];
|
||||
const first=mid(points.at(-1)!,points[0]);
|
||||
let path=`M${first[0]} ${first[1]}`;
|
||||
points.forEach((p,i)=>{const next=mid(p,points[(i+1)%points.length]);path+=`Q${p[0]} ${p[1]} ${next[0]} ${next[1]}`;});path+='Z';
|
||||
const canvas=document.createElement('canvas');canvas.width=width;canvas.height=height;
|
||||
const ctx=canvas.getContext('2d')!;ctx.drawImage(source,0,0);
|
||||
const original=ctx.getImageData(0,0,width,height).data;
|
||||
ctx.globalCompositeOperation='destination-in';ctx.fill(new Path2D(path));
|
||||
const outputImage=ctx.getImageData(0,0,width,height),output=outputImage.data;
|
||||
// A cutaway may expose piles beyond the terrain silhouette. Preserve their rendered pixels.
|
||||
if(preserveExposedStructures){
|
||||
for(let i=0;i<alpha.length;i+=4)if(alpha[i+3]===0&&original[i+3]>0)output.set(original.subarray(i,i+4),i);
|
||||
ctx.putImageData(outputImage,0,0);
|
||||
}
|
||||
// Compare to terrain alpha only: smoothing should remove its rough border, not invent pixels.
|
||||
let before=0,after=0,addedPixels=0;
|
||||
for(let i=3;i<alpha.length;i+=4){if(alpha[i]>240){before++;if(output[i]>240)after++;}if(original[i]===0&&output[i]>0)addedPixels++;}
|
||||
return {canvas,metadata:{method:'inward-alpha-envelope-quadratic',path,retainedTerrainRatio:after/before,addedOutsideSourcePixels:addedPixels}};
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
"""Promote one rendered camera variant; preserve the previous landing image."""
|
||||
import json
|
||||
import shutil
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
root = Path(__file__).resolve().parents[1]
|
||||
assets = root / '랜딩페이지/assets'
|
||||
variant = sys.argv[1] if len(sys.argv) > 1 else 'horizontal-smooth'
|
||||
if variant not in ('balanced', 'low', 'high', 'trim30', 'trim20', 'refined', 'horizontal-smooth'):
|
||||
raise SystemExit('Unknown camera variant')
|
||||
source = root / ('.verify/landing-horizontal-20261109' if variant == 'horizontal-smooth' else '.verify/landing-hero-20261109')
|
||||
metadata = json.loads((source / f'{variant}.json').read_text())
|
||||
if variant == 'horizontal-smooth':
|
||||
assert metadata['along'] == 0 and metadata['outline']['addedOutsideSourcePixels'] == 0
|
||||
assert metadata['piers'] == 6 and metadata['abutments'] == 2
|
||||
# Full curve control points remain in the capture audit, outside the service bundle.
|
||||
metadata['outline'].pop('path')
|
||||
backup = source / ('before-horizontal' if variant == 'horizontal-smooth' else 'before-refined' if variant == 'refined' else 'before-trim30')
|
||||
backup.mkdir(exist_ok=True)
|
||||
for name in ('okseong-model-hero.png', 'okseong-model-hero.webp', 'okseong-hero-capture.ts'):
|
||||
if (assets / name).exists() and not (backup / name).exists():
|
||||
shutil.copyfile(assets / name, backup / name)
|
||||
for kind in ('png', 'webp'):
|
||||
shutil.copyfile(source / f'{variant}.{kind}', assets / f'okseong-model-hero.{kind}')
|
||||
metadata['terrainFlow'] = [None if p is None else {'x': p['x'], 'y': p['y']} for p in metadata['terrainFlow']]
|
||||
(assets / 'okseong-hero-capture.ts').write_text(
|
||||
'// Generated by scripts/promote-landing-hero.py from actual model projection.\n'
|
||||
'export const heroCapture = ' + json.dumps(metadata, ensure_ascii=False, indent=2) + ' as const;\n'
|
||||
)
|
||||
@@ -0,0 +1,51 @@
|
||||
"""Regenerate the user's two inputs in a separate folder, reusing exact caches."""
|
||||
import hashlib
|
||||
import json
|
||||
import shutil
|
||||
import sys
|
||||
from pathlib import Path
|
||||
import numpy as np
|
||||
|
||||
root=Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0,str(root/'pierzainer_ground'))
|
||||
from pierzainer_ground.drawing_job import DrawingJob,build_drawing
|
||||
|
||||
old=root/'pierzainer_ground/data/output/app_b614afc0-6829-4aef-96e3-3ff1365ff5db'
|
||||
target=root/'pierzainer_ground/data/output/ungok_corrected_20261110'
|
||||
evidence=root/'.verify/ungok-terrain-fixed-20261110'
|
||||
evidence.mkdir(parents=True,exist_ok=True)
|
||||
settings=json.loads((old/'generation_settings.json').read_text())
|
||||
old_file=old/'ground_model_3d_with_maps.json'
|
||||
sources=[Path(settings['drawing']),Path(settings['piers']),old_file]
|
||||
digest=lambda p:hashlib.sha256(p.read_bytes()).hexdigest()
|
||||
before={str(p):digest(p) for p in sources}
|
||||
if (target/'ground_model_3d_with_maps.json').exists():
|
||||
raise SystemExit('Corrected result already exists; preserve it rather than overwrite.')
|
||||
(target/'cache').mkdir(parents=True,exist_ok=True)
|
||||
for path in (old/'cache').iterdir():
|
||||
if path.name.endswith(('.dxf','.dxf.source.json')) or path.name=='map_layers.json':
|
||||
shutil.copyfile(path,target/'cache'/path.name)
|
||||
settings.update(output=str(target),allow_download=False)
|
||||
log=[]
|
||||
data=build_drawing(DrawingJob(**settings),lambda text:(log.append(text),print(text,flush=True)))
|
||||
original=json.loads(old_file.read_text())
|
||||
def metrics(d):
|
||||
g=d['terrain_grid'];z=np.array([np.nan if v is None else v for v in g['z']]).reshape(g['ny'],g['nx'])
|
||||
edges=np.concatenate([np.abs(np.diff(z,axis=0)).ravel(),np.abs(np.diff(z,axis=1)).ravel()]);edges=edges[np.isfinite(edges)]
|
||||
return {'valid_nodes':int(np.isfinite(z).sum()),'min_el':float(np.nanmin(z)),'max_el':float(np.nanmax(z)),
|
||||
'max_adjacent_dz':float(edges.max()),'edges_over_5m':int((edges>5).sum()),'edges_over_8m':int((edges>8).sum())}
|
||||
checks={
|
||||
'original_inputs_and_previous_result_unchanged':all(digest(p)==before[str(p)] for p in sources),
|
||||
'all_16_centers_have_ground':len(data['piers'])==16 and all(p['ground']['center'] is not None for p in data['piers']),
|
||||
'same_structure_ids_and_coordinates':[(p['id'],p['X'],p['Y']) for p in data['piers']]==[(p['id'],p['X'],p['Y']) for p in original['piers']],
|
||||
'same_grid_layout':all(data['terrain_grid'][k]==original['terrain_grid'][k] for k in ('origin','step','nx','ny')),
|
||||
'semantic_source_filter_used':data['terrain']['source_counts'].get('semantic_layer_filter')==1,
|
||||
'no_adjacent_jumps_over_5m':metrics(data)['edges_over_5m']==0,
|
||||
'both_embedded_maps_retained':data['map_status']['status']=='mapped' and {l['id'] for l in data['visualization']['layers']}=={'satellite','ground'},
|
||||
'original_source_provenance_matches':data['surface_provenance']['sha256']==before[str(sources[0])] and data['surface_provenance']['pier_sha256']==before[str(sources[1])],
|
||||
}
|
||||
report={'result':'PASS' if all(checks.values()) else 'FAIL','checks':checks,'before':metrics(original),'after':metrics(data),'output':str(target/'ground_model_3d_with_maps.json'),'inputs_sha256':before,'terrain':data['terrain'],'log':log,
|
||||
'centers':[{'id':new['id'],'before':prior['ground']['center'],'after':new['ground']['center']} for prior,new in zip(original['piers'],data['piers'])]}
|
||||
(evidence/'report.json').write_text(json.dumps(report,ensure_ascii=False,indent=2))
|
||||
print(json.dumps(report,ensure_ascii=False))
|
||||
if not all(checks.values()):raise SystemExit(1)
|
||||
@@ -0,0 +1,123 @@
|
||||
import * as THREE from 'three';
|
||||
import type { ProjectFile } from '../src/core/project';
|
||||
import { buildPier } from '../src/core/geometry';
|
||||
import { buildAllPiers } from '../src/view/all-piers';
|
||||
import { buildTerrainMapGroup, disposeTerrainMapGroup } from '../src/view/terrain-map';
|
||||
import { buildAbut } from '../src/core/abut/geometry';
|
||||
import { 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 { abutSoles, resolveBearingRule } from '../src/core/abut/bearing-table';
|
||||
import { bridgePairs } from '../src/core/bridge-pairs';
|
||||
import { buildRoadExtensions, roundedTerrainPlanes } from './landing-ground-treatment';
|
||||
import { smoothLandingOutline } from './landing-smooth-outline';
|
||||
|
||||
/** Actual structure/terrain capture with optional landing-only road extensions. */
|
||||
export async function renderLandingHero(project: ProjectFile, elevation: number, along = 0, endMarginM?: number, refined = false, smoothOutline = false, cutawayFrontM?: number) {
|
||||
const width = 2172, height = refined ? 870 : 724, aspect=width/height;
|
||||
const renderer = new THREE.WebGLRenderer({ alpha: true, antialias: true, preserveDrawingBuffer: true });
|
||||
renderer.setPixelRatio(2); renderer.setSize(width, height); renderer.setClearColor(0xffffff, 0);
|
||||
renderer.localClippingEnabled = true;
|
||||
const scene = new THREE.Scene();
|
||||
scene.add(new THREE.HemisphereLight(0xffffff, 0x7c8a9a, 2.1));
|
||||
const sun = new THREE.DirectionalLight(0xffffff, 2.4); sun.position.set(-100, 250, 150); scene.add(sun);
|
||||
const pairs=bridgePairs(project.piers.map(p=>p.input),project.abutments??[]);
|
||||
const entries = project.piers.map(p => ({ name: p.input.name, model: buildPier(p.input,{pairs}) }));
|
||||
const pivot = entries[0].model.pivot, grid = project.groundModel?.terrainGrid;
|
||||
const all = buildAllPiers(entries, grid); scene.add(all.piers); if (all.terrain) scene.add(all.terrain);
|
||||
const anchors = entries.map(e => ({ name: e.name, kind: 'pier', point: new THREE.Vector3(e.model.pivot.e - pivot.e, e.model.copingRefTop + 1, -(e.model.pivot.n - pivot.n)) }));
|
||||
for (const rec of project.abutments ?? []) {
|
||||
const model = buildAbut(toAbutInput(rec));
|
||||
const members = buildMembers(model, { bottomEl: rec.foundationBottomEl, pileLengthMm: rec.pileLengthMm }, rec.shape);
|
||||
const soles = abutSoles(model, resolveBearingRule(rec.bearingRule), rec.bearingHeightMm, {
|
||||
pairs,slopeI:rec.slopeI,
|
||||
inputAzimuthDeg: Object.fromEntries(rec.bearings.filter(b => b.azimuthDeg !== undefined).map(b => [String(b.no), b.azimuthDeg!])),
|
||||
});
|
||||
scene.add(buildAbutGroup(model, members, buildWings(model, members, rec.wings, rec.wingDims), pivot, rec.groundEl, grid, rec.slab, soles, undefined, !!all.terrain));
|
||||
anchors.push({ name: rec.name, kind: 'abutment', point: new THREE.Vector3(rec.ip2.e-pivot.e, Math.max(members.parapetTopStartEl, members.parapetTopEndEl)+1, -(rec.ip2.n-pivot.n)) });
|
||||
}
|
||||
const layers = project.groundModel?.mapLayers ?? [];
|
||||
await Promise.all(layers.map(l => new Promise<void>((resolve, reject) => { const i = new Image(); i.onload = () => resolve(); i.onerror = reject; i.src = l.image; })));
|
||||
let mapError = false;
|
||||
const maps = buildTerrainMapGroup(layers, pivot, 0, 'satellite', () => { mapError = true; }, grid); scene.add(maps);
|
||||
const deadline=performance.now()+15000;
|
||||
while (!maps.children.every(m => {
|
||||
const image=((m as THREE.Mesh).material as THREE.MeshBasicMaterial).map!.image as HTMLImageElement|null;
|
||||
return image?.complete && image.naturalWidth>0;
|
||||
})) {
|
||||
if(mapError || performance.now()>deadline) throw new Error('Map texture loading failed or timed out');
|
||||
await new Promise(resolve=>setTimeout(resolve,20));
|
||||
}
|
||||
if (mapError) throw new Error('Map texture loading failed');
|
||||
scene.updateMatrixWorld(true);
|
||||
const box = new THREE.Box3().setFromObject(scene), target = box.getCenter(new THREE.Vector3());
|
||||
const axis = new THREE.Vector3(entries.at(-1)!.model.pivot.e-pivot.e, 0, -(entries.at(-1)!.model.pivot.n-pivot.n)).normalize();
|
||||
if (endMarginM !== undefined) {
|
||||
const ends=anchors.filter(a=>a.kind==='abutment').map(a=>a.point.dot(axis));
|
||||
if(ends.length!==2)throw new Error('End margin requires both abutments');
|
||||
const planes=refined ? roundedTerrainPlanes(axis,Math.min(...ends)-endMarginM,Math.max(...ends)+endMarginM,56,18) : [new THREE.Plane(axis.clone(),-Math.min(...ends)+endMarginM),new THREE.Plane(axis.clone().negate(),Math.max(...ends)+endMarginM)];
|
||||
if(all.terrain)(all.terrain.material as THREE.Material).clippingPlanes=planes;
|
||||
maps.children.forEach(m=>{((m as THREE.Mesh).material as THREE.Material).clippingPlanes=planes;});
|
||||
}
|
||||
const road=refined&&all.terrain ? buildRoadExtensions(all.terrain,anchors,axis,box.max.y) : null;
|
||||
if(road)scene.add(road.group);
|
||||
if(cutawayFrontM!==undefined){
|
||||
const front=new THREE.Vector3(-axis.z,0,axis.x);
|
||||
const plane=new THREE.Plane(front.negate(),cutawayFrontM);
|
||||
for(const mesh of [all.terrain,...maps.children])if(mesh){
|
||||
const material=(mesh as THREE.Mesh).material as THREE.Material;
|
||||
material.clippingPlanes=[...(material.clippingPlanes??[]),plane];
|
||||
}
|
||||
}
|
||||
scene.updateMatrixWorld(true);
|
||||
const direction = new THREE.Vector3(-axis.z, elevation, axis.x).addScaledVector(axis, along).normalize();
|
||||
const camera = new THREE.OrthographicCamera(-1, 1, 1, -1, .1, 10000);
|
||||
camera.position.copy(target).addScaledVector(direction, 2000); camera.lookAt(target); camera.updateMatrixWorld(true);
|
||||
const bounds = new THREE.Box3();
|
||||
scene.traverse(obj => {
|
||||
if (!(obj instanceof THREE.Mesh)) return;
|
||||
for (let p: THREE.Object3D | null = obj; p; p=p.parent) if (!p.visible) return;
|
||||
const pos = obj.geometry.getAttribute('position');
|
||||
for (let i=0;i<pos.count;i++) {
|
||||
const point=new THREE.Vector3().fromBufferAttribute(pos,i).applyMatrix4(obj.matrixWorld);
|
||||
const material: THREE.Material=Array.isArray(obj.material)?obj.material[0]:obj.material;
|
||||
if(material.clippingPlanes?.some(p=>p.distanceToPoint(point)<0))continue;
|
||||
bounds.expandByPoint(point.applyMatrix4(camera.matrixWorldInverse));
|
||||
}
|
||||
});
|
||||
const w = Math.max(bounds.max.x-bounds.min.x, (bounds.max.y-bounds.min.y)*aspect)*1.12, h=w/aspect;
|
||||
const cx=(bounds.max.x+bounds.min.x)/2, cy=(bounds.max.y+bounds.min.y)/2;
|
||||
camera.left=cx-w/2; camera.right=cx+w/2; camera.top=cy+h/2; camera.bottom=cy-h/2; camera.updateProjectionMatrix();
|
||||
let terrainCanvas:HTMLCanvasElement|undefined;
|
||||
if(smoothOutline){
|
||||
const hidden=scene.children.filter(o=>o!==all.terrain&&o!==maps&&o!==road?.group&&!(o instanceof THREE.Light)&&o.visible);
|
||||
hidden.forEach(o=>{o.visible=false;});renderer.render(scene,camera);
|
||||
terrainCanvas=document.createElement('canvas');terrainCanvas.width=renderer.domElement.width;terrainCanvas.height=renderer.domElement.height;
|
||||
terrainCanvas.getContext('2d')!.drawImage(renderer.domElement,0,0);
|
||||
hidden.forEach(o=>{o.visible=true;});
|
||||
}
|
||||
renderer.render(scene, camera);
|
||||
const points=anchors.map(a => { const p=a.point.clone().project(camera); return { name:a.name,kind:a.kind,x:+((p.x+1)*width/2).toFixed(2),y:+((1-p.y)*height/2).toFixed(2) }; }).sort((a,b)=>a.x-b.x);
|
||||
const ordered=anchors.toSorted((a,b)=>a.point.clone().project(camera).x-b.point.clone().project(camera).x);
|
||||
const ray=new THREE.Raycaster(), down=new THREE.Vector3(0,-1,0), spacing=(grid?.step??5)/2;
|
||||
const terrainFlow: ({x:number;y:number;el:number;world:[number,number,number]}|null)[]=[];
|
||||
if(!all.terrain)throw new Error('Terrain mesh is required for surface-following light');
|
||||
for(let i=0;i<ordered.length-1;i++){
|
||||
const from=ordered[i].point,to=ordered[i+1].point;
|
||||
const steps=Math.ceil(Math.hypot(to.x-from.x,to.z-from.z)/spacing);
|
||||
for(let j=i===0?0:1;j<=steps;j++){
|
||||
const p=from.clone().lerp(to,j/steps);p.y=box.max.y+100;
|
||||
ray.set(p,down);const hit=ray.intersectObject(all.terrain,false)[0];
|
||||
if(!hit){terrainFlow.push(null);continue;}
|
||||
const surface=hit.point.clone();surface.y+=.12;const projected=surface.clone().project(camera);
|
||||
terrainFlow.push({x:+((projected.x+1)*width/2).toFixed(3),y:+((1-projected.y)*height/2).toFixed(3),el:+hit.point.y.toFixed(5),world:surface.toArray()});
|
||||
}
|
||||
}
|
||||
const smooth=terrainCanvas?smoothLandingOutline(renderer.domElement,terrainCanvas,cutawayFrontM!==undefined):undefined;
|
||||
const output=smooth?.canvas??renderer.domElement;
|
||||
const result={png:output.toDataURL('image/png'),webp:output.toDataURL('image/webp',.94),metadata:{width,height,elevation,along,...(cutawayFrontM!==undefined?{cutawayFrontM}:{}),...(smooth?{outline:smooth.metadata}:{}),...(endMarginM!==undefined?{endMarginM}:{}),...(refined?{cornerRadiusM:18,terrainHalfWidthM:56,roads:road?.descriptions}:{}),points,terrainFlow,camera:{position:camera.position.toArray(),target:target.toArray(),left:camera.left,right:camera.right,top:camera.top,bottom:camera.bottom},piers:entries.length,abutments:project.abutments?.length??0}};
|
||||
disposeTerrainMapGroup(maps);
|
||||
scene.traverse(o=>{if(o instanceof THREE.Mesh){o.geometry.dispose();for(const m of Array.isArray(o.material)?o.material:[o.material])m.dispose();}});
|
||||
renderer.dispose();renderer.forceContextLoss();return result;
|
||||
}
|
||||
@@ -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 .workspace-pane > .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 .workspace-pane > .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,102 @@
|
||||
"""Read-only terrain diagnostics: grid slopes, isolated peaks, and source TIN."""
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
import numpy as np
|
||||
from collections import Counter
|
||||
|
||||
ROOT=Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0,str(ROOT/'pierzainer_ground'))
|
||||
from pierzainer_ground.survey_terrain import read_survey_block
|
||||
from pierzainer_ground.dxf_terrain import read_source_document, read_terrain_dxf
|
||||
from pierzainer_ground.dtm import DTM
|
||||
from pierzainer_ground.config import GroundConfig
|
||||
|
||||
out=ROOT/'.verify/ungok-terrain-review-20261110'
|
||||
out.mkdir(parents=True,exist_ok=True)
|
||||
folder=ROOT/'pierzainer_ground/data/output/app_b614afc0-6829-4aef-96e3-3ff1365ff5db'
|
||||
data=json.loads((folder/'ground_model_3d_with_maps.json').read_text())
|
||||
settings=json.loads((folder/'generation_settings.json').read_text())
|
||||
g=data['terrain_grid']; z=np.array([np.nan if v is None else v for v in g['z']]).reshape(g['ny'],g['nx'])
|
||||
edges=[]
|
||||
for j in range(g['ny']):
|
||||
for i in range(g['nx']):
|
||||
for dj,di in [(0,1),(1,0)]:
|
||||
if j+dj<g['ny'] and i+di<g['nx'] and np.isfinite(z[j,i]) and np.isfinite(z[j+dj,i+di]):
|
||||
edges.append({'row':j,'col':i,'to_row':j+dj,'to_col':i+di,'z':float(z[j,i]),'to_z':float(z[j+dj,i+di]),'dz':float(abs(z[j+dj,i+di]-z[j,i]))})
|
||||
edges.sort(key=lambda e:-e['dz'])
|
||||
summary={'file':str(folder),'terrain':data['terrain'],'provenance':data.get('surface_provenance'),'grid':{k:v for k,v in g.items() if k!='z'},'valid_nodes':int(np.isfinite(z).sum()),'z_range':[float(np.nanmin(z)),float(np.nanmax(z))],'adjacent_edge_count':len(edges),'edges_over_5m':sum(e['dz']>5 for e in edges),'edges_over_8m':sum(e['dz']>8 for e in edges),'worst_edges':edges[:12]}
|
||||
source=next((folder/'cache').glob('*.dxf'))
|
||||
doc,_=read_source_document(source)
|
||||
cfg=GroundConfig()
|
||||
points,counts,_=read_survey_block(source,{'terrain_block':settings['terrain_block'],'terrain_crs':settings['terrain_crs'],'crs':settings['crs'],'cad_axes':settings['cad_axes'],'contour_step_m':cfg.contour_step,'dedup_m':cfg.dedup_xy,'z_min':settings['z_min'],'z_max':settings['z_max'],'exclude_zero':True,'prefer_recognized_layers':False},doc=doc)
|
||||
lo=np.array(g['origin'])-settings['max_edge'];hi=np.array(g['origin'])+[(g['nx']-1)*g['step'],(g['ny']-1)*g['step']]+settings['max_edge']
|
||||
points=points[np.all((points[:,:2]>=lo)&(points[:,:2]<=hi),axis=1)]
|
||||
dtm=DTM(points,settings['max_edge'])
|
||||
summary['source_counts']=counts
|
||||
summary['raw_height_layers']=dict(Counter(e.dxf.layer for e in doc.blocks.get(settings['terrain_block']) if e.dxftype() in ('POLYLINE','LWPOLYLINE')))
|
||||
# Counterfactual only: use the drawing's two contour layers, without replacing
|
||||
# the generated user model or changing source geometry.
|
||||
contours,contour_counts,_=read_survey_block(source,{'terrain_block':settings['terrain_block'],'terrain_crs':settings['terrain_crs'],'crs':settings['crs'],'cad_axes':settings['cad_axes'],'contour_step_m':cfg.contour_step,'dedup_m':cfg.dedup_xy,'z_min':settings['z_min'],'z_max':settings['z_max'],'exclude_zero':True,'layers':['CAA001','CAA002']},doc=doc)
|
||||
contours=contours[np.all((contours[:,:2]>=lo)&(contours[:,:2]<=hi),axis=1)]
|
||||
clean_dtm=DTM(contours,settings['max_edge'])
|
||||
gx,gy=np.meshgrid(g['origin'][0]+np.arange(g['nx'])*g['step'],g['origin'][1]+np.arange(g['ny'])*g['step'])
|
||||
clean=clean_dtm.z_many(np.column_stack([gx.ravel(),gy.ravel()])).reshape(z.shape)
|
||||
clean_edges=np.concatenate([np.abs(np.diff(clean,axis=0)).ravel(),np.abs(np.diff(clean,axis=1)).ravel()])
|
||||
clean_edges=clean_edges[np.isfinite(clean_edges)]
|
||||
summary['contour_only_diagnostic']={'layers':['CAA001','CAA002'],'counts':contour_counts,'valid_nodes':int(np.isfinite(clean).sum()),'z_range':[float(np.nanmin(clean)),float(np.nanmax(clean))],'max_adjacent_dz':float(clean_edges.max()),'edges_over_5m':int((clean_edges>5).sum()),'edges_over_8m':int((clean_edges>8).sum()),'not_applied':True}
|
||||
typed_cfg=GroundConfig(use_profile_points=False,z_min=settings['z_min'],z_max=settings['z_max'])
|
||||
typed_cfg.allowed_terrain_layers=typed_cfg.layers.spot_block+typed_cfg.layers.spot_text+typed_cfg.layers.contour
|
||||
typed=read_terrain_dxf(source,typed_cfg,document=doc)
|
||||
typed_local=typed.points[np.all((typed.points[:,:2]>=lo)&(typed.points[:,:2]<=hi),axis=1)]
|
||||
typed_dtm=DTM(typed_local,settings['max_edge'])
|
||||
typed_grid=typed_dtm.z_many(np.column_stack([gx.ravel(),gy.ravel()])).reshape(z.shape)
|
||||
typed_edges=np.concatenate([np.abs(np.diff(typed_grid,axis=0)).ravel(),np.abs(np.diff(typed_grid,axis=1)).ravel()])
|
||||
typed_edges=typed_edges[np.isfinite(typed_edges)]
|
||||
summary['typed_layer_diagnostic']={'layers':list(typed_cfg.allowed_terrain_layers),'counts':typed.stats['by_source'],'valid_nodes':int(np.isfinite(typed_grid).sum()),'z_range':[float(np.nanmin(typed_grid)),float(np.nanmax(typed_grid))],'max_adjacent_dz':float(typed_edges.max()),'edges_over_5m':int((typed_edges>5).sum()),'edges_over_8m':int((typed_edges>8).sum()),'covered_piers':sum(typed_dtm.z_at(p['X'],p['Y']) is not None for p in data['piers']),'not_applied':True}
|
||||
summary['pier_diagnostic']=[]
|
||||
for pier in data['piers']:
|
||||
observed=clean_dtm.z_at(pier['X'],pier['Y'])
|
||||
summary['pier_diagnostic'].append({'id':pier['id'],'generated':pier['ground']['center'],'contour_only':observed,'difference':None if observed is None else observed-pier['ground']['center']})
|
||||
for e in summary['worst_edges']:
|
||||
triangles=[]
|
||||
for row,col in [(e['row'],e['col']),(e['to_row'],e['to_col'])]:
|
||||
xy=np.array(g['origin'])+np.array([col,row])*g['step']; k=int(dtm.tri.find_simplex(xy-dtm.origin))
|
||||
triangles.append({'xy':xy.tolist(),'vertices':points[dtm.tri.simplices[k]].tolist() if k>=0 else [],'predicted':dtm.z_at(*xy)})
|
||||
e['source_triangles']=triangles
|
||||
# Raw constant-height CAD entities retained in the selected TIN: look for
|
||||
# closed design outlines vs survey contours near the worst interpolation.
|
||||
raw=[]
|
||||
for entity in doc.blocks.get(settings['terrain_block']):
|
||||
if entity.dxftype() not in ('LWPOLYLINE','POLYLINE'):continue
|
||||
pts=np.array([tuple(p) for p in (entity.vertices_in_wcs() if entity.dxftype()=='LWPOLYLINE' else entity.points_in_wcs())])
|
||||
if len(pts)<2 or np.ptp(pts[:,2])>1e-6:continue
|
||||
raw.append({'handle':entity.dxf.handle,'layer':entity.dxf.layer,'z':float(pts[0,2]),'vertices':len(pts),'closed':bool(entity.closed if entity.dxftype()=='LWPOLYLINE' else entity.is_closed),'xy_min':pts[:,:2].min(0).tolist(),'xy_max':pts[:,:2].max(0).tolist(),'xyz':pts.tolist()})
|
||||
for e in summary['worst_edges'][:6]:
|
||||
for tri in e['source_triangles']:
|
||||
tri['entities']=[]
|
||||
for north,east,height in tri['vertices']:
|
||||
candidates=[]
|
||||
for entity in raw:
|
||||
if abs(entity['z']-height)>1e-3:continue
|
||||
xy=np.array(entity['xyz'])[:,:2]; d=float(np.linalg.norm(xy-[east,north],axis=1).min())
|
||||
candidates.append((d,entity))
|
||||
candidates.sort(key=lambda r:r[0])
|
||||
tri['entities'].append([{'distance_to_vertex':round(d,4),**{k:v for k,v in ent.items() if k!='xyz'}} for d,ent in candidates[:2]])
|
||||
(out/'report.json').write_text(json.dumps(summary,ensure_ascii=False,indent=2))
|
||||
print(json.dumps({k:v for k,v in summary.items() if k not in ('worst_edges','provenance')},ensure_ascii=False))
|
||||
for e in summary['worst_edges'][:3]:print(json.dumps(e,ensure_ascii=False))
|
||||
try:
|
||||
import matplotlib
|
||||
matplotlib.use('Agg')
|
||||
import matplotlib.pyplot as plt
|
||||
fig,axes=plt.subplots(1,3,figsize=(16,5),constrained_layout=True)
|
||||
extent=[g['origin'][0],g['origin'][0]+(g['nx']-1)*g['step'],g['origin'][1],g['origin'][1]+(g['ny']-1)*g['step']]
|
||||
for ax,values,title in [(axes[0],z,'Generated: all height-bearing layers'),(axes[1],typed_grid,'Diagnostic: survey spots + contour layers')]:
|
||||
im=ax.imshow(values,origin='lower',extent=extent,vmin=20,vmax=47,aspect='auto',cmap='terrain');ax.set_title(title);ax.set_xlabel('Northing (m)');ax.set_ylabel('Easting (m)')
|
||||
fig.colorbar(im,ax=axes[:2],label='EL (m)',shrink=.7)
|
||||
row=summary['worst_edges'][0]['to_row'];axis=g['origin'][0]+np.arange(g['nx'])*g['step']
|
||||
axes[2].plot(axis,z[row],label='Generated');axes[2].plot(axis,typed_grid[row],label='Survey spots + contours');axes[2].set_title(f'Transect E={g["origin"][1]+row*g["step"]:.3f} m');axes[2].set_xlabel('Northing (m)');axes[2].set_ylabel('EL (m)');axes[2].legend();axes[2].grid(alpha=.25)
|
||||
fig.savefig(out/'comparison.png',dpi=150);plt.close(fig)
|
||||
except ImportError:
|
||||
print('Plot dependency unavailable; numeric report preserved.')
|
||||
@@ -0,0 +1,64 @@
|
||||
import { readFileSync, mkdirSync, writeFileSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import * as XLSX from 'xlsx';
|
||||
import * as THREE from 'three';
|
||||
import { it, expect } from 'vitest';
|
||||
import { parseGroundModel } from '../src/core/ground-model';
|
||||
import { parseWorkbook } from '../src/core/parseWorkbook';
|
||||
import { buildPier } from '../src/core/geometry';
|
||||
import { parseAbutments, toAbutInput } from '../src/core/abut/parse';
|
||||
import { buildAbut } from '../src/core/abut/geometry';
|
||||
import { buildMembers } from '../src/core/abut/members';
|
||||
import { buildWings } from '../src/core/abut/wings';
|
||||
import { buildAbutGroup } from '../src/view/abut-scene';
|
||||
import { terrainGeometry } from '../src/view/terrain';
|
||||
import { terrainMapGeometry } from '../src/view/terrain-map';
|
||||
|
||||
it('records terrain and map placement against the actual Okseong project', () => {
|
||||
const folder = '/mnt/c/Users/User/Downloads/NAVER WORKS';
|
||||
const ground = parseGroundModel(readFileSync(path.join(folder, '옥성교 지형.json'), 'utf8'));
|
||||
const wb = XLSX.read(readFileSync(path.join(folder, '옥성교 데이터_261106.xlsx')), { type: 'buffer' });
|
||||
const piers = parseWorkbook(wb).piers;
|
||||
const abuts = parseAbutments(wb, piers.map(p => p.name)).abutments;
|
||||
const pivot = buildPier(piers[0]).pivot, grid = ground.terrainGrid!;
|
||||
const terrain = terrainGeometry(grid, pivot), pos = terrain.getAttribute('position');
|
||||
const layers = ground.mapLayers!.map(layer => {
|
||||
const g = terrainMapGeometry(layer, pivot, 0, grid);
|
||||
return { id: layer.id, corners: layer.survey_corners, vertices: g.getAttribute('position').count, triangles: g.getIndex()!.count / 3 };
|
||||
});
|
||||
const duplicates = abuts.map(rec => {
|
||||
const model = buildAbut(toAbutInput(rec));
|
||||
const members = buildMembers(model, { bottomEl: rec.foundationBottomEl, pileLengthMm: rec.pileLengthMm }, rec.shape);
|
||||
const root = buildAbutGroup(model, members, buildWings(model, members, rec.wings, rec.wingDims), pivot, rec.groundEl, grid, rec.slab);
|
||||
const shared = buildAbutGroup(model, members, buildWings(model, members, rec.wings, rec.wingDims), pivot, rec.groundEl, grid, rec.slab, undefined, undefined, true);
|
||||
expect(shared.getObjectByName('ground')).toBeUndefined();
|
||||
root.updateMatrixWorld(true);
|
||||
const meshes: object[] = [];
|
||||
root.getObjectByName('ground')?.traverse(obj => {
|
||||
if (!(obj instanceof THREE.Mesh)) return;
|
||||
const vertices = obj.geometry.getAttribute('position');
|
||||
let maxDisplacement = 0;
|
||||
if (vertices.count === pos.count) for (let i = 0; i < vertices.count; i++) {
|
||||
const actual = new THREE.Vector3().fromBufferAttribute(vertices, i).applyMatrix4(obj.matrixWorld);
|
||||
const expected = new THREE.Vector3().fromBufferAttribute(pos, i);
|
||||
maxDisplacement = Math.max(maxDisplacement, actual.distanceTo(expected));
|
||||
}
|
||||
meshes.push({ vertices: vertices.count, triangles: obj.geometry.getIndex()?.count! / 3, sameGrid: vertices.count === pos.count, maxDisplacementM: maxDisplacement });
|
||||
});
|
||||
const gx = (rec.ip2.n - grid.origin[0]) / grid.step, gy = (rec.ip2.e - grid.origin[1]) / grid.step;
|
||||
const i = Math.floor(gx), j = Math.floor(gy);
|
||||
const inside = i >= 0 && j >= 0 && i + 1 < grid.nx && j + 1 < grid.ny;
|
||||
const heights = inside ? [grid.z[j * grid.nx + i], grid.z[j * grid.nx + i + 1], grid.z[(j + 1) * grid.nx + i], grid.z[(j + 1) * grid.nx + i + 1]] : [];
|
||||
const valid = heights.length === 4 && heights.every(h => h !== null);
|
||||
const u = gx - i, v = gy - j;
|
||||
const sampledAtIp2 = valid ? heights[0]! * (1-u) * (1-v) + heights[1]! * u * (1-v) + heights[2]! * (1-u) * v + heights[3]! * u * v : null;
|
||||
return { abutment: rec.name, excelGroundEl: rec.groundEl, ip2: rec.ip2, modelEntry: ground.piers.some(p => p.id === rec.name), ip2GridSample: sampledAtIp2, meshes };
|
||||
});
|
||||
const values = grid.z.filter((v): v is number => v !== null);
|
||||
const report = { grid: { origin: grid.origin, step: grid.step, nx: grid.nx, ny: grid.ny, valid: values.length, total: grid.z.length, min: Math.min(...values), max: Math.max(...values) }, terrain: { vertices: pos.count, triangles: terrain.getIndex()!.count / 3 }, layers, duplicates };
|
||||
const out = path.resolve('.verify/terrain-review-20261109'); mkdirSync(out, { recursive: true });
|
||||
writeFileSync(path.join(out, 'report.json'), JSON.stringify(report, null, 2));
|
||||
console.log(JSON.stringify(report));
|
||||
expect(layers.every(layer => layer.triangles === report.terrain.triangles)).toBe(true);
|
||||
expect(duplicates.every(d => d.meshes.every(m => (m as { maxDisplacementM: number }).maxDisplacementM < 0.001))).toBe(true);
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
"""Apply the project ground manager integration to the existing application."""
|
||||
from pathlib import Path
|
||||
|
||||
root = Path(__file__).resolve().parents[1]
|
||||
p = root / 'src/main.ts'
|
||||
s = p.read_text()
|
||||
s = "import { createProjectGroundManager } from './view/project-ground';\nimport { applyProjectGround, groundInputChanged } from './core/project-ground';\nimport { registerGroundImporter } from '../pierzainer_ground/ground-link';\n" + s
|
||||
s = s.replace('let groundModel: GroundModelFile | null = null;', 'let groundModel: GroundModelFile | null = null;\nlet groundProjectVersion = 0;\nlet groundImportSerial = 0;')
|
||||
marker = 'function el<K extends keyof HTMLElementTagNameMap>'
|
||||
insert = '''async function importProjectGround(text: string, name: string): Promise<void> {
|
||||
const version = groundProjectVersion, serial = ++groundImportSerial;
|
||||
const imported = parseGroundModel(text);
|
||||
await Promise.all((imported.mapLayers ?? []).map(decodeGroundMapImage));
|
||||
if (version !== groundProjectVersion || serial !== groundImportSerial) throw new Error('프로젝트 또는 불러올 모델이 변경되어 이전 결과를 적용하지 않았습니다.');
|
||||
groundModel = imported;
|
||||
groundMapMode = imported.mapLayers?.length ? 'satellite' : 'none';
|
||||
setGroundMapMode(groundMapMode);
|
||||
state.visible.ground = true;
|
||||
setupScene(); renderAll();
|
||||
setStatus(`${name} 지반모델을 프로젝트 공통으로 불러왔습니다. 설계 입력은 비교 후 적용하세요.`);
|
||||
}
|
||||
registerGroundImporter(importProjectGround);
|
||||
const projectGround = createProjectGroundManager({
|
||||
snapshot: () => ({ model: groundModel, piers: state.piers }),
|
||||
importModel: importProjectGround,
|
||||
apply: (names, fields) => {
|
||||
if (!groundModel) return;
|
||||
for (const p of state.piers) if (names.has(p.input.name)) p.draft = applyProjectGround(p.draft, groundModel, fields);
|
||||
renderAll();
|
||||
},
|
||||
regenerate: () => {
|
||||
state.piers = state.piers.map(p => groundInputChanged(p.input, p.draft) ? regenerateEntry(p) : p);
|
||||
setupScene(); renderAll();
|
||||
setStatus('지반 입력이 변경된 교각을 다시 생성했습니다. 생성 상태와 알림을 확인하세요.');
|
||||
},
|
||||
});
|
||||
function resetProjectGround(): void {
|
||||
groundProjectVersion++; groundImportSerial++;
|
||||
projectGround.reset();
|
||||
window.dispatchEvent(new Event('pierzainer:project-changed'));
|
||||
}
|
||||
|
||||
'''
|
||||
assert marker in s
|
||||
s = s.replace(marker, insert + marker, 1)
|
||||
s = s.replace(' updatePartToggles();\n $<HTMLButtonElement>', ' updatePartToggles();\n projectGround.refresh();\n $<HTMLButtonElement>', 1)
|
||||
start = s.index(' // ② 지반 모델 (승인 2026-10-23):')
|
||||
end = s.index(' if (!groundModel) {', start)
|
||||
s = s[:start] + " wrap.appendChild(el('p', '프로젝트 공통 지반모델은 ① 형상의 [프로젝트 지반모델]에서 생성·불러오기·선택 적용합니다.', 'muted small'));\n\n" + s[end:]
|
||||
s = s.replace(' * The ground model import and the 지반고 apply stay at the bottom, as before.', ' * Project model generation/import is managed from ①; this card retains local inputs.')
|
||||
s = s.replace(' state.project = { ...parsed.project };', ' resetProjectGround();\n state.project = { ...parsed.project };', 1)
|
||||
marker = " state.project = { ...state.project, name, bridge: $<HTMLInputElement>('bridge-name').value.trim() };"
|
||||
assert marker in s
|
||||
s = s.replace(marker, " resetProjectGround();\n groundModel = null;\n groundMapMode = 'none';\n setGroundMapMode(groundMapMode);\n" + marker, 1)
|
||||
p.write_text(s)
|
||||
@@ -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 .workspace-pane > 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 .workspace-pane > 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,95 @@
|
||||
// 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/ground-abutments-20261109');
|
||||
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: process.env.PIERZAINER_URL || 'http://127.0.0.1:5173/' });
|
||||
await until(`typeof window.openSampleProject==='function'`);
|
||||
const selectFile=async(selector,files)=>{
|
||||
const doc=await send('DOM.getDocument'),{nodeId}=await send('DOM.querySelector',{nodeId:doc.root.nodeId,selector});
|
||||
await send('DOM.setFileInputFiles',{nodeId,files});
|
||||
};
|
||||
const folder='C:\\Users\\User\\Downloads\\NAVER WORKS';
|
||||
await ev(`window.openSampleProject()`);
|
||||
await selectFile('#file',[path.join(folder,'옥성교 데이터_261106.xlsx')]);
|
||||
await until(`document.getElementById('file-name').textContent.includes('선택된 파일: 옥성교 데이터_261106.xlsx')`);
|
||||
await ev(`(()=>{const e=document.getElementById('project-name');e.value='교대 지반 검증';e.dispatchEvent(new Event('input'));document.getElementById('import-dialog').showModal();})()`);
|
||||
await click(`document.getElementById('dlg-apply')`);
|
||||
await click(`document.getElementById('nav-shape')`);
|
||||
await click(`document.getElementById('project-ground-open')`);
|
||||
check('manager lists six piers and both abutments before model import',await ev(`document.querySelectorAll('[data-ground-pier]').length===8&&document.querySelectorAll('[data-ground-kind="교대"]').length===2`));
|
||||
await selectFile('#ground-import',[path.join(folder,'옥성교 지형.json')]);
|
||||
await until(`document.getElementById('project-ground-status').textContent.includes('8/8')`);
|
||||
check('both abutments show IP2 grid source and finite comparison elevations',await ev(`[...document.querySelectorAll('[data-ground-kind="교대"]')].every(e=>e.closest('tr').textContent.includes('IP2 격자 보간')&&!e.disabled)`));
|
||||
const values=()=>ev(`[...document.querySelectorAll('.project-ground-table tbody tr')].map(r=>({name:r.children[1].textContent,el:Number(r.children[2].textContent),model:Number(r.children[3].textContent)}))`);
|
||||
const before=await values();
|
||||
check('import alone preserves original abutment inputs',before.find(r=>r.name==='A1').el===11.24&&before.find(r=>r.name==='A2').el===14.43);
|
||||
await click(`document.querySelector('[data-ground-pier="A1"]')`);
|
||||
await click(`document.getElementById('project-ground-apply')`);
|
||||
const one=await values();
|
||||
check('only selected A1 changes; A2 and P1 remain unchanged',one.find(r=>r.name==='A1').el===11.789&&one.find(r=>r.name==='A2').el===14.43&&one.find(r=>r.name==='P1').el===before.find(r=>r.name==='P1').el);
|
||||
await click(`document.getElementById('project-ground-select-all')`);
|
||||
check('select all includes both types',await ev(`document.querySelectorAll('[data-ground-pier]:checked').length===8`));
|
||||
await click(`document.getElementById('project-ground-apply')`);
|
||||
const applied=await values();
|
||||
check('A2 receives IP2 interpolation',applied.find(r=>r.name==='A2').el===14.61);
|
||||
check('pier regeneration remains explicit',await ev(`!document.getElementById('project-ground-regenerate').disabled`));
|
||||
await click(`document.getElementById('project-ground-regenerate')`);
|
||||
check('pending pier changes regenerate',await ev(`document.getElementById('project-ground-regenerate').disabled`));
|
||||
await click(`document.getElementById('project-ground-el')`);
|
||||
await click(`document.getElementById('project-ground-layers')`);
|
||||
check('missing abutment soil layers cannot be applied',await ev(`[...document.querySelectorAll('[data-ground-kind="교대"]')].every(e=>e.disabled)`));
|
||||
await click(`document.getElementById('project-ground-el')`);
|
||||
await click(`document.getElementById('project-ground-layers')`);
|
||||
await shot('01-unified-ground');
|
||||
await click(`document.querySelector('#project-ground-dialog .viewer-head button')`);
|
||||
await shot('02-shared-terrain');
|
||||
await ev(`(()=>{const original=URL.createObjectURL;window.__saved=null;URL.createObjectURL=function(blob){blob.text().then(text=>{try{window.__saved=JSON.parse(text);}catch{}});return original.call(this,blob);};})()`);
|
||||
await send('Browser.setDownloadBehavior',{behavior:'deny'});
|
||||
await click(`document.getElementById('save-project')`);
|
||||
await until(`document.getElementById('save-dialog').open`);
|
||||
await click(`document.getElementById('sv-save')`);
|
||||
await until(`!!window.__saved?.abutments`);
|
||||
const saved=await ev(`window.__saved`);
|
||||
check('saved project retains both abutment elevations and Excel source',Math.abs(saved.abutments.find(a=>a.name==='A1').groundEl-11.7886759049651)<1e-6&&Math.abs(saved.abutments.find(a=>a.name==='A2').groundEl-14.610314099767017)<1e-6&&saved.source.fileName==='옥성교 데이터_261106.xlsx');
|
||||
const savedPath=path.join(out,'saved.pzr.json');fs.writeFileSync(savedPath,JSON.stringify(saved));
|
||||
await selectFile('#project-file',[savedPath]);
|
||||
await click(`document.getElementById('project-ground-open')`);
|
||||
check('reopening project restores abutment inputs', (await values()).filter(r=>r.name==='A1'||r.name==='A2').every(r=>Math.abs(r.el-r.model)<0.001));
|
||||
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,168 @@
|
||||
// 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/ground-import-20261109');
|
||||
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: process.env.PIERZAINER_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 {defaultLevelWidthMm}=await import('/src/core/geometry.ts');
|
||||
const piers=['직접기초','직접기초','현장타설말뚝'].map((foundationType,i)=>{const input={...base,name:'P'+(i+1),foundationType,type:'T형',columnCount:1,groundEl:30,pileLengthMm:24000,castInPlaceShape:{clayBottomEl:16},columnDiameterMm:2000,footingLengthMm:10400,footingWidthMm:8150,footingHeightMm:2000,blindingMarginMm:100,blindingThicknessMm:100,pileKind:'강관',pileOuterMm:609.6,pileThicknessMm:12,copingH1Mm:1000,copingH2Mm:1500,levelWidthMm:defaultLevelWidthMm('T형',2000)};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===3`);
|
||||
const selectFile = async (selector, files) => {
|
||||
const doc = await send('DOM.getDocument');
|
||||
const {nodeId} = await send('DOM.querySelector', {nodeId:doc.root.nodeId, selector});
|
||||
await send('DOM.setFileInputFiles', {nodeId, files});
|
||||
};
|
||||
await click(`document.getElementById('nav-shape')`);
|
||||
check('shape has a visible project ground entry', await ev(`document.getElementById('project-ground-open').getBoundingClientRect().height>0`));
|
||||
await click(`document.getElementById('project-ground-open')`);
|
||||
check('manager opens without per-pier controls', await ev(`document.getElementById('project-ground-dialog').open&&document.querySelectorAll('#ground-import').length===1`));
|
||||
const modelFile=path.join(out,'ground.json');
|
||||
const raw={schema:2,generated:'2026-11-09',piers:[{id:'P1',ground:{center:29,excel:30},soil:{layers:[{name:'암반',bottom_el:10}]}},{id:'P2',ground:{center:null,excel:30}}]};
|
||||
fs.writeFileSync(modelFile,JSON.stringify(raw));
|
||||
await selectFile('#ground-import',[modelFile]);
|
||||
await until(`document.getElementById('project-ground-info').textContent.includes('2026-11-09')`);
|
||||
check('matching, out-of-range and missing piers are distinguished',await ev(`document.getElementById('project-ground-dialog').textContent.includes('지형 범위 밖')&&document.getElementById('project-ground-dialog').textContent.includes('모델에 없음')&&document.querySelector('[data-ground-pier="P2"]').disabled&&document.querySelector('[data-ground-pier="P3"]').disabled`));
|
||||
check('import alone keeps current design input',await ev(`document.querySelector('.project-ground-table tbody tr').children[2].textContent==='30.000'`));
|
||||
await click(`document.querySelector('[data-ground-pier="P1"]')`);
|
||||
await click(`document.getElementById('project-ground-apply')`);
|
||||
check('selected ground input applies and requires regeneration',await ev(`document.querySelector('.project-ground-table tbody tr').children[2].textContent==='29.000'&&!document.getElementById('project-ground-regenerate').disabled`));
|
||||
await click(`document.getElementById('project-ground-regenerate')`);
|
||||
check('regeneration consumes ground changes',await ev(`document.getElementById('project-ground-regenerate').disabled&&document.querySelector('.project-ground-table tbody tr').textContent.includes('지반고 일치')`));
|
||||
await click(`document.getElementById('project-ground-el')`);
|
||||
await click(`document.getElementById('project-ground-layers')`);
|
||||
await click(`document.getElementById('project-ground-apply')`);
|
||||
check('layers can be applied separately',await ev(`!document.getElementById('project-ground-regenerate').disabled&&document.querySelector('.project-ground-table tbody tr').children[2].textContent==='29.000'`));
|
||||
await click(`document.getElementById('project-ground-regenerate')`);
|
||||
await shot('01-project-ground');
|
||||
await click(`document.querySelector('#project-ground-dialog .viewer-head button')`);
|
||||
await click(`document.getElementById('nav-plan')`);
|
||||
await click(`[...document.querySelectorAll('#members button')].find(b=>b.textContent.includes('지반'))`);
|
||||
check('per-pier ground card has no model generation or file picker',await ev(`!document.getElementById('view-plan').textContent.includes('지반 모델 파일 불러오기 · 지반 생성 프로그램')&&document.querySelectorAll('#view-plan input[type=file]').length===0`));
|
||||
await ev(`(()=>{window.__savedGround=null;const original=URL.createObjectURL;URL.createObjectURL=function(blob){blob.text().then(text=>{try{window.__savedGround=JSON.parse(text);}catch{}});return original.call(this,blob);};})()`);
|
||||
await click(`document.getElementById('save-project')`);
|
||||
await until(`!!window.__savedGround?.groundModel`);
|
||||
const saved=await ev(`window.__savedGround`);
|
||||
check('project saves shared model once and applied inputs',saved.groundModel.piers.length===2&&saved.piers[0].input.groundEl===29&&saved.piers[0].input.strataRows[0].name==='암반'&&saved.piers[1].input.groundEl===30);
|
||||
const savedFile=path.join(out,'saved.pzr.json');fs.writeFileSync(savedFile,JSON.stringify(saved));
|
||||
await selectFile('#project-file',[savedFile]);
|
||||
await until(`document.getElementById('project-ground-status').textContent.includes('불러옴')`);
|
||||
check('project reopen restores ground model',await ev(`document.getElementById('project-ground-status').textContent.includes('2/3')`));
|
||||
// Mock the external engine; deliver an in-flight result after switching projects.
|
||||
await ev(`(()=>{const original=window.fetch;window.__releaseGround=null;window.__groundReady=false;window.fetch=async(url,init)=>{const path=String(url);if(path.includes('/ground-link/prepare'))return new Response(JSON.stringify({drawing:'test.dxf',excelName:'test.xlsx',excelSelected:true}));if(path.includes('/ground-link/upload'))return new Response(JSON.stringify({file:'test.xlsx'}));if(path.includes('/ground-link/launch'))return new Response(JSON.stringify({id:'mock'}));if(path.includes('/ground-link/result')){window.__groundReady=true;return new Promise(resolve=>window.__releaseGround=()=>resolve(new Response(JSON.stringify({ready:true,state:'done',filename:'old.json',text:${JSON.stringify(JSON.stringify({...raw,generated:'mock-generated'}))}}))));}return original(url,init);};})()`);
|
||||
const excel=path.join(out,'source.xlsx');fs.writeFileSync(excel,'mock');
|
||||
const startGeneration = async () => {
|
||||
await ev(`window.__groundReady=false`);
|
||||
await click(`document.getElementById('nav-shape')`);
|
||||
await click(`document.getElementById('project-ground-open')`);
|
||||
await click(`[...document.querySelectorAll('#project-ground-dialog button')].find(b=>b.textContent==='지반모델 생성')`);
|
||||
await until(`document.querySelector('.ground-popup [data-run]')&&!document.querySelector('.ground-popup [data-run]').disabled`);
|
||||
await selectFile('#ground-excel-file',[excel]);
|
||||
await click(`document.querySelector('.ground-popup [data-run]')`);
|
||||
await until(`window.__groundReady`);
|
||||
};
|
||||
await startGeneration();
|
||||
await click(`document.querySelector('.ground-popup [data-close]')`);
|
||||
await click(`document.querySelector('#project-ground-dialog .viewer-head button')`);
|
||||
await click(`document.getElementById('nav-rebar')`);
|
||||
await ev(`window.__releaseGround()`);
|
||||
await until(`document.querySelector('.ground-popup [data-status]').textContent.includes('생성 완료')`);
|
||||
await click(`document.getElementById('nav-shape')`);
|
||||
await click(`document.getElementById('project-ground-open')`);
|
||||
check('generated model imports after leaving both manager and ground card',await ev(`document.getElementById('project-ground-info').textContent.includes('mock-generated')`));
|
||||
await click(`document.querySelector('#project-ground-dialog .viewer-head button')`);
|
||||
await startGeneration();
|
||||
await selectFile('#project-file',[file]);
|
||||
await until(`document.getElementById('project-ground-status').textContent==='모델 없음'`);
|
||||
await ev(`window.__releaseGround()`);
|
||||
await delay(300);
|
||||
check('late generated result does not contaminate another project',await ev(`document.getElementById('project-ground-status').textContent==='모델 없음'`));
|
||||
check('project switch closes manager and generator',await ev(`!document.getElementById('project-ground-dialog').open&&!document.querySelector('.ground-popup').open`));
|
||||
await selectFile('#project-file',[savedFile]);
|
||||
await until(`document.getElementById('project-ground-status').textContent.includes('불러옴')`);
|
||||
await ev(`(async()=>{const bytes=await(await fetch('/'+encodeURIComponent('도면추출')+'/samples/'+encodeURIComponent('운곡교P1입력.xlsx'))).arrayBuffer();window.loadBytesToApp(new Uint8Array(bytes),'새 프로젝트.xlsx');const name=document.getElementById('project-name');name.value='새 프로젝트';name.dispatchEvent(new Event('input'));document.getElementById('import-dialog').showModal();})()`);
|
||||
await until(`document.getElementById('import-dialog').open&&!document.getElementById('dlg-apply').disabled`);
|
||||
await click(`document.getElementById('dlg-apply')`);
|
||||
await until(`document.getElementById('project-ground-status').textContent==='모델 없음'`);
|
||||
check('new Excel import clears previous project ground model',await ev(`document.getElementById('project-ground-status').textContent==='모델 없음'`));
|
||||
// Real generated JSON and the adjacent settings file reproduce the reported failure.
|
||||
const actualFolder=path.resolve(__dirname,'../pierzainer_ground/data/output/drawing_01_옥성교_종평면도');
|
||||
const actualModel=path.join(actualFolder,'ground_model.json');
|
||||
const expected=JSON.parse(fs.readFileSync(actualModel,'utf8'));
|
||||
const six={...fixture,piers:Array.from({length:6},(_,i)=>{
|
||||
const input={...fixture.piers[0].input,name:'P'+(i+1)};
|
||||
return{id:input.name,input,defaults:input,draft:input};
|
||||
})};
|
||||
const sixFile=path.join(out,'six-piers.pzr.json');fs.writeFileSync(sixFile,JSON.stringify(six));
|
||||
await selectFile('#project-file',[sixFile]);
|
||||
await until(`document.querySelectorAll('#pier-items button').length===6`);
|
||||
await click(`document.getElementById('nav-shape')`);
|
||||
await click(`document.getElementById('project-ground-open')`);
|
||||
await selectFile('#ground-import',[path.join(actualFolder,'generation_settings.json')]);
|
||||
await until(`document.getElementById('project-ground-dialog').textContent.includes('지반 생성 설정 파일')`);
|
||||
check('settings file reports correct result filenames and blocks application',await ev(`document.getElementById('project-ground-apply').disabled&&document.getElementById('project-ground-status').textContent==='모델 없음'&&document.getElementById('project-ground-dialog').textContent.includes('ground_model_3d_with_maps.json')`));
|
||||
await selectFile('#ground-import',[actualModel]);
|
||||
await until(`document.getElementById('project-ground-status').textContent.includes('6/6')`);
|
||||
check('real Okseong JSON imports all six piers',await ev(`document.querySelectorAll('[data-ground-pier]:not(:disabled)').length===6`));
|
||||
await click(`[...document.querySelectorAll('#project-ground-dialog button')].find(b=>b.textContent==='적용 가능한 교각 선택')`);
|
||||
check('select all enables application',await ev(`document.querySelectorAll('[data-ground-pier]:checked').length===6&&!document.getElementById('project-ground-apply').disabled`));
|
||||
await click(`document.getElementById('project-ground-apply')`);
|
||||
const applied=await ev(`[...document.querySelectorAll('.project-ground-table tbody tr')].map(row=>Number(row.children[2].textContent))`);
|
||||
check('all six design elevations match real JSON',applied.every((el,i)=>el===expected.piers[i].ground.center));
|
||||
await click(`document.getElementById('project-ground-regenerate')`);
|
||||
check('all six applied elevations regenerate',await ev(`document.getElementById('project-ground-regenerate').disabled`));
|
||||
await selectFile('#ground-import',[path.join(actualFolder,'generation_settings.json')]);
|
||||
await until(`document.getElementById('project-ground-dialog').textContent.includes('지반 생성 설정 파일')`);
|
||||
check('invalid replacement preserves the loaded project ground model',await ev(`document.getElementById('project-ground-status').textContent.includes('6/6')`));
|
||||
await shot('02-real-okseong');
|
||||
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,100 @@
|
||||
// Run with Windows Node and an isolated Chrome on CDP 9444; Vite must be on 5173.
|
||||
// Verifies the landing intro-video trigger + modal (lazy load, play, Esc/backdrop close, focus, layouts).
|
||||
const fs = require('node:fs'), path = require('node:path'), assert = require('node:assert/strict');
|
||||
const out = path.resolve(__dirname, '../.verify/intro-video-20261109');
|
||||
fs.mkdirSync(out, { recursive: true });
|
||||
const report = { checks: [], errors: [], metrics: {} };
|
||||
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, userGesture: true });
|
||||
if (r.exceptionDetails) throw new Error(r.exceptionDetails.exception?.description || r.exceptionDetails.text);
|
||||
return r.result.value;
|
||||
};
|
||||
const until = async (expression, tries = 120) => {
|
||||
for (let i = 0; i < tries; 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 shot = async name => {
|
||||
const r = await send('Page.captureScreenshot', { format: 'png' });
|
||||
fs.writeFileSync(path.join(out, name + '.png'), Buffer.from(r.data, 'base64'));
|
||||
};
|
||||
const viewport = (width, height, mobile = false) => send('Emulation.setDeviceMetricsOverride', { width, height, deviceScaleFactor: 1, mobile });
|
||||
const load = async () => {
|
||||
await send('Page.navigate', { url: process.env.PIERZAINER_URL || 'http://127.0.0.1:5173/' });
|
||||
await until(`!!document.querySelector('.landing-video-trigger')`);
|
||||
await delay(1800); // entry animations
|
||||
};
|
||||
const mp4Requests = `performance.getEntriesByType('resource').filter(e=>e.name.includes('pierzainer-intro')).length`;
|
||||
const pressEsc = async () => {
|
||||
for (const type of ['keyDown', 'keyUp']) await send('Input.dispatchKeyEvent', { type, key: 'Escape', code: 'Escape', windowsVirtualKeyCode: 27 });
|
||||
};
|
||||
|
||||
await send('Page.enable'); await send('Runtime.enable');
|
||||
await viewport(1600, 1000); await load();
|
||||
await shot('01-landing-1600');
|
||||
report.metrics.triggers = await ev(`[...document.querySelectorAll('.landing-guide-trigger,.landing-video-trigger')].map(e=>{const r=e.getBoundingClientRect();return[e.className,Math.round(r.left),Math.round(r.top),Math.round(r.right),Math.round(r.bottom)];})`);
|
||||
check('trigger sits beside the guide button', await ev(`(()=>{const g=document.querySelector('.landing-guide-trigger').getBoundingClientRect(),v=document.querySelector('.landing-video-trigger').getBoundingClientRect();return v.width>0&&Math.abs(g.top-v.top)<1&&Math.abs(g.height-v.height)<1&&v.left>g.right;})()`));
|
||||
check('video is not downloaded before opening', await ev(mp4Requests) === 0 && await ev(`!document.querySelector('#landing-video video source')`));
|
||||
await shot('01-landing-1600');
|
||||
|
||||
await ev(`document.querySelector('.landing-video-trigger').click()`);
|
||||
check('modal opens with focus on close button', await ev(`document.getElementById('landing-video').open&&document.activeElement.classList.contains('landing-video-close')`));
|
||||
await until(`document.querySelector('#landing-video video').readyState>=3`, 200);
|
||||
await delay(2200);
|
||||
const media = await ev(`(()=>{const v=document.querySelector('#landing-video video');return{t:v.currentTime,d:v.duration,w:v.videoWidth,h:v.videoHeight,paused:v.paused,muted:v.muted,src:v.currentSrc.split('/').pop()};})()`);
|
||||
report.metrics.media = media;
|
||||
check('mp4 loads at 1920x1080 for about 56 s', media.w === 1920 && media.h === 1080 && Math.abs(media.d - 56) < 1);
|
||||
check('video autoplays muted after opening', !media.paused && media.muted && media.t > 1);
|
||||
check('modal fits the viewport', await ev(`(()=>{const r=document.getElementById('landing-video').getBoundingClientRect();return r.left>=0&&r.top>=0&&r.right<=innerWidth&&r.bottom<=innerHeight;})()`));
|
||||
await shot('02-playing-1600');
|
||||
|
||||
await pressEsc(); await delay(200);
|
||||
check('Esc closes, pauses, rewinds and restores focus', await ev(`(()=>{const v=document.querySelector('#landing-video video');return !document.getElementById('landing-video').open&&v.paused&&v.currentTime===0&&document.activeElement.classList.contains('landing-video-trigger');})()`));
|
||||
|
||||
await ev(`document.querySelector('.landing-video-trigger').click()`); await delay(300);
|
||||
await send('Input.dispatchMouseEvent', { type: 'mousePressed', x: 8, y: 8, button: 'left', clickCount: 1 });
|
||||
await send('Input.dispatchMouseEvent', { type: 'mouseReleased', x: 8, y: 8, button: 'left', clickCount: 1 });
|
||||
await delay(200);
|
||||
check('backdrop click closes the modal', await ev(`!document.getElementById('landing-video').open`));
|
||||
await ev(`document.querySelector('.landing-video-trigger').click()`); await delay(300);
|
||||
await ev(`document.querySelector('.landing-video-close').click()`); await delay(200);
|
||||
check('close button closes and pauses', await ev(`!document.getElementById('landing-video').open&&document.querySelector('#landing-video video').paused`));
|
||||
|
||||
for (const [w, h, mobile] of [[1280, 800, false], [1920, 1080, false], [390, 844, true]]) {
|
||||
await viewport(w, h, mobile); await load();
|
||||
check(`${w}px trigger visible without horizontal scroll`, await ev(`(()=>{const r=document.querySelector('.landing-video-trigger').getBoundingClientRect();return r.width>0&&r.right<=innerWidth&&document.documentElement.scrollWidth<=innerWidth;})()`));
|
||||
await ev(`document.querySelector('.landing-video-trigger').click()`); await delay(500);
|
||||
check(`${w}px modal fits viewport`, await ev(`(()=>{const r=document.getElementById('landing-video').getBoundingClientRect();return r.left>=0&&r.right<=innerWidth&&r.bottom<=innerHeight+1;})()`));
|
||||
await shot(`03-modal-${w}`);
|
||||
await ev(`document.querySelector('.landing-video-close').click()`);
|
||||
}
|
||||
|
||||
await viewport(1600, 1000);
|
||||
await send('Emulation.setEmulatedMedia', { features: [{ name: 'prefers-reduced-motion', value: 'reduce' }] });
|
||||
await load();
|
||||
await ev(`document.querySelector('.landing-video-trigger').click()`); await delay(1500);
|
||||
check('reduced motion: opens without autoplay', await ev(`document.getElementById('landing-video').open&&document.querySelector('#landing-video video').paused`));
|
||||
await send('Emulation.setEmulatedMedia', { features: [] });
|
||||
|
||||
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({ result: report.result, failure: report.failure, metrics: report.metrics, errors: report.errors })); ws?.close();
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
// 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/landing-horizontal-20261109/applied-browser');
|
||||
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: process.env.PIERZAINER_URL || 'http://127.0.0.1:5173/' });
|
||||
await until(`typeof window.openSampleProject==='function'`);
|
||||
await until(`document.querySelector('.landing-promo-image')?.complete&&document.querySelector('.landing-promo-image').naturalWidth>0`);
|
||||
await delay(1800);
|
||||
check('landing uses the new high-resolution WebP',await ev(`document.querySelector('.landing-promo-image').src.includes('okseong-model-hero.webp')&&document.querySelector('.landing-promo-image').naturalWidth===4344`));
|
||||
check('eight projected markers include both abutments',await ev(`document.querySelectorAll('.landing-pier-beacon').length===8&&!!document.querySelector('[data-structure="A1"]')&&!!document.querySelector('[data-structure="A2"]')`));
|
||||
check('30m end margin is active',await ev(`(async()=>{const {heroCapture}=await import('/'+encodeURIComponent('랜딩페이지')+'/assets/okseong-hero-capture.ts');return heroCapture.endMarginM===30;})()`));
|
||||
check('taller frame and rounded terrain are active',await ev(`(async()=>{const {heroCapture}=await import('/'+encodeURIComponent('랜딩페이지')+'/assets/okseong-hero-capture.ts');return heroCapture.height===870&&heroCapture.cornerRadiusM===18&&document.querySelector('.landing-promo-image').naturalHeight===1740;})()`));
|
||||
check('horizontal view and smooth inward outline are applied',await ev(`(async()=>{const {heroCapture}=await import('/'+encodeURIComponent('랜딩페이지')+'/assets/okseong-hero-capture.ts');return heroCapture.along===0&&heroCapture.outline.method==='inward-alpha-envelope-quadratic'&&heroCapture.outline.addedOutsideSourcePixels===0&&heroCapture.outline.retainedTerrainRatio>.9;})()`));
|
||||
const expectedHash=require('node:crypto').createHash('sha256').update(fs.readFileSync(path.resolve(__dirname,'../.verify/landing-horizontal-20261109/horizontal-smooth.webp'))).digest('hex');
|
||||
check('served landing image matches the reviewed horizontal capture',await ev(`(async()=>{const bytes=await(await fetch(document.querySelector('.landing-promo-image').src)).arrayBuffer();const hash=[...new Uint8Array(await crypto.subtle.digest('SHA-256',bytes))].map(n=>n.toString(16).padStart(2,'0')).join('');return hash===${JSON.stringify(expectedHash)};})()`));
|
||||
check('both road extensions follow valid terrain sections',await ev(`(async()=>{const {heroCapture}=await import('/'+encodeURIComponent('랜딩페이지')+'/assets/okseong-hero-capture.ts');return heroCapture.roads.length===2&&heroCapture.roads.every(r=>r.missingSections===0&&r.validSections===81&&r.visualizationOnly);})()`));
|
||||
check('decorative frame includes both abutments',await ev(`(()=>{const box=document.querySelector('.landing-hud-bracket').getBBox(),a=document.querySelector('[data-structure="A1"]').transform.baseVal.getItem(0).matrix,b=document.querySelector('[data-structure="A2"]').transform.baseVal.getItem(0).matrix;return box.x<a.e&&box.x+box.width>b.e;})()`));
|
||||
check('restored light follows projected structures from left to right',await ev(`(()=>{const path=document.getElementById('landing-alignment-path'),first=path.getPointAtLength(0),last=path.getPointAtLength(path.getTotalLength()),a=document.querySelector('[data-structure="A1"]').transform.baseVal.getItem(0).matrix,b=document.querySelector('[data-structure="A2"]').transform.baseVal.getItem(0).matrix;return first.x<last.x&&Math.abs(first.x-a.e)<.1&&Math.abs(last.x-b.e)<.1;})()`));
|
||||
check('light follows dense terrain samples rather than coping elevations',await ev(`(async()=>{const {heroCapture}=await import('/'+encodeURIComponent('랜딩페이지')+'/assets/okseong-hero-capture.ts');const path=document.getElementById('landing-alignment-path'),samples=heroCapture.terrainFlow.filter(Boolean),first=path.getPointAtLength(0),a=document.querySelector('[data-structure="A1"]').transform.baseVal.getItem(0).matrix;return samples.length>100&&(path.getAttribute('d').match(/L/g)||[]).length>100&&Math.abs(first.y-samples[0].y)<.1&&Math.abs(first.y-a.f)>10;})()`));
|
||||
check('vertical scan, moving grid, and rising sparks are removed',await ev(`!document.querySelector('.landing-scan-mask,.landing-scan-beam,.landing-terrain-grid,.landing-pier-spark')&&![...document.querySelectorAll('.landing-hero *')].some(e=>e.getAnimations().some(a=>['landing-scan-pass','landing-beacon-spark','landing-grid-breathe'].includes(a.animationName)))`));
|
||||
check('traveling light is white',await ev(`[...document.querySelectorAll('#landing-flow-fade stop')].every(e=>getComputedStyle(e).stopColor==='rgb(255, 255, 255)')&&getComputedStyle(document.querySelector('.landing-energy-track')).stroke==='rgb(255, 255, 255)'`));
|
||||
const offset=async time=>ev(`(()=>{const e=document.querySelector('.landing-energy-flow'),a=e.getAnimations()[0];if(!a)throw new Error('Missing flow animation');a.pause();a.currentTime=${time};return parseFloat(getComputedStyle(e).strokeDashoffset);})()`);
|
||||
const left=await offset(3500),right=await offset(5000);
|
||||
check('light actually advances along the path over time',right<left);
|
||||
await shot('light-moving');
|
||||
await ev(`document.querySelector('.landing-energy-flow').getAnimations()[0].play()`);
|
||||
check('asset has transparent background and opaque terrain',await ev(`(()=>{const c=document.createElement('canvas');c.width=100;c.height=34;const ctx=c.getContext('2d');ctx.drawImage(document.querySelector('.landing-promo-image'),0,0,100,34);const data=ctx.getImageData(0,0,100,34).data;let lo=255,hi=0;for(let i=3;i<data.length;i+=4){lo=Math.min(lo,data[i]);hi=Math.max(hi,data[i]);}return lo===0&&hi===255;})()`));
|
||||
await send('Emulation.setEmulatedMedia',{features:[{name:'prefers-reduced-motion',value:'reduce'}]});
|
||||
for(const [width,height] of [[1920,1080],[1600,900],[1280,800],[390,844]]){
|
||||
await send('Emulation.setDeviceMetricsOverride',{width,height,deviceScaleFactor:1,mobile:width<760});
|
||||
await delay(200);
|
||||
await shot(`${width}-landing`);
|
||||
report.layout=await ev(`[...document.querySelectorAll('.landing-cards .landing-primary')].map(e=>({text:e.textContent,rect:e.getBoundingClientRect().toJSON()}))`);
|
||||
check(`no horizontal overflow at ${width}`,await ev(`document.documentElement.scrollWidth<=innerWidth`));
|
||||
check(`all entry actions remain visible at ${width}`,await ev(`[...document.querySelectorAll('.landing-cards .landing-primary')].every(e=>{const r=e.getBoundingClientRect(),hit=document.elementFromPoint(r.x+r.width/2,r.y+r.height/2);return r.width>0&&r.height>0&&r.left>=0&&r.right<=innerWidth+1&&r.top>=0&&r.bottom<=innerHeight&&!!hit&&(hit===e||e.contains(hit));})`));
|
||||
check(`image and projected markers align at ${width}`,await ev(`(()=>{const img=document.querySelector('.landing-promo-image').getBoundingClientRect(),svg=document.querySelector('.landing-model-motion'),view=svg.viewBox.baseVal;return [...document.querySelectorAll('.landing-pier-beacon')].every(g=>{const t=g.transform.baseVal.getItem(0).matrix,p=svg.createSVGPoint();p.x=t.e;p.y=t.f;const actual=p.matrixTransform(svg.getScreenCTM());return Math.abs(actual.x-(img.left+t.e/view.width*img.width))<2&&Math.abs(actual.y-(img.top+t.f/view.height*img.height))<2;});})()`));
|
||||
check(`first-visit guide does not cover structures at ${width}`,await ev(`(()=>{const hint=document.querySelector('.landing-guide-hint');if(hint.hidden)return true;const r=hint.getBoundingClientRect(),svg=document.querySelector('.landing-model-motion');return [...document.querySelectorAll('.landing-pier-beacon')].every(g=>{const m=g.transform.baseVal.getItem(0).matrix,p=svg.createSVGPoint();p.x=m.e;p.y=m.f;const q=p.matrixTransform(svg.getScreenCTM());return q.x<r.left||q.x>r.right||q.y<r.top||q.y>r.bottom;});})()`));
|
||||
await shot(`${width}-landing`);
|
||||
}
|
||||
check('reduced motion stops beacon animation',await ev(`getComputedStyle(document.querySelector('.landing-pier-pulse')).animationName==='none'`));
|
||||
check('reduced motion stops light travel',await ev(`getComputedStyle(document.querySelector('.landing-energy-flow')).animationName==='none'`));
|
||||
await send('Emulation.setDeviceMetricsOverride',{width:1600,height:1000,deviceScaleFactor:1,mobile:false});
|
||||
await click(`document.querySelector('[data-card="sample"] .landing-primary')`);
|
||||
check('design library still opens',await ev(`document.querySelector('.sample-library-popup').open`));
|
||||
await click(`document.querySelector('.sample-library-close')`);
|
||||
await click(`document.querySelector('[data-card="excel"] .landing-primary')`);
|
||||
check('Excel entry still opens',await ev(`document.getElementById('import-dialog').open`));
|
||||
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,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,200 @@
|
||||
// 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/okseong-user-files-20261109');
|
||||
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: process.env.PIERZAINER_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 {defaultLevelWidthMm}=await import('/src/core/geometry.ts');
|
||||
const piers=['직접기초','직접기초','현장타설말뚝'].map((foundationType,i)=>{const input={...base,name:'P'+(i+1),foundationType,type:'T형',columnCount:1,groundEl:30,pileLengthMm:24000,castInPlaceShape:{clayBottomEl:16},columnDiameterMm:2000,footingLengthMm:10400,footingWidthMm:8150,footingHeightMm:2000,blindingMarginMm:100,blindingThicknessMm:100,pileKind:'강관',pileOuterMm:609.6,pileThicknessMm:12,copingH1Mm:1000,copingH2Mm:1500,levelWidthMm:defaultLevelWidthMm('T형',2000)};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===3`);
|
||||
const selectFile = async (selector, files) => {
|
||||
const doc = await send('DOM.getDocument');
|
||||
const {nodeId} = await send('DOM.querySelector', {nodeId:doc.root.nodeId, selector});
|
||||
await send('DOM.setFileInputFiles', {nodeId, files});
|
||||
};
|
||||
await click(`document.getElementById('nav-shape')`);
|
||||
check('shape has a visible project ground entry', await ev(`document.getElementById('project-ground-open').getBoundingClientRect().height>0`));
|
||||
await click(`document.getElementById('project-ground-open')`);
|
||||
check('manager opens without per-pier controls', await ev(`document.getElementById('project-ground-dialog').open&&document.querySelectorAll('#ground-import').length===1`));
|
||||
const modelFile=path.join(out,'ground.json');
|
||||
const raw={schema:2,generated:'2026-11-09',piers:[{id:'P1',ground:{center:29,excel:30},soil:{layers:[{name:'암반',bottom_el:10}]}},{id:'P2',ground:{center:null,excel:30}}]};
|
||||
fs.writeFileSync(modelFile,JSON.stringify(raw));
|
||||
await selectFile('#ground-import',[modelFile]);
|
||||
await until(`document.getElementById('project-ground-info').textContent.includes('2026-11-09')`);
|
||||
check('matching, out-of-range and missing piers are distinguished',await ev(`document.getElementById('project-ground-dialog').textContent.includes('지형 범위 밖')&&document.getElementById('project-ground-dialog').textContent.includes('모델에 없음')&&document.querySelector('[data-ground-pier="P2"]').disabled&&document.querySelector('[data-ground-pier="P3"]').disabled`));
|
||||
check('import alone keeps current design input',await ev(`document.querySelector('.project-ground-table tbody tr').children[2].textContent==='30.000'`));
|
||||
await click(`document.querySelector('[data-ground-pier="P1"]')`);
|
||||
await click(`document.getElementById('project-ground-apply')`);
|
||||
check('selected ground input applies and requires regeneration',await ev(`document.querySelector('.project-ground-table tbody tr').children[2].textContent==='29.000'&&!document.getElementById('project-ground-regenerate').disabled`));
|
||||
await click(`document.getElementById('project-ground-regenerate')`);
|
||||
check('regeneration consumes ground changes',await ev(`document.getElementById('project-ground-regenerate').disabled&&document.querySelector('.project-ground-table tbody tr').textContent.includes('지반고 일치')`));
|
||||
await click(`document.getElementById('project-ground-el')`);
|
||||
await click(`document.getElementById('project-ground-layers')`);
|
||||
await click(`document.getElementById('project-ground-apply')`);
|
||||
check('layers can be applied separately',await ev(`!document.getElementById('project-ground-regenerate').disabled&&document.querySelector('.project-ground-table tbody tr').children[2].textContent==='29.000'`));
|
||||
await click(`document.getElementById('project-ground-regenerate')`);
|
||||
await shot('01-project-ground');
|
||||
await click(`document.querySelector('#project-ground-dialog .viewer-head button')`);
|
||||
await click(`document.getElementById('nav-plan')`);
|
||||
await click(`[...document.querySelectorAll('#members button')].find(b=>b.textContent.includes('지반'))`);
|
||||
check('per-pier ground card has no model generation or file picker',await ev(`!document.getElementById('view-plan').textContent.includes('지반 모델 파일 불러오기 · 지반 생성 프로그램')&&document.querySelectorAll('#view-plan input[type=file]').length===0`));
|
||||
await ev(`(()=>{window.__savedGround=null;const original=URL.createObjectURL;URL.createObjectURL=function(blob){blob.text().then(text=>{try{window.__savedGround=JSON.parse(text);}catch{}});return original.call(this,blob);};})()`);
|
||||
await click(`document.getElementById('save-project')`);
|
||||
await until(`!!window.__savedGround?.groundModel`);
|
||||
const saved=await ev(`window.__savedGround`);
|
||||
check('project saves shared model once and applied inputs',saved.groundModel.piers.length===2&&saved.piers[0].input.groundEl===29&&saved.piers[0].input.strataRows[0].name==='암반'&&saved.piers[1].input.groundEl===30);
|
||||
const savedFile=path.join(out,'saved.pzr.json');fs.writeFileSync(savedFile,JSON.stringify(saved));
|
||||
await selectFile('#project-file',[savedFile]);
|
||||
await until(`document.getElementById('project-ground-status').textContent.includes('불러옴')`);
|
||||
check('project reopen restores ground model',await ev(`document.getElementById('project-ground-status').textContent.includes('2/3')`));
|
||||
// Mock the external engine; deliver an in-flight result after switching projects.
|
||||
await ev(`(()=>{const original=window.fetch;window.__releaseGround=null;window.__groundReady=false;window.fetch=async(url,init)=>{const path=String(url);if(path.includes('/ground-link/prepare'))return new Response(JSON.stringify({drawing:'test.dxf',excelName:'test.xlsx',excelSelected:true}));if(path.includes('/ground-link/upload'))return new Response(JSON.stringify({file:'test.xlsx'}));if(path.includes('/ground-link/launch'))return new Response(JSON.stringify({id:'mock'}));if(path.includes('/ground-link/result')){window.__groundReady=true;return new Promise(resolve=>window.__releaseGround=()=>resolve(new Response(JSON.stringify({ready:true,state:'done',filename:'old.json',text:${JSON.stringify(JSON.stringify({...raw,generated:'mock-generated'}))}}))));}return original(url,init);};})()`);
|
||||
const excel=path.join(out,'source.xlsx');fs.writeFileSync(excel,'mock');
|
||||
const startGeneration = async () => {
|
||||
await ev(`window.__groundReady=false`);
|
||||
await click(`document.getElementById('nav-shape')`);
|
||||
await click(`document.getElementById('project-ground-open')`);
|
||||
await click(`[...document.querySelectorAll('#project-ground-dialog button')].find(b=>b.textContent==='지반모델 생성')`);
|
||||
await until(`document.querySelector('.ground-popup [data-run]')&&!document.querySelector('.ground-popup [data-run]').disabled`);
|
||||
await selectFile('#ground-excel-file',[excel]);
|
||||
await click(`document.querySelector('.ground-popup [data-run]')`);
|
||||
await until(`window.__groundReady`);
|
||||
};
|
||||
await startGeneration();
|
||||
await click(`document.querySelector('.ground-popup [data-close]')`);
|
||||
await click(`document.querySelector('#project-ground-dialog .viewer-head button')`);
|
||||
await click(`document.getElementById('nav-rebar')`);
|
||||
await ev(`window.__releaseGround()`);
|
||||
await until(`document.querySelector('.ground-popup [data-status]').textContent.includes('생성 완료')`);
|
||||
await click(`document.getElementById('nav-shape')`);
|
||||
await click(`document.getElementById('project-ground-open')`);
|
||||
check('generated model imports after leaving both manager and ground card',await ev(`document.getElementById('project-ground-info').textContent.includes('mock-generated')`));
|
||||
await click(`document.querySelector('#project-ground-dialog .viewer-head button')`);
|
||||
await startGeneration();
|
||||
await selectFile('#project-file',[file]);
|
||||
await until(`document.getElementById('project-ground-status').textContent==='모델 없음'`);
|
||||
await ev(`window.__releaseGround()`);
|
||||
await delay(300);
|
||||
check('late generated result does not contaminate another project',await ev(`document.getElementById('project-ground-status').textContent==='모델 없음'`));
|
||||
check('project switch closes manager and generator',await ev(`!document.getElementById('project-ground-dialog').open&&!document.querySelector('.ground-popup').open`));
|
||||
await selectFile('#project-file',[savedFile]);
|
||||
await until(`document.getElementById('project-ground-status').textContent.includes('불러옴')`);
|
||||
await ev(`(async()=>{const bytes=await(await fetch('/'+encodeURIComponent('도면추출')+'/samples/'+encodeURIComponent('운곡교P1입력.xlsx'))).arrayBuffer();window.loadBytesToApp(new Uint8Array(bytes),'새 프로젝트.xlsx');const name=document.getElementById('project-name');name.value='새 프로젝트';name.dispatchEvent(new Event('input'));document.getElementById('import-dialog').showModal();})()`);
|
||||
await until(`document.getElementById('import-dialog').open&&!document.getElementById('dlg-apply').disabled`);
|
||||
await click(`document.getElementById('dlg-apply')`);
|
||||
await until(`document.getElementById('project-ground-status').textContent==='모델 없음'`);
|
||||
check('new Excel import clears previous project ground model',await ev(`document.getElementById('project-ground-status').textContent==='모델 없음'`));
|
||||
// Real generated JSON and the adjacent settings file reproduce the reported failure.
|
||||
const actualFolder=path.resolve(__dirname,'../pierzainer_ground/data/output/drawing_01_옥성교_종평면도');
|
||||
const actualModel=path.join(actualFolder,'ground_model.json');
|
||||
const expected=JSON.parse(fs.readFileSync(actualModel,'utf8'));
|
||||
const six={...fixture,piers:Array.from({length:6},(_,i)=>{
|
||||
const input={...fixture.piers[0].input,name:'P'+(i+1)};
|
||||
return{id:input.name,input,defaults:input,draft:input};
|
||||
})};
|
||||
const sixFile=path.join(out,'six-piers.pzr.json');fs.writeFileSync(sixFile,JSON.stringify(six));
|
||||
await selectFile('#project-file',[sixFile]);
|
||||
await until(`document.querySelectorAll('#pier-items button').length===6`);
|
||||
await click(`document.getElementById('nav-shape')`);
|
||||
await click(`document.getElementById('project-ground-open')`);
|
||||
await selectFile('#ground-import',[path.join(actualFolder,'generation_settings.json')]);
|
||||
await until(`document.getElementById('project-ground-dialog').textContent.includes('지반 생성 설정 파일')`);
|
||||
check('settings file reports correct result filenames and blocks application',await ev(`document.getElementById('project-ground-apply').disabled&&document.getElementById('project-ground-status').textContent==='모델 없음'&&document.getElementById('project-ground-dialog').textContent.includes('ground_model_3d_with_maps.json')`));
|
||||
await selectFile('#ground-import',[actualModel]);
|
||||
await until(`document.getElementById('project-ground-status').textContent.includes('6/6')`);
|
||||
check('real Okseong JSON imports all six piers',await ev(`document.querySelectorAll('[data-ground-pier]:not(:disabled)').length===6`));
|
||||
await click(`[...document.querySelectorAll('#project-ground-dialog button')].find(b=>b.textContent==='적용 가능한 교각 선택')`);
|
||||
check('select all enables application',await ev(`document.querySelectorAll('[data-ground-pier]:checked').length===6&&!document.getElementById('project-ground-apply').disabled`));
|
||||
await click(`document.getElementById('project-ground-apply')`);
|
||||
const applied=await ev(`[...document.querySelectorAll('.project-ground-table tbody tr')].map(row=>Number(row.children[2].textContent))`);
|
||||
check('all six design elevations match real JSON',applied.every((el,i)=>el===expected.piers[i].ground.center));
|
||||
await click(`document.getElementById('project-ground-regenerate')`);
|
||||
check('all six applied elevations regenerate',await ev(`document.getElementById('project-ground-regenerate').disabled`));
|
||||
await selectFile('#ground-import',[path.join(actualFolder,'generation_settings.json')]);
|
||||
await until(`document.getElementById('project-ground-dialog').textContent.includes('지반 생성 설정 파일')`);
|
||||
check('invalid replacement preserves the loaded project ground model',await ev(`document.getElementById('project-ground-status').textContent.includes('6/6')`));
|
||||
await shot('02-real-okseong');
|
||||
await click(`document.querySelector('#project-ground-dialog .viewer-head button')`);
|
||||
const userFolder=process.env.OKSEONG_FILES||'C:\\Users\\User\\Downloads\\NAVER WORKS';
|
||||
const userExcel=path.join(userFolder,'옥성교 데이터_261106.xlsx'),userJson=path.join(userFolder,'옥성교 지형.json');
|
||||
await selectFile('#file',[userExcel]);
|
||||
await until(`document.getElementById('file-name').textContent.includes('선택된 파일: 옥성교 데이터_261106.xlsx')&&!document.getElementById('dlg-apply').disabled`);
|
||||
await ev(`document.getElementById('import-dialog').showModal()`);
|
||||
await click(`document.getElementById('dlg-apply')`);
|
||||
await until(`document.getElementById('project-ground-status').textContent==='모델 없음'`);
|
||||
const before=await ev(`[...document.querySelectorAll('#inputs-all tbody tr')].map(row=>row.textContent)`);
|
||||
const projectBefore=await ev(`document.getElementById('project-line').textContent`);
|
||||
await click(`document.getElementById('nav-shape')`);
|
||||
await click(`document.getElementById('project-ground-open')`);
|
||||
await selectFile('#ground-import',[userJson]);
|
||||
await until(`document.getElementById('project-ground-status').textContent.includes('6/6')`);
|
||||
check('user JSON imports six elevations plus terrain and two maps',await ev(`document.getElementById('project-ground-info').textContent.includes('지도 2개')&&document.getElementById('project-ground-info').textContent.includes('지형 있음')`));
|
||||
check('importing embedded model preserves user Excel inputs and project',await ev(`JSON.stringify([...document.querySelectorAll('#inputs-all tbody tr')].map(row=>row.textContent))`)===JSON.stringify(before)&&await ev(`document.getElementById('project-line').textContent`)===projectBefore);
|
||||
await click(`[...document.querySelectorAll('#project-ground-dialog button')].find(b=>b.textContent==='적용 가능한 교각 선택')`);
|
||||
await click(`document.getElementById('project-ground-apply')`);
|
||||
const userExpected=JSON.parse(fs.readFileSync(userJson,'utf8')).groundModel;
|
||||
const userApplied=await ev(`[...document.querySelectorAll('.project-ground-table tbody tr')].map(row=>Number(row.children[2].textContent))`);
|
||||
check('user Excel accepts all six elevations from embedded ground model',userApplied.length===6&&userApplied.every((el,i)=>el===userExpected.piers[i].centerEl));
|
||||
await click(`document.getElementById('project-ground-regenerate')`);
|
||||
check('user project regenerates after ground application',await ev(`document.getElementById('project-ground-regenerate').disabled`));
|
||||
await shot('03-user-files-applied');
|
||||
await click(`document.querySelector('#project-ground-dialog .viewer-head button')`);
|
||||
await send('Browser.setDownloadBehavior',{behavior:'deny'});
|
||||
await ev(`window.__savedGround=null`);
|
||||
await click(`document.getElementById('save-project')`);
|
||||
await until(`document.getElementById('save-dialog').open`);
|
||||
await click(`document.getElementById('sv-save')`);
|
||||
await until(`window.__savedGround?.source?.fileName==='옥성교 데이터_261106.xlsx'`);
|
||||
check('saved result retains user Excel source and applied embedded terrain',await ev(`window.__savedGround.piers.length===6&&window.__savedGround.piers[0].input.groundEl===18.011&&window.__savedGround.groundModel.mapLayers.length===2`));
|
||||
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,138 @@
|
||||
// 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/project-ground-20261109');
|
||||
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: process.env.PIERZAINER_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 {defaultLevelWidthMm}=await import('/src/core/geometry.ts');
|
||||
const piers=['직접기초','직접기초','현장타설말뚝'].map((foundationType,i)=>{const input={...base,name:'P'+(i+1),foundationType,type:'T형',columnCount:1,groundEl:30,pileLengthMm:24000,castInPlaceShape:{clayBottomEl:16},columnDiameterMm:2000,footingLengthMm:10400,footingWidthMm:8150,footingHeightMm:2000,blindingMarginMm:100,blindingThicknessMm:100,pileKind:'강관',pileOuterMm:609.6,pileThicknessMm:12,copingH1Mm:1000,copingH2Mm:1500,levelWidthMm:defaultLevelWidthMm('T형',2000)};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===3`);
|
||||
const selectFile = async (selector, files) => {
|
||||
const doc = await send('DOM.getDocument');
|
||||
const {nodeId} = await send('DOM.querySelector', {nodeId:doc.root.nodeId, selector});
|
||||
await send('DOM.setFileInputFiles', {nodeId, files});
|
||||
};
|
||||
await click(`document.getElementById('nav-shape')`);
|
||||
check('shape has a visible project ground entry', await ev(`document.getElementById('project-ground-open').getBoundingClientRect().height>0`));
|
||||
await click(`document.getElementById('project-ground-open')`);
|
||||
check('manager opens without per-pier controls', await ev(`document.getElementById('project-ground-dialog').open&&document.querySelectorAll('#ground-import').length===1`));
|
||||
const modelFile=path.join(out,'ground.json');
|
||||
const raw={schema:2,generated:'2026-11-09',piers:[{id:'P1',ground:{center:29,excel:30},soil:{layers:[{name:'암반',bottom_el:10}]}},{id:'P2',ground:{center:null,excel:30}}]};
|
||||
fs.writeFileSync(modelFile,JSON.stringify(raw));
|
||||
await selectFile('#ground-import',[modelFile]);
|
||||
await until(`document.getElementById('project-ground-info').textContent.includes('2026-11-09')`);
|
||||
check('matching, out-of-range and missing piers are distinguished',await ev(`document.getElementById('project-ground-dialog').textContent.includes('지형 범위 밖')&&document.getElementById('project-ground-dialog').textContent.includes('모델에 없음')&&document.querySelector('[data-ground-pier="P2"]').disabled&&document.querySelector('[data-ground-pier="P3"]').disabled`));
|
||||
check('import alone keeps current design input',await ev(`document.querySelector('.project-ground-table tbody tr').children[2].textContent==='30.000'`));
|
||||
await click(`document.querySelector('[data-ground-pier="P1"]')`);
|
||||
await click(`document.getElementById('project-ground-apply')`);
|
||||
check('selected ground input applies and requires regeneration',await ev(`document.querySelector('.project-ground-table tbody tr').children[2].textContent==='29.000'&&!document.getElementById('project-ground-regenerate').disabled`));
|
||||
await click(`document.getElementById('project-ground-regenerate')`);
|
||||
check('regeneration consumes ground changes',await ev(`document.getElementById('project-ground-regenerate').disabled&&document.querySelector('.project-ground-table tbody tr').textContent.includes('지반고 일치')`));
|
||||
await click(`document.getElementById('project-ground-el')`);
|
||||
await click(`document.getElementById('project-ground-layers')`);
|
||||
await click(`document.getElementById('project-ground-apply')`);
|
||||
check('layers can be applied separately',await ev(`!document.getElementById('project-ground-regenerate').disabled&&document.querySelector('.project-ground-table tbody tr').children[2].textContent==='29.000'`));
|
||||
await click(`document.getElementById('project-ground-regenerate')`);
|
||||
await shot('01-project-ground');
|
||||
await click(`document.querySelector('#project-ground-dialog .viewer-head button')`);
|
||||
await click(`document.getElementById('nav-plan')`);
|
||||
await click(`[...document.querySelectorAll('#members button')].find(b=>b.textContent.includes('지반'))`);
|
||||
check('per-pier ground card has no model generation or file picker',await ev(`!document.getElementById('view-plan').textContent.includes('지반 모델 파일 불러오기 · 지반 생성 프로그램')&&document.querySelectorAll('#view-plan input[type=file]').length===0`));
|
||||
await ev(`(()=>{window.__savedGround=null;const original=URL.createObjectURL;URL.createObjectURL=function(blob){blob.text().then(text=>{try{window.__savedGround=JSON.parse(text);}catch{}});return original.call(this,blob);};})()`);
|
||||
await click(`document.getElementById('save-project')`);
|
||||
await until(`!!window.__savedGround?.groundModel`);
|
||||
const saved=await ev(`window.__savedGround`);
|
||||
check('project saves shared model once and applied inputs',saved.groundModel.piers.length===2&&saved.piers[0].input.groundEl===29&&saved.piers[0].input.strataRows[0].name==='암반'&&saved.piers[1].input.groundEl===30);
|
||||
const savedFile=path.join(out,'saved.pzr.json');fs.writeFileSync(savedFile,JSON.stringify(saved));
|
||||
await selectFile('#project-file',[savedFile]);
|
||||
await until(`document.getElementById('project-ground-status').textContent.includes('불러옴')`);
|
||||
check('project reopen restores ground model',await ev(`document.getElementById('project-ground-status').textContent.includes('2/3')`));
|
||||
// Mock the external engine; deliver an in-flight result after switching projects.
|
||||
await ev(`(()=>{const original=window.fetch;window.__releaseGround=null;window.__groundReady=false;window.fetch=async(url,init)=>{const path=String(url);if(path.includes('/ground-link/prepare'))return new Response(JSON.stringify({drawing:'test.dxf',excelName:'test.xlsx',excelSelected:true}));if(path.includes('/ground-link/upload'))return new Response(JSON.stringify({file:'test.xlsx'}));if(path.includes('/ground-link/launch'))return new Response(JSON.stringify({id:'mock'}));if(path.includes('/ground-link/result')){window.__groundReady=true;return new Promise(resolve=>window.__releaseGround=()=>resolve(new Response(JSON.stringify({ready:true,state:'done',filename:'old.json',text:${JSON.stringify(JSON.stringify({...raw,generated:'mock-generated'}))}}))));}return original(url,init);};})()`);
|
||||
const excel=path.join(out,'source.xlsx');fs.writeFileSync(excel,'mock');
|
||||
const startGeneration = async () => {
|
||||
await ev(`window.__groundReady=false`);
|
||||
await click(`document.getElementById('nav-shape')`);
|
||||
await click(`document.getElementById('project-ground-open')`);
|
||||
await click(`[...document.querySelectorAll('#project-ground-dialog button')].find(b=>b.textContent==='지반모델 생성')`);
|
||||
await until(`document.querySelector('.ground-popup [data-run]')&&!document.querySelector('.ground-popup [data-run]').disabled`);
|
||||
await selectFile('#ground-excel-file',[excel]);
|
||||
await click(`document.querySelector('.ground-popup [data-run]')`);
|
||||
await until(`window.__groundReady`);
|
||||
};
|
||||
await startGeneration();
|
||||
await click(`document.querySelector('.ground-popup [data-close]')`);
|
||||
await click(`document.querySelector('#project-ground-dialog .viewer-head button')`);
|
||||
await click(`document.getElementById('nav-rebar')`);
|
||||
await ev(`window.__releaseGround()`);
|
||||
await until(`document.querySelector('.ground-popup [data-status]').textContent.includes('생성 완료')`);
|
||||
await click(`document.getElementById('nav-shape')`);
|
||||
await click(`document.getElementById('project-ground-open')`);
|
||||
check('generated model imports after leaving both manager and ground card',await ev(`document.getElementById('project-ground-info').textContent.includes('mock-generated')`));
|
||||
await click(`document.querySelector('#project-ground-dialog .viewer-head button')`);
|
||||
await startGeneration();
|
||||
await selectFile('#project-file',[file]);
|
||||
await until(`document.getElementById('project-ground-status').textContent==='모델 없음'`);
|
||||
await ev(`window.__releaseGround()`);
|
||||
await delay(300);
|
||||
check('late generated result does not contaminate another project',await ev(`document.getElementById('project-ground-status').textContent==='모델 없음'`));
|
||||
check('project switch closes manager and generator',await ev(`!document.getElementById('project-ground-dialog').open&&!document.querySelector('.ground-popup').open`));
|
||||
await selectFile('#project-file',[savedFile]);
|
||||
await until(`document.getElementById('project-ground-status').textContent.includes('불러옴')`);
|
||||
await ev(`(async()=>{const bytes=await(await fetch('/'+encodeURIComponent('도면추출')+'/samples/'+encodeURIComponent('운곡교P1입력.xlsx'))).arrayBuffer();window.loadBytesToApp(new Uint8Array(bytes),'새 프로젝트.xlsx');const name=document.getElementById('project-name');name.value='새 프로젝트';name.dispatchEvent(new Event('input'));document.getElementById('import-dialog').showModal();})()`);
|
||||
await until(`document.getElementById('import-dialog').open&&!document.getElementById('dlg-apply').disabled`);
|
||||
await click(`document.getElementById('dlg-apply')`);
|
||||
await until(`document.getElementById('project-ground-status').textContent==='모델 없음'`);
|
||||
check('new Excel import clears previous project ground model',await ev(`document.getElementById('project-ground-status').textContent==='모델 없음'`));
|
||||
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,97 @@
|
||||
// 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/quantity-generation-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 {defaultLevelWidthMm}=await import('/src/core/geometry.ts');
|
||||
const piers=['직접기초','직접기초','현장타설말뚝'].map((foundationType,i)=>{const input={...base,name:'P'+(i+1),foundationType,type:'T형',columnCount:1,groundEl:30,pileLengthMm:24000,castInPlaceShape:{clayBottomEl:16},columnDiameterMm:2000,footingLengthMm:10400,footingWidthMm:8150,footingHeightMm:2000,blindingMarginMm:100,blindingThicknessMm:100,pileKind:'강관',pileOuterMm:609.6,pileThicknessMm:12,copingH1Mm:1000,copingH2Mm:1500,levelWidthMm:defaultLevelWidthMm('T형',2000)};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===3`);
|
||||
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('P3 ·'))`);
|
||||
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('materials is the default result tab', await ev(`!document.getElementById('view-quantity').hidden&&document.querySelector('[data-takeoff-mode="materials"]').getAttribute('aria-pressed')==='true'`));
|
||||
check('local material table preserved', await ev(`document.getElementById('quantity-body').textContent.includes('말뚝-P1')`));
|
||||
await shot('02-materials');
|
||||
check('partial results explicitly export as review only', await ev(`!document.getElementById('quantity-export').disabled&&document.getElementById('quantity-export').textContent.includes('검토용')`));
|
||||
check('piece details expose count and individual lengths', await ev(`!!document.querySelector('#quantity-body details table')&&document.getElementById('quantity-body').textContent.includes('가공 본수')`));
|
||||
await click(`[...document.querySelectorAll('#quantity-pier-items button')].find(b=>b.textContent.startsWith('P1 ·'))`);
|
||||
check('current regular pier permits export', await ev(`!document.getElementById('quantity-export').disabled`));
|
||||
await click(`document.querySelector('[data-takeoff-mode="takeoff"]')`);
|
||||
check('selected pier receives automatic rebar weight', await ev(`!document.querySelector('input[data-variable="rebar"]')&&document.querySelector('.takeoff-workspace').textContent.includes('기존 철근재료표')`));
|
||||
await ev(`(()=>{const e=document.querySelector('select[aria-label="수량산출 대상 교각"]');e.value='P2';e.dispatchEvent(new Event('change'));})()`);
|
||||
check('another pier retains automatic weight despite material filter', await ev(`document.querySelector('select[aria-label="수량산출 대상 교각"]').value==='P2'&&!document.querySelector('input[data-variable="rebar"]')&&document.querySelector('.takeoff-workspace').textContent.includes('기존 철근재료표')`));
|
||||
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('ga-materials')`);
|
||||
check('drawing shortcut opens materials rather than takeoff', await ev(`!document.getElementById('view-quantity').hidden&&document.querySelector('[data-takeoff-mode="materials"]').getAttribute('aria-pressed')==='true'`));
|
||||
await click(`document.getElementById('nav-plan')`);
|
||||
check('plan remains reachable after drawing', await ev(`!document.getElementById('view-plan').hidden`));
|
||||
await ev(`(()=>{const e=document.getElementById('edit-copingLengthMm');e.value=Number(e.value)+100;e.dispatchEvent(new Event('change'));})()`);
|
||||
await click(`document.getElementById('nav-rebar')`);
|
||||
await click(`document.getElementById('rebar-materials')`);
|
||||
check('dirty geometry blocks Excel and print with reason', await ev(`document.getElementById('quantity-export').disabled&&document.getElementById('quantity-print').disabled&&document.getElementById('quantity-body').textContent.includes('재생성 필요')`));
|
||||
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,109 @@
|
||||
// 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/quantity-navigation-20261109');
|
||||
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: process.env.PIERZAINER_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 {defaultLevelWidthMm}=await import('/src/core/geometry.ts');
|
||||
const piers=['직접기초','직접기초','현장타설말뚝'].map((foundationType,i)=>{const input={...base,name:'P'+(i+1),foundationType,type:'T형',columnCount:1,groundEl:30,pileLengthMm:24000,castInPlaceShape:{clayBottomEl:16},columnDiameterMm:2000,footingLengthMm:10400,footingWidthMm:8150,footingHeightMm:2000,blindingMarginMm:100,blindingThicknessMm:100,pileKind:'강관',pileOuterMm:609.6,pileThicknessMm:12,copingH1Mm:1000,copingH2Mm:1500,levelWidthMm:defaultLevelWidthMm('T형',2000)};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===3`);
|
||||
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('P3 ·'))`);
|
||||
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('materials is the default result tab', await ev(`!document.getElementById('view-quantity').hidden&&document.querySelector('[data-takeoff-mode="materials"]').getAttribute('aria-pressed')==='true'`));
|
||||
check('local material table preserved', await ev(`document.getElementById('quantity-body').textContent.includes('말뚝-P1')`));
|
||||
await shot('02-materials');
|
||||
check('partial results explicitly export as review only', await ev(`!document.getElementById('quantity-export').disabled&&document.getElementById('quantity-export').textContent.includes('검토용')`));
|
||||
check('piece details expose count and individual lengths', await ev(`!!document.querySelector('#quantity-body details table')&&document.getElementById('quantity-body').textContent.includes('가공 본수')`));
|
||||
await click(`[...document.querySelectorAll('#quantity-pier-items button')].find(b=>b.textContent.startsWith('P1 ·'))`);
|
||||
check('current regular pier permits export', await ev(`!document.getElementById('quantity-export').disabled`));
|
||||
await click(`document.querySelector('[data-takeoff-mode="takeoff"]')`);
|
||||
check('selected pier receives automatic rebar weight', await ev(`!document.querySelector('input[data-variable="rebar"]')&&document.querySelector('.takeoff-workspace').textContent.includes('기존 철근재료표')`));
|
||||
await ev(`(()=>{const e=document.querySelector('select[aria-label="수량산출 대상 교각"]');e.value='P2';e.dispatchEvent(new Event('change'));})()`);
|
||||
check('another pier retains automatic weight despite material filter', await ev(`document.querySelector('select[aria-label="수량산출 대상 교각"]').value==='P2'&&!document.querySelector('input[data-variable="rebar"]')&&document.querySelector('.takeoff-workspace').textContent.includes('기존 철근재료표')`));
|
||||
check('takeoff displays input, results and preview panels', await ev(`document.querySelectorAll('.tq-dashboard-grid > section, .tq-dashboard-grid > aside').length===3&&!!document.querySelector('.takeoff-excel-preview')`));
|
||||
await shot('04-takeoff');
|
||||
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('ga-materials')`);
|
||||
check('drawing shortcut preserves takeoff tab and selected pier', await ev(`!document.getElementById('view-quantity').hidden&&document.querySelector('[data-takeoff-mode="takeoff"]').getAttribute('aria-pressed')==='true'&&document.querySelector('select[aria-label="수량산출 대상 교각"]').value==='P2'`));
|
||||
check('quantity header alone is highlighted', await ev(`document.getElementById('nav-quantity').getAttribute('aria-current')==='true'&&document.getElementById('nav-rebar').getAttribute('aria-current')==='false'`));
|
||||
await click(`document.getElementById('nav-rebar')`);
|
||||
await click(`[...document.querySelectorAll('#rebar-pier-items button')].find(b=>b.textContent.startsWith('P1 ·'))`);
|
||||
await click(`document.getElementById('nav-quantity')`);
|
||||
check('header entry restores previous result pier after another screen changes selection', await ev(`document.querySelector('select[aria-label="수량산출 대상 교각"]').value==='P2'`));
|
||||
await click(`document.querySelector('[data-takeoff-mode="materials"]')`);
|
||||
await click(`document.getElementById('nav-rebar')`);
|
||||
await click(`document.getElementById('rebar-materials')`);
|
||||
check('rebar shortcut preserves materials tab', await ev(`document.querySelector('[data-takeoff-mode="materials"]').getAttribute('aria-pressed')==='true'`));
|
||||
await click(`[...document.querySelectorAll('#quantity-pier-items button')].find(b=>b.textContent.startsWith('P1 ·'))`);
|
||||
await click(`document.getElementById('nav-plan')`);
|
||||
check('plan remains reachable after drawing', await ev(`!document.getElementById('view-plan').hidden`));
|
||||
await ev(`(()=>{const e=document.getElementById('edit-copingLengthMm');e.value=Number(e.value)+100;e.dispatchEvent(new Event('change'));})()`);
|
||||
await click(`document.getElementById('nav-rebar')`);
|
||||
await click(`document.getElementById('rebar-materials')`);
|
||||
check('dirty geometry blocks Excel and print with reason', await ev(`document.getElementById('quantity-export').disabled&&document.getElementById('quantity-print').disabled&&document.getElementById('quantity-body').textContent.includes('재생성 필요')`));
|
||||
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,120 @@
|
||||
// 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/takeoff-alignment-20261106/browser');
|
||||
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('Browser.setDownloadBehavior', {behavior:'deny'}); // Request transport is mocked below; native Excel is verified separately.
|
||||
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 {defaultLevelWidthMm}=await import('/src/core/geometry.ts');
|
||||
const piers=['직접기초','직접기초','현장타설말뚝'].map((foundationType,i)=>{const input={...base,name:'P'+(i+1),foundationType,type:'T형',columnCount:1,groundEl:30,pileLengthMm:24000,castInPlaceShape:{clayBottomEl:16},columnDiameterMm:2000,footingLengthMm:10400,footingWidthMm:8150,footingHeightMm:2000,blindingMarginMm:100,blindingThicknessMm:100,pileKind:'강관',pileOuterMm:609.6,pileThicknessMm:12,copingH1Mm:1000,copingH2Mm:1500,levelWidthMm:defaultLevelWidthMm('T형',2000)};return{id:input.name,input,defaults:input,draft:input};});
|
||||
return{schemaVersion:1,project:{name:'병합 검증',bridge:'검증',client:'',author:'',crs:'',memo:''},source:{fileName:'합성 표본',sheets:[]},piers,history:[]};
|
||||
})()`);
|
||||
for(const pier of fixture.piers){
|
||||
const settings={values:{r20:0,r21:2.8,r22:0,r23:0},reviewed:true};
|
||||
for(const key of ['input','defaults','draft'])pier[key]={...pier[key],quantityTakeoff:settings};
|
||||
}
|
||||
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===3`);
|
||||
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('P3 ·'))`);
|
||||
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('materials is the default result tab', await ev(`!document.getElementById('view-quantity').hidden&&document.querySelector('[data-takeoff-mode="materials"]').getAttribute('aria-pressed')==='true'`));
|
||||
check('local material table preserved', await ev(`document.getElementById('quantity-body').textContent.includes('말뚝-P1')`));
|
||||
await shot('02-materials');
|
||||
check('partial results explicitly export as review only', await ev(`!document.getElementById('quantity-export').disabled&&document.getElementById('quantity-export').textContent.includes('검토용')`));
|
||||
check('piece details expose count and individual lengths', await ev(`!!document.querySelector('#quantity-body details table')&&document.getElementById('quantity-body').textContent.includes('가공 본수')`));
|
||||
await click(`[...document.querySelectorAll('#quantity-pier-items button')].find(b=>b.textContent.startsWith('P1 ·'))`);
|
||||
check('current regular pier permits export', await ev(`!document.getElementById('quantity-export').disabled`));
|
||||
await click(`document.querySelector('[data-takeoff-mode="takeoff"]')`);
|
||||
check('selected pier receives automatic rebar weight', await ev(`!document.querySelector('input[data-variable="rebar"]')&&document.querySelector('.takeoff-workspace').textContent.includes('기존 철근재료표')`));
|
||||
check('selected and all Excel exports are separate', await ev(`document.querySelector('[data-takeoff-export="selected"]').textContent==='선택 교각 Excel'&&document.querySelector('[data-takeoff-export="all"]').textContent==='전체 교각 Excel'`));
|
||||
await click(`document.querySelector('button[aria-label="수량산출 재계산"]')`);
|
||||
await ev(`(()=>{
|
||||
const original=window.fetch;window.__takeoffRequests=[];
|
||||
window.fetch=(url,options)=>String(url).includes('/__pierzainer/quantity/export')?new Promise(resolve=>{window.__takeoffRequests.push(JSON.parse(options.body));window.__finishTakeoff=()=>resolve(new Response(new Uint8Array([80,75]),{status:200}));}):original(url,options);
|
||||
})()`);
|
||||
await click(`document.querySelector('[data-takeoff-export="selected"]')`);
|
||||
await until(`window.__takeoffRequests.length===1`);
|
||||
check('selected request carries one pier and model solplate mode', await ev(`window.__takeoffRequests[0].piers.length===1&&window.__takeoffRequests[0].piers[0].pier==='P1'&&window.__takeoffRequests[0].piers[0].solplateMode==='model'`));
|
||||
await ev(`(()=>{const e=document.querySelector('input[data-variable="r21"]');e.value='4';e.dispatchEvent(new Event('change'));})()`);
|
||||
check('rerender during export keeps both export buttons locked', await ev(`[...document.querySelectorAll('[data-takeoff-export]')].every(b=>b.disabled)`));
|
||||
await ev(`window.__finishTakeoff()`);
|
||||
await until(`!document.querySelector('[data-takeoff-export="selected"]').disabled`);
|
||||
check('async export preserves edited input and displays snapshot warning', await ev(`document.querySelector('input[data-variable="r21"]').value==='4'&&document.querySelector('.takeoff-workspace [role="status"]').textContent.includes('입력은 보존')`));
|
||||
const requests=await ev(`window.__takeoffRequests.length`);
|
||||
await click(`document.querySelector('[data-takeoff-export="all"]')`);
|
||||
await delay(200);
|
||||
check('all export validates other incomplete piers before requesting server', await ev(`window.__takeoffRequests.length===${requests}&&document.querySelector('.takeoff-workspace [role="status"]').textContent.includes('P3')`));
|
||||
await ev(`(()=>{const e=document.querySelector('select[aria-label="수량산출 대상 교각"]');e.value='P2';e.dispatchEvent(new Event('change'));})()`);
|
||||
check('another pier retains automatic weight despite material filter', await ev(`document.querySelector('select[aria-label="수량산출 대상 교각"]').value==='P2'&&!document.querySelector('input[data-variable="rebar"]')&&document.querySelector('.takeoff-workspace').textContent.includes('기존 철근재료표')`));
|
||||
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('ga-materials')`);
|
||||
check('drawing shortcut opens materials rather than takeoff', await ev(`!document.getElementById('view-quantity').hidden&&document.querySelector('[data-takeoff-mode="materials"]').getAttribute('aria-pressed')==='true'`));
|
||||
await click(`document.getElementById('nav-plan')`);
|
||||
check('plan remains reachable after drawing', await ev(`!document.getElementById('view-plan').hidden`));
|
||||
await ev(`(()=>{const e=document.getElementById('edit-copingLengthMm');e.value=Number(e.value)+100;e.dispatchEvent(new Event('change'));})()`);
|
||||
await click(`document.getElementById('nav-rebar')`);
|
||||
await click(`document.getElementById('rebar-materials')`);
|
||||
check('dirty geometry blocks Excel and print with reason', await ev(`document.getElementById('quantity-export').disabled&&document.getElementById('quantity-print').disabled&&document.getElementById('quantity-body').textContent.includes('재생성 필요')`));
|
||||
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,49 @@
|
||||
// Run with Windows Node against isolated Chrome CDP 9444 and Vite 5173.
|
||||
const fs=require('node:fs'),path=require('node:path'),assert=require('node:assert/strict');
|
||||
const root=path.resolve(__dirname,'..'),out=path.join(root,'.verify/ungok-terrain-fixed-20261110/browser');
|
||||
fs.mkdirSync(out,{recursive:true});
|
||||
const report={checks:[],errors:[]};let ws;
|
||||
const delay=ms=>new Promise(r=>setTimeout(r,ms));
|
||||
(async()=>{
|
||||
const tab=await(await fetch('http://127.0.0.1:9444/json/new?about:blank',{method:'PUT'})).json();
|
||||
ws=new WebSocket(tab.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(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);};
|
||||
const send=(method,params={})=>new Promise((resolve,reject)=>{const id=++seq,timer=setTimeout(()=>reject(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 Error(r.exceptionDetails.exception?.description||r.exceptionDetails.text);return r.result.value;};
|
||||
const until=async expression=>{for(let i=0;i<150;i++){if(await ev(expression))return;await delay(200);}throw 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 Error('Missing target '+${JSON.stringify(expression)});e.click();})()`);
|
||||
const file=async(selector,name)=>{const d=await send('DOM.getDocument'),{nodeId}=await send('DOM.querySelector',{nodeId:d.root.nodeId,selector});await send('DOM.setFileInputFiles',{nodeId,files:[name]});};
|
||||
const shot=async name=>{const s=await send('Page.captureScreenshot',{format:'png'});fs.writeFileSync(path.join(out,name+'.png'),Buffer.from(s.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 settings=JSON.parse(fs.readFileSync(path.join(root,'pierzainer_ground/data/output/ungok_corrected_20261110/generation_settings.json')));
|
||||
const workbook='\\\\wsl.localhost\\Ubuntu'+settings.piers.replaceAll('/','\\');
|
||||
await ev('window.openSampleProject()');await file('#file',workbook);
|
||||
await until(`document.getElementById('file-name').textContent.includes('piers.xlsx')`);
|
||||
await ev(`(()=>{const e=document.getElementById('project-name');e.value='운곡교 지반 수정 검증';e.dispatchEvent(new Event('input'));document.getElementById('import-dialog').showModal();})()`);
|
||||
await until(`!document.getElementById('dlg-apply').disabled`);
|
||||
await click(`document.getElementById('dlg-apply')`);await click(`document.getElementById('nav-shape')`);await click(`document.getElementById('project-ground-open')`);
|
||||
check('actual workbook lists all 16 structures',await ev(`document.querySelectorAll('[data-ground-pier]').length===16`));
|
||||
await file('#ground-import',path.join(root,'pierzainer_ground/data/output/ungok_corrected_20261110/ground_model_3d_with_maps.json'));
|
||||
await until(`document.getElementById('project-ground-status').textContent.includes('16/16')`);
|
||||
check('all structures have applicable corrected ground',await ev(`[...document.querySelectorAll('[data-ground-pier]')].every(e=>!e.disabled)`));
|
||||
await click(`document.getElementById('project-ground-select-all')`);
|
||||
check('all 16 structures are selected for application',await ev(`document.querySelectorAll('[data-ground-pier]:checked').length===16`));
|
||||
await click(`document.getElementById('project-ground-apply')`);
|
||||
await click(`document.getElementById('project-ground-regenerate')`);
|
||||
await until(`document.getElementById('project-ground-regenerate').disabled`);
|
||||
check('all changes regenerate successfully',await ev(`document.getElementById('project-ground-regenerate').disabled`));
|
||||
await shot('01-corrected-manager');await click(`document.querySelector('#project-ground-dialog .viewer-head button')`);
|
||||
await delay(1000);await shot('02-corrected-terrain');
|
||||
await send('Browser.setDownloadBehavior',{behavior:'deny'});
|
||||
await ev(`(()=>{window.__ungokSaved=null;const original=URL.createObjectURL;URL.createObjectURL=function(blob){blob.text().then(t=>{try{window.__ungokSaved=JSON.parse(t);}catch{}});return original.call(this,blob);};})()`);
|
||||
await click(`document.getElementById('save-project')`);await until(`document.getElementById('save-dialog').open`);await click(`document.getElementById('sv-save')`);await until(`!!window.__ungokSaved?.groundModel`);
|
||||
const saved=await ev(`window.__ungokSaved`);fs.writeFileSync(path.join(out,'verified.pzr.json'),JSON.stringify(saved));
|
||||
check('saved project retains 16 corrected ground records and two maps',saved.groundModel.piers.length===16&&saved.groundModel.mapLayers.length===2);
|
||||
const expected=JSON.parse(fs.readFileSync(path.join(root,'pierzainer_ground/data/output/ungok_corrected_20261110/ground_model_3d_with_maps.json')));
|
||||
check('saved terrain elevations equal generated JSON',JSON.stringify(saved.groundModel.terrainGrid.z)===JSON.stringify(expected.terrain_grid.z));
|
||||
check('browser runtime has no 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,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,135 @@
|
||||
// Run with Windows Node and an isolated Chrome on CDP 9444; Vite must be on 5173.
|
||||
// ②·③ center workspace: stacked ↔ side-by-side, splitter drag/keyboard, persistence, width fallback.
|
||||
const fs = require('node:fs'), path = require('node:path'), assert = require('node:assert/strict');
|
||||
const root = path.resolve(__dirname, '..');
|
||||
const out = path.resolve(root, '.verify/workspace-layout-20261109');
|
||||
fs.mkdirSync(out, { recursive: true });
|
||||
const report = { checks: [], errors: [], metrics: {} };
|
||||
let ws, send;
|
||||
const delay = ms => new Promise(r => setTimeout(r, ms));
|
||||
let target;
|
||||
(async () => {
|
||||
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 === 'Page.javascriptDialogOpening') ws.send(JSON.stringify({ id: 900000 + (++seq), method: 'Page.handleJavaScriptDialog', params: { accept: true } }));
|
||||
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)), 8000);
|
||||
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, tries = 120) => {
|
||||
for (let i = 0; i < tries; i++) { try { if (await ev(expression)) return; } catch { /* navigating */ } 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 => { await delay(250); const r = await send('Page.captureScreenshot', { format: 'png' }); fs.writeFileSync(path.join(out, name + '.png'), Buffer.from(r.data, 'base64')); };
|
||||
const viewport = (width, height) => send('Emulation.setDeviceMetricsOverride', { width, height, deviceScaleFactor: 1, mobile: false });
|
||||
const rects = ws_ => ev(`(()=>{const w=document.getElementById('${ws_}'),r=e=>{const b=e.getBoundingClientRect();return{l:Math.round(b.left),t:Math.round(b.top),r:Math.round(b.right),b:Math.round(b.bottom),w:Math.round(b.width),h:Math.round(b.height)};};const c=document.querySelector('#viewer-block canvas');return{ws:r(w),slot:r(w.querySelector('.viewer-slot')),split:r(w.querySelector('.workspace-splitter')),pane:r(w.querySelector('.workspace-pane')),canvas:c?r(c):null,side:w.classList.contains('layout-side'),note:w.querySelector('.layout-note').textContent,label:w.querySelector('[data-size-label]').textContent,value:w.querySelector('output').textContent};})()`);
|
||||
const drag = async (sel, dx, dy) => {
|
||||
const b = await ev(`(()=>{const r=document.querySelector('${sel}').getBoundingClientRect();return{x:r.left+r.width/2,y:r.top+r.height/2};})()`);
|
||||
await send('Input.dispatchMouseEvent', { type: 'mousePressed', x: b.x, y: b.y, button: 'left', buttons: 1, clickCount: 1 });
|
||||
for (let i = 1; i <= 5; i++) await send('Input.dispatchMouseEvent', { type: 'mouseMoved', x: b.x + dx * i / 5, y: b.y + dy * i / 5, button: 'left', buttons: 1 });
|
||||
await send('Input.dispatchMouseEvent', { type: 'mouseReleased', x: b.x + dx, y: b.y + dy, button: 'left', buttons: 0, clickCount: 1 });
|
||||
await delay(250);
|
||||
};
|
||||
const key = async (k, code, vk) => { for (const type of ['keyDown', 'keyUp']) await send('Input.dispatchKeyEvent', { type, key: k, code, windowsVirtualKeyCode: vk }); await delay(120); };
|
||||
|
||||
await send('Page.enable'); await send('Runtime.enable'); await send('DOM.enable');
|
||||
await viewport(1600, 1000);
|
||||
await send('Page.navigate', { url: process.env.PIERZAINER_URL || 'http://127.0.0.1:5173/' });
|
||||
await until(`!!document.querySelector('[data-card="excel"] button') && typeof window.loadBytesToApp === 'function'`);
|
||||
await ev(`Object.keys(localStorage).filter(k=>k.startsWith('pierzainer.workspace-layout')||k==='pierzainer.review-layout').forEach(k=>localStorage.removeItem(k))`);
|
||||
await send('Page.reload'); await delay(2500);
|
||||
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'));`);
|
||||
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: [path.resolve(root, '../../RAG/운곡교P1입력.xlsx')] });
|
||||
await until(`!document.getElementById('dlg-apply').disabled`);
|
||||
await click(`document.getElementById('dlg-apply')`);
|
||||
await delay(800);
|
||||
|
||||
// ② 계획
|
||||
await click(`document.getElementById('nav-plan')`); await delay(600);
|
||||
let r = await rects('plan-workspace');
|
||||
check('② default is stacked: model above the input pane', !r.side && r.slot.b <= r.pane.t && r.slot.w === r.pane.w);
|
||||
check('② layout switch shows 상하 pressed', await ev(`document.querySelector('#plan-workspace [data-layout="stack"]').getAttribute('aria-pressed')==='true'`));
|
||||
await shot('01-plan-stack');
|
||||
await click(`document.querySelector('#plan-workspace [data-layout="side"]')`); await delay(500);
|
||||
r = await rects('plan-workspace'); report.metrics.planSide = r;
|
||||
check('② 좌우: model left, pane right, same top', r.side && r.slot.r <= r.pane.l && Math.abs(r.slot.t - r.pane.t) <= 1);
|
||||
check('② 좌우 starts near 50 % and shows width label', Math.abs(r.slot.w / r.ws.w - 0.5) < 0.03 && r.label === '모델 폭' && r.value === '50%');
|
||||
check('② 3D canvas follows the narrower slot', r.canvas && r.slot.w - r.canvas.w >= 0 && r.slot.w - r.canvas.w <= 40 && r.canvas.h > 300);
|
||||
check('② pane scrolls independently', await ev(`(()=>{const p=document.querySelector('#plan-workspace .workspace-pane');return getComputedStyle(p).overflowY==='auto';})()`));
|
||||
await shot('02-plan-side');
|
||||
await drag('#plan-workspace .workspace-splitter', -Math.round(r.ws.w * 0.1), 0);
|
||||
r = await rects('plan-workspace');
|
||||
check('② splitter drag narrows model to about 40 %', r.value === '40%' && Math.abs(r.slot.w / r.ws.w - 0.4) < 0.03);
|
||||
await ev(`document.querySelector('#plan-workspace .workspace-splitter').focus()`);
|
||||
await key('ArrowRight', 'ArrowRight', 39);
|
||||
check('② splitter keyboard step (+2 %)', (await rects('plan-workspace')).value === '42%' && await ev(`document.querySelector('#plan-workspace .workspace-splitter').getAttribute('aria-valuenow')==='42'`));
|
||||
await send('Input.dispatchMouseEvent', { type: 'mousePressed', x: r.split.l + 6, y: r.split.t + 100, button: 'left', clickCount: 2 });
|
||||
await send('Input.dispatchMouseEvent', { type: 'mouseReleased', x: r.split.l + 6, y: r.split.t + 100, button: 'left', clickCount: 2 });
|
||||
await ev(`document.querySelector('#plan-workspace .workspace-splitter').dispatchEvent(new MouseEvent('dblclick',{bubbles:true}))`); await delay(200);
|
||||
check('② double-click resets to 50 %', (await rects('plan-workspace')).value === '50%');
|
||||
await click(`document.getElementById('plan-model-toggle')`); await delay(300);
|
||||
r = await rects('plan-workspace');
|
||||
check('② collapsed model: pane takes the full width', r.slot.w === 0 && Math.abs(r.pane.w - r.ws.w) <= 2);
|
||||
await click(`document.getElementById('plan-model-toggle')`); await delay(300);
|
||||
|
||||
// ③ 배근 stays independent
|
||||
await click(`document.getElementById('nav-rebar')`); await delay(700);
|
||||
r = await rects('review-workspace');
|
||||
check('③ preference is independent of ② (still stacked, 240 px)', !r.side && r.value === '240 px' && r.slot.b <= r.pane.t);
|
||||
await drag('#review-workspace .workspace-splitter', 0, 100);
|
||||
r = await rects('review-workspace');
|
||||
check('③ stacked splitter drag grows model height', r.value === '340 px' && Math.abs(r.slot.h - 340) <= 2);
|
||||
await click(`document.querySelector('#review-workspace [data-layout="side"]')`); await delay(500);
|
||||
r = await rects('review-workspace'); report.metrics.rebarSide = r;
|
||||
check('③ 좌우: model left, rebar table right', r.side && r.slot.r <= r.pane.l && r.canvas && r.slot.w - r.canvas.w >= 0 && r.slot.w - r.canvas.w <= 40);
|
||||
await click(`document.getElementById('review-table')`); await delay(300);
|
||||
check('③ 철근표 shortcut still reveals the table in 좌우', await ev(`(()=>{const t=document.activeElement,p=document.querySelector('#review-workspace .workspace-pane > section').getBoundingClientRect();return t.matches('#rebar-table table.rebar-table')&&t.getBoundingClientRect().top>=p.top-2;})()`));
|
||||
await shot('03-rebar-side');
|
||||
|
||||
// persistence
|
||||
await send('Page.reload'); await delay(2500); await until(`typeof window.loadBytesToApp==='function'`); await delay(800);
|
||||
check('preferences persist per stage after reload', await ev(`(()=>{const p=JSON.parse(localStorage.getItem('pierzainer.workspace-layout.plan')),q=JSON.parse(localStorage.getItem('pierzainer.workspace-layout.rebar'));return p.mode==='side'&&p.width===50&&q.mode==='side'&&q.height===340;})()`));
|
||||
await until(`typeof window.openSampleProject==='function'`); await ev('window.openSampleProject()'); await delay(1500);
|
||||
await click(`document.getElementById('nav-plan')`); await delay(600);
|
||||
check('② restored 좌우 when shown after reload', await ev(`document.getElementById('plan-workspace').classList.contains('layout-side')`));
|
||||
await click(`document.getElementById('nav-rebar')`); await delay(600);
|
||||
check('③ restored 좌우 and 340 px height after reload', await ev(`document.getElementById('review-workspace').classList.contains('layout-side')&&getComputedStyle(document.getElementById('review-workspace')).getPropertyValue('--review-model-height').trim()==='340px'`));
|
||||
await click(`document.getElementById('nav-plan')`); await delay(400);
|
||||
|
||||
// width fallback
|
||||
await viewport(1280, 800); await delay(500);
|
||||
check('1280 px: side request falls back to stacked with a note', await ev(`(()=>{const w=document.getElementById('plan-workspace');return !w.classList.contains('layout-side')&&w.querySelector('.layout-note').textContent.includes('좁아')&&w.querySelector('[data-layout="side"]').getAttribute('aria-pressed')==='true';})()`));
|
||||
await viewport(1920, 1080); await delay(500);
|
||||
check('1920 px: side-by-side restored automatically', await ev(`document.getElementById('plan-workspace').classList.contains('layout-side')`));
|
||||
await viewport(1000, 900); await delay(500);
|
||||
check('1000 px (mobile frame): stacked, splitter hidden', await ev(`(()=>{const w=document.getElementById('plan-workspace');return !w.classList.contains('layout-side')&&getComputedStyle(w.querySelector('.workspace-splitter')).display==='none';})()`));
|
||||
for (const width of [1280, 1600, 1920]) {
|
||||
await viewport(width, 1000); await delay(300);
|
||||
check(`${width} px: no horizontal page scroll`, await ev(`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({ result: report.result, failure: report.failure, errors: report.errors }));
|
||||
if (target?.id) fetch('http://127.0.0.1:9444/json/close/' + target.id).catch(() => {});
|
||||
ws?.close();
|
||||
});
|
||||
@@ -0,0 +1,194 @@
|
||||
/**
|
||||
* 교대 받침 카드 (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), 받침마다 그 받침의 값 (2026-11-10): 엑셀 받침높이가 정한 거더 하면을 그대로 둔다
|
||||
* (시안은 교각 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)] })),
|
||||
{
|
||||
// A typed h3 is for every bearing; otherwise h3 = 그 받침의 받침높이 (없으면 교대 값) − T_ref.
|
||||
h3Mm: rule.h3Mm, tRefMm: rule.tRefMm,
|
||||
h3ByNo: Object.fromEntries(model.bearings.map((b) => [String(b.no), (b.heightMm ?? heightMm) - 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,215 @@
|
||||
/**
|
||||
* 교대 형상 · 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;
|
||||
/** 이 받침의 받침높이 H (mm, 2026-11-10). 없으면 교대 받침높이를 쓴다. */
|
||||
heightMm?: 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;
|
||||
/** 이 받침의 받침높이 H (mm). 입력이 없으면 undefined. */
|
||||
heightMm?: 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, heightMm: b.heightMm, 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,250 @@
|
||||
/**
|
||||
* 교대 입력 읽기 (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): 받침별 값이 없을 때의 기본값. 받침마다 다르면 받침별 값을 쓴다 (2026-11-10, 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개 이상 필요합니다.` });
|
||||
// 받침높이가 받침마다 달라도 받침별 값을 쓴다 (2026-11-10). 교대 값은 첫 받침의 값.
|
||||
ab.bearingHeightMm = ab.bearings[0]?.bearingHeightMm ?? 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, heightMm: Number.isFinite(b.bearingHeightMm) ? b.bearingHeightMm : undefined })),
|
||||
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 };
|
||||
}
|
||||
+165
-46
@@ -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, SOLE_DEFAULTS, 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,11 +22,19 @@ 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,
|
||||
/** W11 (제안 2026-11-10): 받침블럭 콘크리트(패드 제외) 최소 높이 = 피복 50 + D13 = 63 mm (교대 I01과 같은 기준). */
|
||||
blockMinHeight: 0.063,
|
||||
footingL: 10.4,
|
||||
footingW: 8.15,
|
||||
footingH: 2.0,
|
||||
@@ -74,8 +90,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}). */
|
||||
@@ -110,6 +128,11 @@ export interface FootingModel {
|
||||
bottom: number;
|
||||
length: number;
|
||||
width: number;
|
||||
/**
|
||||
* One footing across the kink (2026-11-10): this record is one stretch's part, reaching past the mitre (the mitre plane
|
||||
* trims it), and gFrom … gTo is the whole footing in unfolded g. Rebar and quantities take the pair as one footing.
|
||||
*/
|
||||
bent?: { gFrom: number; gTo: number; part: 0 | 1 };
|
||||
}
|
||||
|
||||
export interface BlindingModel {
|
||||
@@ -204,6 +227,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 +255,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 +275,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 };
|
||||
@@ -306,28 +336,44 @@ export function buildPier(input: PierInput): PierModel {
|
||||
};
|
||||
const frontPlaced = front.map((b) => ({ no: b.no, row: '전열' as Row, z: b.z, ...place(b) }));
|
||||
const backPlaced = back.map((b) => ({ no: b.no, row: '후열' as Row, z: b.z, ...place(b) }));
|
||||
if (unequalRows) { frontPlaced.sort((a,b)=>a.g-b.g); backPlaced.sort((a,b)=>a.g-b.g); }
|
||||
// Rows run along the coping (2026-11-10): bearing numbers may run against IP1 → IP3 (산외교 P9 ① at g 12.1, ③ at 1.3),
|
||||
// so the steps are always built in g order, not number order.
|
||||
frontPlaced.sort((a,b)=>a.g-b.g); backPlaced.sort((a,b)=>a.g-b.g);
|
||||
|
||||
// Step boundaries sit midway between consecutive front bearings in coping coordinates (spec §4.3).
|
||||
// A step boundary less than half a block from the kink moves onto it (2026-11-10), so the step face is the mitre
|
||||
// plane and the two stretches meet without a wedge; only when both bearings stay half a block away from the kink.
|
||||
const halfBlock = DIM.blockSize / 2;
|
||||
const toKink = (bound: number, gLeft: number, gRight: number) =>
|
||||
kinkG !== null && Math.abs(bound - kinkG) < halfBlock && kinkG - gLeft >= halfBlock && gRight - kinkG >= halfBlock ? kinkG : bound;
|
||||
const bounds = [0];
|
||||
for (let k = 0; k < frontPlaced.length - 1; k++) {
|
||||
bounds.push((frontPlaced[k].g + frontPlaced[k + 1].g) / 2);
|
||||
bounds.push(toKink((frontPlaced[k].g + frontPlaced[k + 1].g) / 2, frontPlaced[k].g, frontPlaced[k + 1].g));
|
||||
}
|
||||
bounds.push(L);
|
||||
const segments: Segment[] = frontPlaced.map((b, k) => ({ s0: bounds[k], s1: bounds[k + 1], top: b.z - h1 }));
|
||||
// 교좌면 EL per bearing (2026-11-10): when given, it is the coping top of that bearing's step; otherwise z − h1 (z − h2 behind).
|
||||
const seatOf = new Map(input.bearings.map((r) => [r.no, r.seatEl]));
|
||||
const segments: Segment[] = frontPlaced.map((b, k) => ({ s0: bounds[k], s1: bounds[k + 1], top: seatOf.get(b.no) ?? b.z - h1 }));
|
||||
|
||||
// Lateral step (spec §4.3, Q1·Q2 추천안): front and back tops differ by 5 mm or more in some segment.
|
||||
// The back steps break midway between consecutive back bearings (2026-11-10), as the front ones do: back bearings need
|
||||
// not sit opposite the front ones (산외교 P11 ⑥ ↔ ⑦). With equal counts, a back bound less than half a block from the
|
||||
// front bound snaps to it, so slightly staggered rows (P1, ≈ 0.09 m) keep one boundary and no sliver step.
|
||||
const backBound = (k: number) => {
|
||||
const own = (backPlaced[k-1].g + backPlaced[k].g) / 2;
|
||||
return !unequalRows && Math.abs(own - bounds[k]) < halfBlock ? bounds[k] : toKink(own, backPlaced[k-1].g, backPlaced[k].g);
|
||||
};
|
||||
const backSegments: Segment[] = backPlaced.map((b,k) => ({
|
||||
s0: unequalRows ? (k === 0 ? 0 : (backPlaced[k-1].g+b.g)/2) : segments[k].s0,
|
||||
s1: unequalRows ? (k === backPlaced.length-1 ? L : (b.g+backPlaced[k+1].g)/2) : segments[k].s1,
|
||||
top: b.z-h2,
|
||||
s0: k === 0 ? 0 : backBound(k),
|
||||
s1: k === backPlaced.length-1 ? L : backBound(k + 1),
|
||||
top: seatOf.get(b.no) ?? b.z-h2,
|
||||
}));
|
||||
const stepBounds = [...new Set([0,L,...segments.slice(1).map(s=>s.s0),...backSegments.slice(1).map(s=>s.s0)])].sort((a,b)=>a-b);
|
||||
const stepSamples = stepBounds.slice(1).map((end,k)=>(stepBounds[k]+end)/2);
|
||||
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 +386,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 +395,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.
|
||||
@@ -362,36 +412,70 @@ export function buildPier(input: PierInput): PierModel {
|
||||
};
|
||||
// Each stretch uses the same outline shifted to its own base; the mitre plane trims the overlap at the kink.
|
||||
const shifted = (pts: [number, number][], b: number): [number, number][] => pts.map(([s, z]) => [s - b, z]);
|
||||
// A step at the kink (2026-11-10): each stretch's step face is square to its own axis, the mitre is not, so the face
|
||||
// moves past the mitre spread (B/2)·tan(θ/2) into the trimmed side: stretch 0 keeps its left top up to the mitre and
|
||||
// stretch 1 its right top back to it, and the visible step is the mitre plane itself (no sliver on either half).
|
||||
const mitreSpread = (B / 2) * Math.tan((Math.abs(ip4.angleDeg) * Math.PI) / 360) + 0.01;
|
||||
const atKink = (pts: [number, number][], k: number): [number, number][] =>
|
||||
kinkG === null || mitreSpread >= halfBlock ? pts
|
||||
: pts.map(([s, z]) => [Math.abs(s - kinkG) < 1e-9 ? kinkG + (k === 0 ? mitreSpread : -mitreSpread) : s, z]);
|
||||
|
||||
const meanN = (rows: { n: number }[]) => rows.reduce((sum, r) => sum + r.n, 0) / rows.length;
|
||||
const copingParts: CopingPart[] = [];
|
||||
const stretchCount = kinkG === null ? 1 : 2;
|
||||
// One front/back line for the whole pier (2026-11-10), so the parts of the two stretches meet at the kink without a
|
||||
// sideways jog; per stretch only when some bearing would be closer than half a block to that line.
|
||||
const nAll = (meanN(frontPlaced) + meanN(backPlaced)) / 2;
|
||||
const oneLine = [...frontPlaced, ...backPlaced].every((b) => Math.abs(b.n - nAll) >= halfBlock);
|
||||
for (let k = 0; k < stretchCount; k++) {
|
||||
const fRows = frontPlaced.filter((b) => b.k === k);
|
||||
const bRows = backPlaced.filter((b) => b.k === k);
|
||||
const fRows = oneLine ? frontPlaced : frontPlaced.filter((b) => b.k === k);
|
||||
const bRows = oneLine ? backPlaced : backPlaced.filter((b) => b.k === k);
|
||||
const nStep = fRows.length && bRows.length
|
||||
? (meanN(fRows) + meanN(bRows)) / 2
|
||||
: (meanN(frontPlaced) + meanN(backPlaced)) / 2;
|
||||
: nAll;
|
||||
if (lateralStep) {
|
||||
// The front row may lie on either side of the axis (it depends on the IP1 → IP3 direction), so each part takes the
|
||||
// side of its own row; the order stays [front, back].
|
||||
const frontLow = (fRows.length && bRows.length ? meanN(fRows) - meanN(bRows) : meanN(frontPlaced) - meanN(backPlaced)) <= 0;
|
||||
copingParts.push(
|
||||
{ k, nFrom: -planHalfB, nTo: nStep, profile: shifted(outline(segments), base[k]) },
|
||||
{ k, nFrom: nStep, nTo: planHalfB, profile: shifted(outline(backSegments), base[k]) },
|
||||
{ k, nFrom: frontLow ? -planHalfB : nStep, nTo: frontLow ? nStep : planHalfB, profile: shifted(atKink(outline(segments), k), base[k]) },
|
||||
{ k, nFrom: frontLow ? nStep : -planHalfB, nTo: frontLow ? planHalfB : nStep, profile: shifted(atKink(outline(backSegments), k), base[k]) },
|
||||
);
|
||||
} else {
|
||||
copingParts.push({ k, nFrom: -planHalfB, nTo: planHalfB, profile: shifted(outline(segments), base[k]) });
|
||||
copingParts.push({ k, nFrom: -planHalfB, nTo: planHalfB, profile: shifted(atKink(outline(segments), k), base[k]) });
|
||||
}
|
||||
}
|
||||
|
||||
const fd = footingDims(input);
|
||||
// Footings are grouped per stretch so that a 일체기초 never crosses the kink; each group is aligned with its stretch.
|
||||
const footingSets: { s: number; k: number; length: number }[] = [];
|
||||
const footingSets: { s: number; k: number; length: number; bent?: FootingModel['bent'] }[] = [];
|
||||
for (let k = 0; k < stretchCount; k++) {
|
||||
for (const g of footingGroups(colG.filter((_, i) => colK[i] === k), fd.L)) {
|
||||
footingSets.push({ s: g.s - base[k], k, length: g.length });
|
||||
}
|
||||
}
|
||||
// Footings that meet or overlap across the kink become one bent footing (2026-11-10, 산외교 P11: 2.75 m overlap).
|
||||
// Each part reaches past the mitre spread (W/2)·tan(θ/2) so the trimmed concrete joins without a wedge gap.
|
||||
if (kinkG !== null) {
|
||||
const last0 = footingSets.filter((f) => f.k === 0).sort((a, b) => b.s - a.s)[0];
|
||||
const first1 = footingSets.filter((f) => f.k === 1).sort((a, b) => a.s - b.s)[0];
|
||||
if (last0 && first1) {
|
||||
const spreadF = (fd.W / 2) * Math.tan((Math.abs(ip4.angleDeg) * Math.PI) / 360);
|
||||
const g0s = last0.s - last0.length / 2, g0e = last0.s + last0.length / 2;
|
||||
const g1s = kinkG + first1.s - first1.length / 2, g1e = kinkG + first1.s + first1.length / 2;
|
||||
if (g0e >= g1s - 2 * spreadF) {
|
||||
const ext = spreadF + 0.01;
|
||||
const p0e = Math.max(g0e, kinkG + ext), p1s = Math.min(g1s, kinkG - ext) - kinkG;
|
||||
const bent = { gFrom: g0s, gTo: g1e };
|
||||
Object.assign(last0, { s: (g0s + p0e) / 2, length: p0e - g0s, bent: { ...bent, part: 0 as const } });
|
||||
Object.assign(first1, { s: (p1s + g1e - kinkG) / 2, length: g1e - kinkG - p1s, bent: { ...bent, part: 1 as const } });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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,15 +488,15 @@ 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) {
|
||||
footings.push({ s: f.s, k: f.k, top: footingTop, bottom: input.footingBottomEl, length: f.length, width: fd.W });
|
||||
footings.push({ s: f.s, k: f.k, top: footingTop, bottom: input.footingBottomEl, length: f.length, width: fd.W, ...(f.bent ? { bent: f.bent } : {}) });
|
||||
blindings.push({
|
||||
s: f.s,
|
||||
k: f.k,
|
||||
@@ -421,30 +505,53 @@ export function buildPier(input: PierInput): PierModel {
|
||||
width: fd.W + fd.blM,
|
||||
thickness: fd.blT,
|
||||
});
|
||||
if (input.foundationType === '말뚝기초') {
|
||||
const g = pileGeometry(f.length, fd.W);
|
||||
if (input.foundationType === '말뚝기초' && f.bent?.part !== 1) {
|
||||
// A bent footing lays its piles once over the unfolded length; each pile goes to the stretch of its side of the kink.
|
||||
const len = f.bent ? f.bent.gTo - f.bent.gFrom : f.length;
|
||||
const g = pileGeometry(len, fd.W);
|
||||
const counts = resolveRowCounts(input.pileRows, g);
|
||||
piles.push(...pilesFor(f.s, input.footingBottomEl, pileLength, g, counts, fd.pileD).map((p) => ({ ...p, k: f.k })));
|
||||
const centre = f.bent ? (f.bent.gFrom + f.bent.gTo) / 2 : f.s;
|
||||
piles.push(...pilesFor(centre, input.footingBottomEl, pileLength, g, counts, fd.pileD).map((p) =>
|
||||
f.bent && kinkG !== null && p.s > kinkG ? { ...p, s: p.s - kinkG, k: 1 } : { ...p, k: f.k }));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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.
|
||||
// 받침높이 per bearing (2026-11-10): h3 = H − T_ref keeps the girder underside at z + H; a typed h3 still wins.
|
||||
const tRef = input.sole?.tRefMm ?? SOLE_DEFAULTS.tRefMm;
|
||||
const h3ByNo = Object.fromEntries(input.bearings.filter((b) => b.heightMm !== undefined).map((b) => [String(b.no), b.heightMm! - tRef]));
|
||||
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, h3ByNo }, 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.
|
||||
// seatCalc stays the slope value (z − h1, z − h2) as the W10 reference.
|
||||
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 = b.z - h1;
|
||||
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 +591,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 +602,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 +610,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];
|
||||
}
|
||||
|
||||
@@ -72,8 +72,21 @@ function numOrNull(v: unknown): number | null {
|
||||
export function checkGroundModel(raw: unknown): GroundModelFile {
|
||||
const obj = raw as Partial<GroundModelFile> | null;
|
||||
if (!obj || typeof obj !== 'object') throw new Error('지반 모델 파일 형식이 아닙니다.');
|
||||
if (!('schema' in obj) && 'schemaVersion' in obj) {
|
||||
const project = obj as { schemaVersion?: unknown; groundModel?: unknown };
|
||||
if (project.schemaVersion !== 1) throw new Error(`프로젝트 형식 번호 ${String(project.schemaVersion)}에서 지반모델을 가져올 수 없습니다.`);
|
||||
if (project.groundModel == null) throw new Error('이 프로젝트 파일에는 지반모델이 없습니다. 프로젝트 전체는 [불러오기 → 프로젝트 열기]에서 열 수 있습니다. 지반 결과는 ground_model.json을 선택하세요.');
|
||||
return checkStoredGroundModel(project.groundModel);
|
||||
}
|
||||
const knownSchema = schemaOrLegacy(obj);
|
||||
if (knownSchema === undefined) {
|
||||
const data = obj as Record<string, unknown>;
|
||||
if ('drawing' in data && 'output' in data && typeof data.piers === 'string') {
|
||||
throw new Error('지반 생성 설정 파일입니다. 계산 결과인 ground_model.json 또는 ground_model_3d_with_maps.json을 선택하세요.');
|
||||
}
|
||||
if (!('schema' in data)) {
|
||||
throw new Error('계산된 지반모델을 찾을 수 없습니다. ground_model.json 또는 ground_model_3d_with_maps.json을 선택하세요. 설정·진단·결과 요약 JSON은 적용할 수 없습니다.');
|
||||
}
|
||||
throw new Error(`지반 모델 형식 번호 ${String((obj as { schema?: unknown }).schema)}을(를) 읽을 수 없습니다 (지원: ${READABLE_SCHEMAS.join(', ')}).`);
|
||||
}
|
||||
if (!READABLE_SCHEMAS.includes(knownSchema as number)) {
|
||||
|
||||
+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,9 @@ 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]),
|
||||
// 받침높이_mm, optional (2026-11-10): blank = the pier h3 setting.
|
||||
heightMm: c[8] === undefined || c[8] === null || c[8] === '' ? undefined : Number(c[8]),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { GroundModelFile, TerrainGrid } from './ground-model';
|
||||
import type { AbutRecord } from './abut/parse';
|
||||
import type { PierInput } from './types';
|
||||
|
||||
export function applyProjectGround(draft: PierInput, model: GroundModelFile, options: { ground: boolean; layers: boolean }): PierInput {
|
||||
const entry = model.piers.find(p => p.id === draft.name);
|
||||
if (!entry) return draft;
|
||||
return { ...draft,
|
||||
...(options.ground && entry.centerEl !== null && Number.isFinite(entry.centerEl) ? { groundEl: entry.centerEl } : {}),
|
||||
...(options.layers && entry.layers?.length ? { strataRows: entry.layers.map(row => ({ ...row })) } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function groundInputChanged(input: PierInput, draft: PierInput): boolean {
|
||||
return input.groundEl !== draft.groundEl || JSON.stringify(input.strataRows ?? []) !== JSON.stringify(draft.strataRows ?? []);
|
||||
}
|
||||
|
||||
/** No extrapolation or filling null nodes; coordinates are survey north/east. */
|
||||
export function sampleGroundGrid(grid: TerrainGrid, point: { e: number; n: number }): number | null {
|
||||
const x = (point.n - grid.origin[0]) / grid.step, y = (point.e - grid.origin[1]) / grid.step;
|
||||
if (!Number.isFinite(x) || !Number.isFinite(y) || x < 0 || y < 0 || x > grid.nx - 1 || y > grid.ny - 1) return null;
|
||||
const i = Math.min(Math.floor(x), grid.nx - 2), j = Math.min(Math.floor(y), grid.ny - 2);
|
||||
const h = [grid.z[j * grid.nx + i], grid.z[j * grid.nx + i + 1], grid.z[(j + 1) * grid.nx + i], grid.z[(j + 1) * grid.nx + i + 1]];
|
||||
if (h.some(v => v == null || !Number.isFinite(v))) return null;
|
||||
const u = x - i, v = y - j;
|
||||
return h[0]! * (1-u) * (1-v) + h[1]! * u * (1-v) + h[2]! * (1-u) * v + h[3]! * u * v;
|
||||
}
|
||||
|
||||
export function abutGroundFromModel(model: GroundModelFile | null, abut: Pick<AbutRecord, 'name' | 'ip2'>) {
|
||||
const explicit = model?.piers.find(p => p.id === abut.name);
|
||||
if (explicit) return { ...explicit, source: 'JSON 개별값' };
|
||||
const centerEl = model?.terrainGrid ? sampleGroundGrid(model.terrainGrid, abut.ip2) : null;
|
||||
return { id: abut.name, centerEl, layers: undefined, source: centerEl === null ? 'IP2 격자 자료 없음' : 'IP2 격자 보간' };
|
||||
}
|
||||
@@ -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,56 @@
|
||||
import * as XLSX from 'xlsx';
|
||||
import { quantityRows, type Quantity } from './rebar/quantity';
|
||||
|
||||
export interface QuantityRecord {
|
||||
name: string;
|
||||
quantity: Quantity | null;
|
||||
dirty: boolean;
|
||||
}
|
||||
|
||||
/** Export checks use the same records as the visible result, including ungenerated piers. */
|
||||
export function quantityExportIssues(records: QuantityRecord[]): string[] {
|
||||
return records.flatMap(r => r.dirty ? [`${r.name}: 형상 재생성 필요`] : !r.quantity ? [`${r.name}: 형상 없음`] : []);
|
||||
}
|
||||
|
||||
export function quantityPieceRows(records: QuantityRecord[]): (string | number)[][] {
|
||||
const rows: (string | number)[][] = [['교각', '부재', '강종', '번호', '규격', '조각', '길이 (mm)', '가공 본수', '총길이 (m)', '총중량 (kg)']];
|
||||
for (const r of records) for (const g of r.quantity?.groups ?? []) for (const d of g.diameters) for (const line of d.lines) {
|
||||
line.pieces.forEach((p, i) => {
|
||||
const totalM = p.length * p.count / 1000;
|
||||
rows.push([r.name, line.member, `SD${g.grade}`, line.name, line.size, i + 1, p.length, p.count, totalM, totalM * line.kgPerM]);
|
||||
});
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
/** Generated values remain numbers; only the display format rounds them. */
|
||||
export function createQuantityWorkbook(records: QuantityRecord[], meta: { project: string; bridge: string; generatedAt: string; abutmentCount: number }): XLSX.WorkBook {
|
||||
const issues = quantityExportIssues(records);
|
||||
if (!records.length || issues.length) throw new Error(issues.join('\n') || '출력할 교각이 없습니다.');
|
||||
const wb = XLSX.utils.book_new(), used = new Set(['합계', '가공 상세', '커플러', '검토 항목', '생성 정보']);
|
||||
const append = (name: string, rows: (string | number)[][]) => XLSX.utils.book_append_sheet(wb, XLSX.utils.aoa_to_sheet(rows), name);
|
||||
const sheetName = (name: string) => {
|
||||
const base = name.replace(/[[\]:*?/\\\x00-\x1f]/g, '_').replace(/^'+|'+$/g, '') || '교각';
|
||||
let candidate = base.slice(0, 31), n = 1;
|
||||
while (used.has(candidate.toLowerCase())) { const suffix = `_${n++}`; candidate = base.slice(0, 31 - suffix.length) + suffix; }
|
||||
used.add(candidate.toLowerCase()); return candidate;
|
||||
};
|
||||
const summary: (string | number)[][] = [['교각', '합계 (kg)', '할증 포함 (kg)', 'ton', '계산 상태']];
|
||||
const couplers: (string | number)[][] = [['교각', '번호', '개수']];
|
||||
const review: (string | number)[][] = [['교각', '미계산·검토 항목']];
|
||||
let kg = 0, kgWithWaste = 0;
|
||||
for (const r of records) {
|
||||
const q = r.quantity!;
|
||||
const status = q.missing.length ? '일부 계산 · 검토용' : '계산 완료';
|
||||
append(sheetName(r.name), [['교각', r.name], ['계산 상태', status], ['할증 (%)', q.wastePercent], [], ...quantityRows(q)]);
|
||||
summary.push([r.name, q.kg, q.kgWithWaste, q.ton, status]);
|
||||
kg += q.kg; kgWithWaste += q.kgWithWaste;
|
||||
q.couplers.forEach(c => couplers.push([r.name, c.name, c.count]));
|
||||
q.missing.forEach(m => review.push([r.name, m]));
|
||||
}
|
||||
const partial = records.some(r => r.quantity!.missing.length);
|
||||
summary.push([partial ? '계산된 부재 소계 · 전체 수량 미완료' : '합계', kg, kgWithWaste, kgWithWaste / 1000, partial ? '검토용' : '계산 완료']);
|
||||
append('합계', summary); append('가공 상세', quantityPieceRows(records)); append('커플러', couplers); append('검토 항목', review);
|
||||
append('생성 정보', [['프로젝트', meta.project], ['교량', meta.bridge], ['생성 시각', meta.generatedAt], ['대상', records.map(r => r.name).join(', ')], ['출력 상태', partial ? '검토용 · 전체 수량 미완료' : '계산 완료'], ['범위', `교각 ${records.length}기 · 교대 ${meta.abutmentCount}기 제외`], ['수량 기준', '할증 전 실제 길이 · 가공 상세 길이×본수로 검산'], ['원표 수량', '철근표 행 수량이며 가공 조각 본수와 다를 수 있음']]);
|
||||
return wb;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Loaded 100 of 260 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user