Compare commits

...
112 Commits
Author SHA1 Message Date
ypshim f51865c57b Merge origin/master with current Pierzainer development
Preserve local abutment, cast-in-place rebar and compact header features alongside remote drawing, ground-link and quantity workspaces. Adapt drawing and takeoff consumers to generated solplate plans, retain current P1 regression coverage, and resolve bearing-detail annotation collisions.

Validation: 122 test files / 975 tests passed; production build passed; 10 browser integration checks passed.
2026-11-06 17:10:33 +09:00
ypshimandClaude Opus 5.5 c9e1cc6eaa feat(import): 엑셀의 프로젝트명 · 교량명을 불러오기 창에 자동 입력
새 입력 양식(운곡교AA.xlsx, 운곡교(완도,광주)데이터)의 '형상정보' B1 '프로젝트명:' → C1,
B2 '교량명:' → C2를 읽어 불러오기 창의 프로젝트명 · 교량명 칸을 채운다.
- core/project-info.ts: 모든 시트의 앞 20행 × 10열에서 라벨을 찾고, 같은 칸 ':' 뒤나 오른쪽 4칸 안의 값을 씀.
  라벨이 없는 엑셀(운곡교P1입력.xlsx)은 아무것도 읽지 않음
- 칸이 비었거나 앞서 자동으로 채운 값일 때만 채움. 사용자가 입력한 값은 덮어쓰지 않음(샘플 이름은 자동 값으로 취급)
- 파일 줄에 읽은 위치 표시: "프로젝트명 · 교량명: 엑셀 형상정보!C1 · 형상정보!C2"
- 테스트 4개, 브라우저: AA 자동 입력 → 직접 고친 프로젝트명은 다음 파일에서도 유지 → 이름 없는 샘플은 그대로

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 16:29:11 +09:00
ypshimandClaude Opus 5.5 bff1502ecf fix(abut): 수축줄눈을 받침 사이 중간에 · 기본 개수 ceil(L/10) − 1 · 3D 선 표시
spec_abut §3 · §4.9 (QA-7 가정)와 맞지 않던 세 가지를 고친다.
- 위치: 등분점(1개면 L/2)이 아니라 가장 가까운 '받침 사이 중간'(= 교좌면 계단 경계). 이전에는 운곡교 교대
  5기 모두 줄눈이 가운데 받침 ③ 블럭을 지났다 (A1: 줄눈 6.099 · 받침 6.037). 이제 A1은 IP3에서 4.905 m
- 개수: 입력이 없으면 ceil(L/10) − 1 (길이에 따라 자동, 이전에는 1 고정). 입력 칸은 비우면 자동
- 3D: 벽체 전면 · 흉벽 전면 · 벽체 배면 · 흉벽 배면에 선으로 표시(저판은 연속이라 없음), '벽체·흉벽' 토글을 따름
- 공통 계산 core/abut/joints.ts를 ② 정면·배면 삽도, 교대 상세 요약, 입력 칸 안내, 3D가 함께 씀
- 테스트: 개수 규칙, A1 위치 = ②·③ 중간 = 계단 경계, 여러 개 · 받침 부족 시 등분, 3D 선 4면

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 16:23:04 +09:00
ypshimandClaude Opus 5.5 1fda705b75 feat(abut): 교대 3D에 받침 본체 · 솔플레이트 (교각과 같은 개념)
교대 3D는 받침블럭 콘크리트와 몰탈패드만 그렸다. ② 받침 카드와 같은 솔플레이트(abutSoles)로 교각처럼 그린다.
- 받침 본체: 솔플레이트 평면에서 사방 100 mm 뺀 상자, 높이 h3, 패드 상면에서
- 솔플레이트: L은 거더 방향(블럭 회전 = 방위각, 적용한 계산 방위각 포함), W는 그 직각, T1은 오르막 쪽
- 거더 하면 고정: T ≠ T_ref이면 블럭 상면 · 패드가 (T − T_ref)만큼 내려감 (받침 ELEVATION TABLE과 일치)
- 부재 표시: '받침·받침블럭'에 교대 bearings, '솔플레이트'에 교대 solplates 연결
- 테스트: A1(짝 P1) 본체 높이 245 · 솔플 상면 z + 245 + 38, 두꺼운 끝이 오르막(A1) 쪽, S −3 % → 패드 −4 mm ·
  상면 유지, soles 없이 부를 때 기존 형상. 브라우저: 교대만 · 교각+교대 가져오기 예외 0, 받침 쌓임 확인

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 16:03:57 +09:00
ypshimandClaude Opus 5.5 c7acfccd51 feat(bearing): 방위각 입력 · 계산 비교와 계산값 적용 버튼
교각·교대 솔플레이트 표에 받침별 '방위각 입력 (CAD)' · '방위각 계산 (CAD)' · '차이 (″, 계산 − 입력)' ·
[적용] 열을 두고, 표 위에 '계산 방위각 모두 적용 / 모두 입력값으로' 버튼을 둔다 (사용자 요청 2026-11-06,
Q-SH4 "입력값 유지"를 사용자가 고르게 바꿈).
- 계산 방위각: 거더 짝이면 두 받침을 잇는 방향, 교대만의 현 짝이면 원곡선 접선(현 방향 ± 방위각 변화의 절반)
- 적용값은 교각 input.azimuthApplied / 교대 record.azimuthApplied에 받침번호별로 저장, 엑셀 값은 그대로.
  블럭 · 받침 · 솔플레이트 회전과 오르막 방향에 쓰고, 짝 찾기는 엑셀 입력값으로
- 차이 표시: 거더 짝 ≤ 10″ / WS01, 현 짝은 '현 · 참고'. 도·분·초 CAD 각(엑셀과 같은 표기)
- 테스트: P1 전열 계산값 ≤ 0.5″ · 후열 없음, 적용한 받침만 블럭 회전 변화, 원곡선 현 접선 = 입력, 교대 toAbutInput 반영
- 브라우저: 운곡교AA.xlsx A1-광주 ① 적용 → 블럭 회전 4.39° → 4.36°, 모두 적용 5개 · 모두 해제, 예외 0

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 15:55:24 +09:00
ypshimandClaude Opus 5.5 7c5f129712 feat(bearing): 교대만 있을 때 A1 ↔ A2 현(chord) 짝 받침
교각 없이 A1 · A2만 불러온 다경간 곡선교(운곡교 광주·완도, 358 m, 방위각 8° 차)는 거더 짝 허용오차
(방위각 ≤ 0.1°)를 넘어 짝이 없었다. 교각이 0기일 때만, A1 받침과 A2 받침을 잇는 현의 방향이 두
방위각의 평균과 0.1° 이내인 받침을 짝으로 한다(원곡선이면 정확히 같다). 같은 노선·같은 번호가 117″,
다른 후보는 1176″ 이상이라 하나로 정해진다.
- S = Δz / 현 길이 (교량 전체 평균 경사), S 근거 '계산', 짝 받침 표시 "A2-광주-① (현)"
- 방위각 검사 칸은 "현 117″ (참고)": 한 거더용 10″ 규칙이 아니므로 WS01은 내지 않음
- 교각이 하나라도 있으면 쓰지 않음. 단경간 직선(A1·A2 방위각 같음)은 기존 거더 짝으로 처리
- 테스트: 곡선 두 노선 합성 10쌍(노선·번호 일치), 교대 카드 표시, 교각 있을 때 미적용
- 브라우저: 운곡교AA.xlsx A1-광주 ①–⑤ ↔ A2-광주 ①–⑤ (현), S −1.900 ~ −1.892 %

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 15:45:33 +09:00
ypshimandClaude Opus 5.5 3bd2340609 feat(bearing): 솔플레이트 표에 '방위각 검사 (″)' 열 (Q-SH4)
교각·교대 솔플레이트 상세 TABLE에 받침별로 엑셀 방위각과 짝 받침 방향의 차이(초)를 보인다.
10″ 이하 "≤ 10″", 넘으면 WS01, 짝이 없으면 "— (짝 없음)".
- BearingSole.azimuthCheckSec (짝의 from/to 쪽에 맞는 값), 표 행 azimuthSec
- 테스트: P1 전열 ≤ 0.5″ · 후열 없음, A1 ≤ 0.5″, P1 ③ 방위각 75°59′ 오타 → 약 87″ · WS01 그 받침만
- 브라우저 확인(운곡교 완도·광주 데이터): P1-광주 전열 짝 A1-광주 0.0–0.3″, 후열 짝 P2-광주 0.0–0.1″

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 15:39:42 +09:00
ypshimandClaude Opus 5.5 54c855ab1a fix(bearing): 짝 받침을 목록 순서가 아니라 거더 기하로 찾음 (두 노선 광주·완도)
운곡교(완도,광주) 데이터에서 구조물 목록이 A1-광주, A1-완도, P1-완도 … P6-완도, P1-광주 … P6-광주,
A2-광주, A2-완도 순서라 "바로 다음 구조물"만 보던 짝짓기가 A1-광주→P1-광주와 P6-완도→A2-완도를
놓쳤다. 이제 모든 다른 구조물의 받침 중 거더를 따라 진행 방향으로 처음 만나는 받침(방위각 ≤ 0.1°,
거더선 ≤ 0.1 m)을 짝으로 한다. 이 데이터에서 14개 연결 × 5 = 70쌍, WS01·WS04 없음, S −2.028 ~ −1.754 %.
교대만 있는 다경간 입력(A1·A2 353 m, 방위각 8° 차)은 짝 없음 → 교대 종단경사 사용 (정상).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 15:35:48 +09:00
ypshimandClaude Opus 5.5 2668f17bf2 feat(import): 교대만으로 가져오기 허용
교각 없이 A1 · A2 같은 교대만으로도 가져오고 저장할 수 있게 한다 (A1 + A2 짝 받침 성립, 2026-11-06 검토).
- 불러오기 창: 교각 또는 교대가 1기 이상이면 [가져오기] 활성, 안내 "교대만 가져옵니다"
- 3D: 교각이 없으면 첫 교대의 IP1을 좌표 기준점으로 교대를 그리고, 카메라를 교대에 맞춤(abutView)
- 프로젝트 파일: 교각 0기 + 교대 저장·열기 왕복 테스트 추가
- 브라우저 검증 scripts/verify-abut-only.cjs (CDP 9444 · Vite 5173): A1만 가져오기 → ① ② ③ 이동,
  런타임 예외 0, 교대 받침 카드(S −1.910 % 종단경사 · T 30 · h3 245 · E 400)와 ③ 교대 철근 표시 확인.
  P1 + A1 함께 가져오기 회귀도 통과

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 15:30:13 +09:00
ypshimandClaude Opus 5.5 9d357de4d1 fix(bearing): 짝 받침은 거더를 따라 처음 만나는 받침 (직선교 후열 오짝 방지)
검토(2026-11-06): 후열이 전열과 같은 거더 선 위에 같은 방위각으로 놓이면(직선교) 거더선 거리가
둘 다 0이라, A1 받침이 더 먼 P1 후열(다음 경간)과 짝을 짓고 모든 받침에 WS04가 떴다.
- 후보(진행 방향, 방위각 ≤ 0.1°, 거더선 ≤ 0.1 m) 중 거더를 따라 가장 가까운 받침을 짝으로 한다
- 그와 0.5 m 이내로 비슷한 거리의 후보가 더 있을 때만 WS04, 그중 거더선에 가까운 것 사용
- 테스트: A1 + A2만(교각 없음) 짝 성립 S −2 %, A1 + P1에서 전열 계산 · 후열 전열 값 가정(WS03),
  직선교 합성 입력에서 전열 ①–⑤와 짝 · WS04 없음

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 15:22:34 +09:00
ypshimandClaude Opus 5.5 364b329c54 feat(rebar): 다주형 코핑 배근을 3주형형상배근.md 규칙으로 재작성
2주형 DWG 실측 규칙(§4–§8)을 생성기에 옮기고, 3D·삽도·철근표가 같은 조각을 쓴다.
- 전단 C9·C10·C11 (H22): 끝면 피복선 1개 + @300, 기둥 구간 8@300, 기둥 옆 밀집 @150
  (바깥 14 · 안쪽 16칸, 가운데 기둥 양쪽 16칸), 기둥 사이 ceil 등분. 번호는 그 자리 하면
  (Level C10 · 오목 C11 · 테이퍼·헌치 C9). 다리 8개 두 묶음(가운데 250), 폐합 4개, 위 끝 = 계단 상면 − c
- 상면 1단 C1(양끝 갈고리) · C4: 8 m 이음, 이음 중심 n등분점 ∓ l_s. 2단 C3 · C2 = 기둥 중심 + 4450까지,
  가운데 기둥 위 C3-1 · C2-1 (± 4450). 폭 27개(cycle-1 14 · cycle-2 13)
- 하면 C7: 전 길이, Level 꺾임점에서 철근을 따라 l_s/2씩 겹침, 양끝 위로 갈고리. C8: 경간마다 ± 2230 안쪽
- 측면 C12(상단 전 길이) · C13(하단, 하면에서 끊김) H16 @200, 다리 8개 위치마다
- C14 크로스타이 연직 600 · 교각축 1200 엇갈림, C15 끝단보다 100 이상 높은 계단 전체에 ⊓
- 경고 RB-M01(밀집 구간 겹침·코핑 끝 초과 → 위치를 합쳐 표시), M02, M03, M04, M05, M06, M08
- 다주형 기본값 피복 120 · 다리 8. 기둥 위치 입력 e_c · s_c 추가(Q-M10 미결, 없으면 L/5)
- §11 재현 테스트: 2주형 112위치(간격 문자열 일치), 3주형 173위치 · C9 78 · C10 41 · C11 54,
  C1/C4 8 m 이음 조각, C3 10324 · C2 9930 · C3-1/C2-1 8900, C7 5조각, C8 8540 × 26, C12 6단 36960,
  C13 4333/4400, RB-M05 위치

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 15:01:55 +09:00
ypshimandClaude Opus 5.5 87b445965b feat(abut): 교대 솔플레이트도 2026-11-06 결정으로 (짝 받침 S · T 하나 · 거더 하면 고정)
교대 받침 카드(spec_abut §5, QA-15)를 교각과 같은 계산(sole-plate.ts)으로 바꾸고 C안 모듈을 정리한다.
- S: 짝 받침(A1 ↔ P1 전열, A2 ↔ Pn) 계산, 없으면 교대 종단경사 i(엑셀 Abut). 직접 수정 가능
- L = A + 100, W = B + 100 (A × B 기본 700 × 640, P1 전열 값 가정), 받침 1열이라 T 하나
- h3 기본값 = 받침 시트 받침높이 − T_ref (A1 275 − 30 = 245): 엑셀 받침높이의 거더 하면을 지킴.
  시안은 P1의 h3 181을 가정했으나 교대 시트에 받침높이가 있어 이렇게 둔다 (입력으로 바꿀 수 있음)
- 블럭 콘크리트 = 블럭 높이 − 패드 − (T − T_ref), E는 그대로
- 입력 칸: h3 · T_ref · T_min · 여유 · A · B · T · S (h3·T·S는 비우면 자동). C안 값(lengthMm 등)은 무시
- A1 검증: S −1.908 … −1.902 % (짝 P1 전열), L 800 · W 740 · T 30 · T1 38 · T2 22, E 400
- src/core/solplate-rule.ts(C안)와 그 테스트 삭제, 솔플레이트 기하 테스트는 새 계획으로

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 14:52:27 +09:00
ypshimandClaude Opus 5.5 a0b0929e13 feat(bearing): 교각 솔플레이트를 2026-11-06 결정으로 (짝 받침 S · 열 T · 거더 하면 고정)
받침방위각_솔플레이트검토.md §3과 시안(Version 110–113)을 따른다 (사용자 결정 2026-11-06: 시안 적용).
11-03 C안(받침높이 H 고정, h3 = H − T, S 입력)을 대체한다.
- S: 교량 전체(A1, P1 … Pn, A2) 거더 짝 받침의 z 차 / 수평거리. 없으면 교각 종단경사 입력 →
  다른 열 같은 순번 값(가정, WS03) → 0 %(WS03). 받침별 직접 수정(수정됨)
- 평면 L = A + 100, W = B + 100 (받침별 A × B, 기본 700 × 640 가정), h3 181
- T = 열마다 max(22, ⌈22 + L·|S|/2⌉), 열 T 직접 수정 가능(T2 < T_min → WS02).
  T1은 오르막 쪽(3D 쐐기의 두꺼운 끝도 그쪽), T2 반대쪽
- 거더 하면 고정: 블럭 상면 = z − (T − T_ref), T_ref 30
- 짝 받침은 불러오기·프로젝트 열기 때 교량 전체로 계산해 교각 생성에 넘김
- ② 카드: h3·T_ref·T_min·여유·열 T·종단경사 입력, 표에 짝 받침·S 근거·받침별 S/A/B 수정·검산 W08·검사
- P1 검증(A1과 함께): S −1.908 … −1.902 %, L 800 · W 740 · T 30 · T1 38 · T2 22, 블럭 높이 변화 없음
- 블럭·솔플레이트 테스트는 A1–P1 짝 문맥으로 실행 (짝이 없으면 S 0 · T 22 · 블럭 +8 mm)
교대(QA-15 C안)와 Pier 시트 종단경사 열 읽기는 아직 남음.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 14:48:10 +09:00
ypshimandClaude Opus 5.5 5fec2743cb feat(cast): 기둥–말뚝 겹침이음 · 말뚝 철근망 근입부 기준 (Q-PL3·Q-PL6)
현장타설말뚝철근.md §5–§6 (사용자 결정 2026-11-06: 11-06 겹침 결정이 11-05 커플러 회신을 대체).
- 희생강관 기본 적용, 상단 지반고 + 2500 (그림 값, Q-PL4 가정). 암반 근입부 지름 입력 추가(기본 D − 150, 강관 없으면 D)
- 말뚝 철근망 반지름을 근입부 기준으로 (D 3000: r 1300 / 1268.2)
- 두 철근 원이 다르면 자동 겹침이음: 기둥 주철근이 지반고 아래 l_s(1.3 l_d, S32 2387)까지 내려감,
  지반고 커플러 없음. 원이 같으면(근입부 없음) 지반고 커플러. 저장된 방식은 그대로 둠
- 겹침 구간의 말뚝 띠철근은 바깥 기둥 주철근을 감싸는 반지름
- 겹침 모드에서도 주기 커플러(기둥 지반고 + @3000, 말뚝 지반고 − @3000, 내측 + 150)
- RB-PL07: 두 원 간격 > min(l_s/5, 150)이면 경고
- 이전 '겹침부 미계산' 표시 제거. 기존 커플러 테스트는 희생강관 미적용 입력으로 유지

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 14:37:12 +09:00
ypshimandClaude Opus 5.5 3d0d484524 feat(ui): 헤더 단계를 시안대로 ①②③으로, 철근재료표는 ③에서 여는 화면으로
시안(11-03·11-06)의 헤더는 ① 형상 생성 · ② 형상계획 · ③ 철근배근뿐이다 (사용자 결정 2026-11-06).
- 헤더의 ④ 재료 버튼을 없앰
- 철근재료표 화면(교각별 표·전체 합계·엑셀·인쇄)은 그대로 두고 ③의 [철근재료표 보기]로 연다
- 재료표 화면에 [← ③ 철근배근] 버튼, 열려 있는 동안 헤더는 ③을 강조
- 브라우저 검증 스크립트 4개가 ③을 거쳐 재료표를 열도록 수정

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 14:31:46 +09:00
ypshimandClaude Opus 5.5 2b26b7c095 fix(rebar): 측면 수평 C12 = 상단 · C13 = 하단 변화구간 (엑셀 Pier B357–G358)
T형과 다주형 모두 엑셀 Pier 시트 철근표(C12 상단, C13 하단(변화구간))에 맞춰
측면 수평철근 번호를 서로 바꾼다 (사용자 결정 2026-11-06).
- 경사 하면에서 끊기는 아래 단 → C13, 끝면까지 가는 위 단 → C12
- 철근표 위치·비고 문구, 3D, 다주형 생성기가 같은 원천(sideRows)을 따른다
- C14 높이 순서를 유지하도록 측면 단을 아래(C13) → 위(C12) 순서로 넘김
- 테스트 기대값의 번호를 교환 (P1: C12 2단 8개, C13 5단 20개)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 14:29:50 +09:00
ypshimandClaude Opus 5.5 fb7b4b021b feat(bearing): 거더 짝 받침 · 종단경사 S · 솔플레이트 열 두께 (Q-SH1·4·8·9, 미연결)
받침방위각_솔플레이트검토.md §3 알고리즘의 순수 함수.
- 이웃 구조물 사이 짝짓기: 진행 방향, 방위각 차 ≤ 0.1°, 거더선 ≤ 0.1 m, 가장 가까운 것 (후보 2개 이상 WS04)
- 방위각 검사 10″ 초과 WS01 (입력 유지)
- S = z 차 / 수평거리, 오르막 쪽 구조물
- 열 두께 T = max(22, ceil(max T_req)), T1 오르막 쪽 / T2
A1–P1 검증: 짝 ①–⑤, S −1.908 / −1.906 / −1.904 / −1.904 / −1.902 %, T 30 · T1 38 · T2 22.

모델·화면에는 아직 연결하지 않는다. 높이 규칙(Q-SH7 블럭 상면 = z − (T − T_ref))이
2026-11-03 C안(받침높이 H 고정, h3 = H − T)과 충돌해 사용자 확인이 필요하다.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 14:14:32 +09:00
ypshimandClaude Opus 5.5 370bd3d7cd feat(shape): 다주형 코핑 하면을 오목형으로 (Level D+200 · 오목 500 · 헌치 1500)
spec §3·§4.4(2026-11-06 개정)와 3주형형상배근.md §2.2(2주형 DWG 실측)를 따른다.
- 기둥 사이 하면: Level 끝에서 헌치 수평 1500 동안 500 올라가 평탄, 다음 기둥 앞에서 내려옴
  (이전 Level + 300, 헌치 300 × 300 대체)
- 다주형 Level 폭 기본값 = D + 200 (입력값이 있으면 입력 우선). T형은 2100 유지
- 오목 평탄부가 없으면 RB-M01 오류
- 3주형 테스트는 오목형이 성립하는 20 m 코핑으로 바꿈 (P1 길이 12 m는 RB-M01)
- 2주형 DWG 치수(L 24200, D 3000) 하면 꺾임점 테스트 추가

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 14:11:15 +09:00
ypshimandClaude Haiku 5.5 42316104df fix(rebar): 다주형 코핑에서 C5·C6 제외 (전용 삽도 근거 없음)
1106 엑셀 '2주형 3주형' 삽도(A92·A61·A38)에 C5·C6이 없어
다주형 상면 주철근 생성에서 제외했다. T형 레거시 경로는 변경하지 않는다.
경고 문구·화면 안내를 갱신하고 제외를 고정하는 테스트를 추가했다.

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-11-06 13:50:30 +09:00
ypshimandClaude Haiku 5.5 da7b13a568 feat(ui): 기둥·말뚝 통합 입력 · 현장타설 화면 · 헤더 한 줄 정리
- 기둥·말뚝 입력을 한 카드로 통합(공통 입력, 구간별 예외 보존, 횡철근 표)
- 현장타설 실배근 · 삽도 · 수량을 화면에 연결하고 프로젝트 저장 · 복원
- 상단 헤더를 한 줄로 축약(상태 축약, 파일 메뉴, 말줄임)
- 디자인 검토 반영(가져오기 범위, ③ 모델 높이 · 접기, 검토 항목 이동)
- 화면 검증 스크립트(verify-unified-input · verify-design-fixes 등) 보관

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-11-06 13:15:01 +09:00
ypshimandClaude Haiku 5.5 2e0bfb6fc8 feat(rebar): 현장타설 말뚝 · 다주형 코핑 형상과 철근 계산
- 현장타설 기둥·말뚝 형상(cast-in-place-shape)과 주철근·횡철근·커플러 계산(rebar/cast-in-place, cast-layout)
- 다주형 코핑 철근(multicol-coping), 기둥 높이 · 양단부 연장
- 철근표 요약 · 재료 수량 · 3D 뷰 연결과 삽도(cast-rebar-svg)
- 검사 기준 갱신과 회귀 테스트, 검증 스크립트 보관

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-11-06 13:15:01 +09:00
ypshimandClaude Haiku 5.5 3922b330d5 feat(abut): 교대 복합명(A1-완도 등)과 가져오기 문제 목록
교대 이름을 전체 명칭으로 해석하고, 엑셀 가져오기 문제를 목록으로 모은다.
교각 이름과의 중복을 저장 파일 검사에서도 막는다(checkStoredAbutments).
검사 스크립트 verify-abut-names · verify-abut-workflow 포함.

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-11-06 13:14:53 +09:00
ypshimandClaude Haiku 5.5 0c23173a1a chore(git): 백업 폴더 backup/ 추적 제외
백업 사본은 변경 전 상태 보관용이며 저장소 이력에 넣지 않는다.

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-11-06 13:12:32 +09:00
ypshimandClaude Haiku 5.5 91c4a4ad63 feat(abut-rebar): ③ 교대 받침블럭 3D 철근 (S1 · S2)
- 받침블럭 배근 경로를 교대 3D 철근에 추가 (선택 · 색 기준 공통), 방위각 회전 반영
- 콘크리트 안 검사에 받침블럭 포함 (다리 묻힘 포함 모두 안)
- 테스트: 블럭 5개 × 8본씩 3조각, 콘크리트 안

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-11-05 09:40:30 +09:00
ypshimandClaude Haiku 5.5 bc91a8166f feat(abut-rebar): ③ 교대 받침블럭 카드
- 보기 탭 평면 · 단면, 입력 두 칸 (교대축 = S2, 교대축 직각 = S1, 사용자 결정 2026-11-05), 피복 상면 · 측면, 첫 철근 여유, 다리 길이
- 자동 값: 블럭 콘크리트 높이 (블럭 − 패드 50), 묻힘 S2 · S1, 적용 블럭 수. 실간격 묶음 수정 S1 · S2 행
- 경고 RB-AK01 (S1 다리 ↔ W2 층, Q-S4 경고만) · 받침블럭 member 목록 활성화

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-11-05 09:39:53 +09:00
ypshimandClaude Haiku 5.5 a68e4a0366 feat(abut-rebar): 교대 받침블럭 배근 삽도 (평면 · 단면)
- 평면: 블럭 1200 × 1200, S2 실선 (위) · S1 파선 (아래), 번호 풍선
- 단면: 교좌면 기준, 몰탈패드 50, S1 다리 · S2 본, 묻힘 치수 문자열
- 결과에 교좌면 높이(seatZ) 추가 — 단면 축을 교좌면 기준으로 맞춤
- verify/dump: 받침블럭 삽도 덤프

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-11-05 09:38:29 +09:00
ypshimandClaude Haiku 5.5 5856cfb423 feat(abut): 받침블럭 배근 입력 저장
- AbutRecord.rebar.block (바꾼 값만), applyBlockRebar, 프로젝트 파일 검증

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-11-05 09:37:12 +09:00
ypshimandClaude Haiku 5.5 a2d0d6057f feat(abut-rebar): 교대 받침블럭 배근 규칙 S1 · S2 (AD6)
- 받침마다 1개 블럭 (1200 × 1200, 높이 125 − 패드 50 = 콘크리트 75). 본수 · 다리 · 여유는 교각 규칙(barsAcross · barLength) 재사용, 깊이만 교대 H13으로 계산
- 묻힘 S2 675 · S1 688, 길이 2500, 블럭당 8본 (40 · 40). 블럭 방위각 회전
- 간섭 (Q-S4, 결정 2026-11-05): S1 다리가 W2 층을 지나면 RB-AK01 경고만
- 테스트: 교대 기획서 §10 값, 간섭 경고, 간격 수정, 회전

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-11-05 09:36:53 +09:00
ypshimandClaude Haiku 5.5 b75f3d4855 feat(abut-rebar): ③ 교대 접속슬래브 3D 철근 (R1–R8)
- 접속슬래브 배근 경로를 교대 3D 철근에 추가 (선택 · 색 기준 공통)
- 콘크리트 안 검사에 접속슬래브 포함: 모든 교대 철근 (P10 다웰 포함) 밖으로 나간 점 없음

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-11-05 09:27:52 +09:00
ypshimandClaude Haiku 5.5 a282618d90 feat(abut-rebar): ③ 교대 접속슬래브 카드
- 보기 탭 평면 · 단면, 입력: 기본간격 교대축 · 교대축 직각, 피복 "80 / 100", 첫 철근 최소 여유, R8 배수 (편집, 사용자 결정 2026-11-05)
- 자동 값: 상면 2s · 하면 s · R8 k·s, 하면 격자, 모따기 판정 · 짧아진 철근 수, R7 l_d. 실간격 묶음 수정은 하면 행 R3 · R4 (상면은 2배)
- 노출형 안내 (매립형 규칙 적용), 공통 카드의 두 숫자 입력이 "/"도 구분자로 받음

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-11-05 09:27:51 +09:00
ypshimandClaude Haiku 5.5 e9d79957c8 feat(abut-rebar): 교대 접속슬래브 배근 삽도 (평면 · 단면)
- 평면: 왼쪽 절반 하면 (R3 · R4 · R8) · 오른쪽 절반 상면 (R1 · R2), 모따기 모서리 R7, 교대축 · 길이 배치 치수
- 단면 (길이 방향, 폭 가운데): 브라켓 위 평탄 + 경사, 층 순서, R8은 R1 · R3 사이
- verify/dump: 접속슬래브 삽도 덤프

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-11-05 09:26:13 +09:00
ypshimandClaude Haiku 5.5 9ae6cf0945 feat(abut): 접속슬래브 배근 입력 저장
- AbutRecord.rebar.slab (바꾼 값만), applySlabRebar, 프로젝트 파일 검증

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-11-05 09:23:56 +09:00
ypshimandClaude Haiku 5.5 fa7d0032c3 feat(abut-rebar): 교대 접속슬래브 배근 규칙 R1–R8 (AD8)
- R1 · R3 (교대축 배치, 하면은 상면 구간 절반, 결정 A), R2 · R4 (길이 배치, 측면 피복 c / sin), 모따기 자름 (결정 E), R7 모서리 상 · 하, R8 엇갈림 (모따기 안 제외)
- ② 슬래브 평면 측면선 · 단면을 그대로 따라감 (상면 철근은 상면, 하면 철근은 하면). 노출형도 같은 규칙 (사용자 결정 2026-11-05)
- P10 다웰과의 간섭은 보지 않음 (사용자 결정 2026-11-05). k_8 편집 가능
- 테스트: 교대접속슬래브철근.md §11 (fck 27), 삽도 폭 11670 재현, 날개벽 형식별 R7 · 폭, 기본간격 연동, 노출형

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-11-05 09:23:42 +09:00
ypshimandClaude Haiku 5.5 bb2af4bdf5 fix(abut-rebar): 날개벽 WR10 = 교대 끝면 쪽 ㄱ자 (교대 3,000 · 날개벽 1,000)
- 사용자 도면(2026-11-05): 꺾이는 점 = 날개벽 바깥면(교대 끝면) − 피복 123 선 × 흉벽 배면 + 123 선. 교대 쪽 다리 3,000 (s 방향, 교대 안쪽),
  날개벽 쪽 다리 1,000 (바깥면 − 123, 경사 하단에서는 짧아짐). 단 @s_v · 단 수 그대로 (A1 38)
- 이전 안쪽 모서리 L자 (대각 300 + 양끝 l_d) 폐기
- 테스트: 꺾이는 점 · 다리 길이 · 방향 · 단 수, 콘크리트 안 검사 통과

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-11-05 09:15:46 +09:00
ypshimandClaude Haiku 5.5 7b9de649e5 fix(abut-rebar): 날개벽 WR7-1 · 7-2 · 7-3 평면 형상 (긴 대각 + 양끝 갈고리 394)
- 사용자 도면(2026-11-05): 헌치 빗변과 나란한 대각이 안쪽 모서리를 c 떨어져 지나, 교대 쪽은 전면 − c (WR7-1 · 7-2 흉벽 전면,
  WR7-3 벽체 전면), 날개벽 쪽은 날개벽 바깥면 − c에서 끝남. 양끝 평면 90° 갈고리 394 (교대 안쪽 / 날개벽 끝 쪽)
- WR7-3의 긴 대각이 더 낮은 이웃 계단의 교좌면 위로 나오는 단은 WR7-2로 (교좌면 계단을 날개벽 형상에 전달)
- 테스트: 갈고리 394 · 끝점 · 대각 길이, WR7-1 · 7-2 끝, 우측 재분류, 콘크리트 안 검사 통과

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-11-05 09:01:33 +09:00
ypshimandClaude Haiku 5.5 89ebf852a9 fix(abut-rebar): 날개벽 WR9 = 저판 상면 헌치 보강 꺾임철근
- 사용자 도면(2026-11-04, 연직헌치 보강철근): 날개벽 방향 벽체 배면 ~ 뒷굽 끝 @2·s_h. 날개벽 수직 단면에서 배면 − c 연직 다리
  (벽체 W9 · W10 정착장, 기본 330) → 헌치 빗변과 나란한 대각 (c 안쪽) → 저판 하면망 높이 → 날개벽 반대쪽 90° 갈고리
- 갈고리 끝이 저판 배면 + c 안에 드는 범위까지만 (skew로 뒤쪽으로 밀림): A1 우 4개 · 좌 3개
- 이전 WR9 (연직 헌치 대각 위 연직) 삭제. 카드 자동 값 · 철근표 문구 반영

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-11-04 18:28:36 +09:00
ypshimandClaude Haiku 5.5 81fe1f1549 fix(abut-rebar): 날개벽 철근이 콘크리트 밖으로 나가는 곳 수정
- WR12 (전면 등단면) 아래 갈고리: 전면이 저판 끝면과 같은 면이라 바깥으로 꺾으면 저판 밖 → 안쪽으로
- WR7-1 · 7-2 · 7-3 · WR10 날개벽 다리: 뒷굽 끝 너머 경사 하단이 올라오는 곳에서 c 안쪽까지만 (최대 l_d)
- WR20: 갈고리까지 날개벽 안인 단만
- 테스트: 교대 A1 모든 3D 철근을 ~100 mm 간격으로 찍어 콘크리트 안인지 확인 (P10은 슬래브 쪽 절반만 밖)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-11-04 18:12:10 +09:00
ypshimandClaude Haiku 5.5 e3c9ad0953 feat(abut-rebar): ③ 교대 날개벽 3D 철근 (WR1–WR20, 좌 · 우)
- 날개벽 배근 경로(교대 s · t · z)를 교대 3D 철근에 추가. 선택 · 색 기준 공통
- 테스트: 철근표 = 3D 개수 (양측), 벽체 전면 뒤 · 저판 하면망 위

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-11-04 18:00:42 +09:00
ypshimandClaude Haiku 5.5 bac087ce63 feat(abut-rebar): ③ 교대 날개벽 카드
- 측 선택 (좌 · 우, ② 날개벽 카드와 같은 측), 보기 탭 세 개 (전면도 · 배면도 · 헌치 · 모서리 평면)
- 입력 (좌우 공통): 기본간격 수평 · 연직, 피복, 첫 철근 최소 여유, δ, WR20 배수. WR9 개수는 없앰 (자동)
- 자동 값: 형식, 배면 s · 전면 2s, 연직 기준선, 수평 단, WR9 개수, l_d. 실간격 묶음 수정 WR1 (연직) · WR3 (수평)
- 공통 카드 함수: 위쪽 컨트롤 줄 · 두 숫자 입력 선택 사항

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-11-04 18:00:41 +09:00
ypshimandClaude Haiku 5.5 d15b237100 feat(abut-rebar): 교대 날개벽 배근 삽도 (전면도 · 배면도 · 헌치 · 모서리 평면)
- 전면도 · 배면도: 날개벽 방향 거리 × 높이, 교대 단면 · 저판 · 뒷굽 끝, 연직 배치 치수, 단 수
- 헌치 · 모서리 평면: WR7-1 · 7-2 · 7-3 · WR10 띠별 한 단, WR9 점
- verify/dump: 날개벽 삽도 덤프

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-11-04 18:00:41 +09:00
ypshimandClaude Haiku 5.5 e6869d17be feat(abut): 날개벽 배근 입력 저장 (좌우 공통)
- AbutRecord.rebar.wing (바꾼 값만), applyWingRebar, 프로젝트 파일 검증

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-11-04 17:57:15 +09:00
ypshimandClaude Haiku 5.5 06220fa312 feat(abut-rebar): 교대 날개벽 배근 규칙 WR1–WR20 (AD7)
- 연직 WR1 · 2 · 11 · 12 (기준선 뒷굽 끝 − c, 등단면 올림 δ, 변화단면 끝 여유), 수평 WR3–5 · 13–15 (띠 3개, 맨 위 2단 경사와 나란히 Q-WR1, 교대 쪽 갈고리)
- WR6 경사 하단, WR7-1 · 7-2 · 7-3 헌치 꺾임, WR9 헌치 대각 @2·s_h 자동 개수 (Q-WR3), WR10 모서리 꺾임, WR20 간격재. 간섭은 보지 않음 (Q-WR2 · Q-WR4)
- 단축형: WR1 · WR11 · WR6 없음. 좌우는 ② 형상 그대로 (skew로 배면 길이 ± 두께 · cot φ)
- 테스트: 교대날개벽철근.md §11 (fck 27, 벽체 높이 2500), 삽도 = 우측 날개벽 치수 재현

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-11-04 17:56:48 +09:00
ypshimandClaude Haiku 5.5 3800a75b78 fix(abut-rebar): 벽체 W9 ㄷ형 · W10 ㄴ형, 정착장 입력 (기본 330)
- 사용자 도면(2026-11-04): 끝마다 이웃 높낮이로 정함 — 높은 이웃 쪽은 경계 너머 정착장만큼 직선 겹침, 낮은 이웃 · 끝면 쪽은 경계 − c에서 연직 갈고리 정착장
- 번호는 모양으로: 양끝 갈고리 W9 (ㄷ), 한쪽 갈고리 W10 (ㄴ). A1: W9 6 (IP1 끝 구간), W10 24
- 벽체 카드에 "W9 · W10 정착장 (mm)" 입력 (기본 330, fck 연동 없음), 철근표 · 삽도 · 안내 문구 반영

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-11-04 17:28:22 +09:00
ypshimandClaude Haiku 5.5 5e64a4f2ed feat(abut): 벽체 높이 H_w = 받침 ① 교좌면 ~ 흉벽 상단 연직 (기본 3326)
- 사용자 도면(2026-11-04): 받침 ① 위치에서 받침 ① 교좌면부터 흉벽 상단까지 연직 거리. 모든 교대 기본값 3326 (QA-4 가정 2500 대체)
- 입력 이름 "벽체 높이 · 받침 ① 기준 연직", ② 정면도에 받침 ① 기준선과 H_w 치수
- ③ 흉벽 철근표 대표 단면 = 받침 ① 위치 (P1–P3는 그 단면에서 정확히 계산)
- 테스트: 흉벽 상단 IP1 48.006 / IP3 47.661 등 + 0.826 반영, 교대흉벽철근.md §11 재현은 2500을 넣어 유지, 3326 기본 확인 (P1 3945)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-11-04 17:20:05 +09:00
ypshimandClaude Haiku 5.5 51a36669a8 feat(abut-rebar): ③ 교대 흉벽 · 브라켓 3D 철근 (P1–P11)
- 흉벽 배근 경로를 교대 3D 철근에 추가 (저판 · 벽체와 같은 장면, 선택 · 색 기준 공통)
- 테스트: 철근표 = 3D 개수, 모든 흉벽 철근이 브라켓 배면 ~ 흉벽 전면, 흉벽 상단 아래 (P10은 슬래브로 300)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-11-04 17:06:28 +09:00
ypshimandClaude Haiku 5.5 9f152fef90 feat(abut-rebar): ③ 교대 흉벽 · 브라켓 카드
- 보기 탭 세 개(단면 · 교대축 단면 · P10 다웰 배치), 입력: 기본간격 2종 · 피복 · P6 · P7 배수(한 칸) · P10 간격 · 길이
- 자동 값: 상단 · 하단 경계, 연직 격자 오프셋, P6 · P7 간격, l_d, P10 개수 · 옮긴 개수. 실간격 묶음 수정 P1–P3 / P4 · P5 · P8 · P9
- 대표 단면 안내 (흉벽 상단선 경사로 흉벽 높이가 위치마다 다름)
- 벽체 · 흉벽 카드 공통 함수 abutRebarCard로 정리 (벽체 카드 동작 그대로)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-11-04 17:06:27 +09:00
ypshimandClaude Haiku 5.5 914e3dbda8 feat(abut-rebar): 교대 흉벽 · 브라켓 배근 삽도 (단면 · 교대축 단면 · P10 다웰 평면)
- 대표 단면(흉벽 높이 = 입력값): P1–P3 · P6 · P7 · P10 선, P4 · P5 점, 두께 · 단 수 치수
- 교대축 단면: 상단 경사와 나란한 P4, P1, 끝면 P11 · P8 · P9
- P10 평면: 슬래브 측면선, 다웰 n개, P3 다리 위치와 비켜 옮긴 다웰
- verify/dump: 흉벽 삽도 덤프

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-11-04 17:03:52 +09:00
ypshimandClaude Haiku 5.5 ba71911edc feat(abut): 흉벽 배근 입력 저장
- AbutRecord.rebar.parapet (바꾼 값만), applyParapetRebar, 프로젝트 파일 검증

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-11-04 17:02:04 +09:00
ypshimandClaude Haiku 5.5 25a4a84138 feat(abut-rebar): 교대 흉벽 · 브라켓 배근 규칙 P1–P11 (AD5)
- P1 · P2 · P3 (벽체 연직 격자 −17.5 / −1.6 / −35), P4 · P5 (상단 경사와 나란함, 계단에서 끊음, P5 브라켓 상면 위 · 아래), P6 · P7 (한 칸 배수, 반 칸 엇갈림, 1/sin θ)
- P8 · P9 끝면 ㄷ형, P11 끝면 연직, P10 다웰 (슬래브 측면선 가운데 맞춤, P3 수평 다리 비켜감 Q-P5 → RB-AP06, 모따기 RB-AP05)
- 대표 단면 = 흉벽 높이가 입력값(2500)인 곳 (흉벽 상단선이 받침 ① · 마지막 받침으로 정해져 IP3 끝은 2468)
- D25 규격 추가. 테스트: 교대흉벽철근.md §11 (fck 27)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-11-04 17:01:32 +09:00
ypshimandClaude Haiku 5.5 74de932eb9 fix(abut-rebar): 벽체 W9 · W10 구간별 ⊓형 (양끝 다리 l_d)
- 사용자 도면(2026-11-04): 구간마다 독립된 ⊓, 양끝 계단 경계 − c (끝면 쪽은 W11 안쪽)에서 아래로 다리 l_d 735
- 낮은 이웃 구간으로의 직선 연장과 RB-AB06 피복 경고 삭제
- 계단 단면 삽도 · 안내 문구 반영

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-11-04 16:45:20 +09:00
ypshimandClaude Haiku 5.5 34959f5b4e fix(abut-rebar): 벽체 W7 skew 길이 · W11 ㄷ형 · RB-AB06 피복 경고
- W7 길이에 1/sin θ 적용 (2594 → 2603)
- W11 위 갈고리 추가, 아래 · 위 모두 교대축 안쪽 ㄷ형 (3433/3717 → 3679/3963), 계단 단면 삽도에 반영
- W9 · W10 연장부 피복 < c이면 RB-AB06 경고만 (A1 44.9)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-11-04 16:40:23 +09:00
ypshimandClaude Haiku 5.5 a4c7bcabcf feat(abut-rebar): ③ 교대 벽체 3D 철근 (W1–W11)
- 벽체 배근 경로를 저판 철근과 같은 교대 3D 철근에 추가 (buildAbutRebar3D). 선택 · 선택만 보기 · 색 기준 공통
- 테스트: 철근표 = 3D 개수, 모든 벽체 철근이 벽체 면 · 끝면 안, 저판 하면 철근망 위

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-11-04 16:31:59 +09:00
ypshimandClaude Haiku 5.5 a17c9ebbb4 feat(abut-rebar): ③ 교대 벽체 카드
- 보기 탭(교대축 직각 단면 / 계단 단면) · 삽도 · 구간 메모 · 범례
- 입력: 기본간격 3종, 피복, 첫 철근 최소 여유, W7 배수(편집). 자동 값: 연직 격자, W7 간격, skew 고정, l_d (fck 교대 벽체 등)
- 철근표: 선택 · 구분 · 번호 · 규격 · 간격 · 실간격(묶음 수정 W3 / W5 / W9) · 형상 · 길이 · 근거(규칙/가정) · 개수 · 위치
- 부재별 경고 수 · 알림(RB-AB), 행 선택 함수를 저판 카드와 공용
- scripts/verify-abut-wall.cjs: 헤드리스 Chrome 화면 확인 스크립트

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-11-04 16:31:03 +09:00
ypshimandClaude Haiku 5.5 079748e5e7 feat(abut-rebar): 교대 벽체 배근 삽도 (교대축 직각 단면 · 계단 단면)
- 배근 규칙 경로로 그림: 구간 1 단면 (W1–W7 · W9 · W10, 단 치수), 계단 단면 (W1 · W5 · W9 · W10 · W11 · W8, 구간 높이)
- 번호 풍선 · 선택 강조 · 선택만 보기는 저판 삽도와 같은 함수 (공용으로 export)
- verify/dump: 삽도 SVG 덤프 전용 설정 (기본 테스트에 포함 안 됨)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-11-04 16:27:57 +09:00
ypshimandClaude Haiku 5.5 66e7d4d90d feat(abut): 벽체 배근 입력 저장 · 교대 벽체 등 fck(35)
- AbutRecord.rebar.wall (바꾼 값만), applyWallRebar, 프로젝트 파일 검증
- 교대 벽체 · 흉벽 · 날개벽 · 접속슬래브 fck(abutBody, 기본 35)를 ② 벽체·흉벽 카드에서 입력. 저판 fck 행과 같은 함수

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-11-04 16:22:15 +09:00
ypshimandClaude Haiku 5.5 1da8dd74e2 feat(abut-rebar): 교대 벽체 배근 규칙 W1–W11 (AD4)
- 연직 W1 · W3 · W4 (저판 격자 − 17.5, B6·B8 위 갈고리, W3 교좌면 + l_d / 브라켓 상면 − c 제한)
- W2 교좌면 상면, W9 · W10 계단 상면 (낮은 이웃 구간으로 l_d), W5 · W6 계단에서 끊음, W7 간격재, W8 끝면 ㄷ형, W11 끝면 연직
- 묶음별 간격 수정(연직 · 수평 · 계단 상면), W7 배수, RB-AB01–05
- 철근표 행 + 철근별 경로 (u, t, z) — 삽도 · 3D 공용
- H13 규격 추가
- 테스트: 교대벽체철근.md §11 기대값 전체 (fck 27), l_d · 기본간격 연동

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-11-04 16:20:34 +09:00
ypshimandClaude Haiku 5.5 8361487b6b feat(abut-rebar): ③ 교대 저판 3D 철근 (B1–B10)
- 배근 결과(abutFootingRebar)로 저판 철근을 교대 좌표에 그림: B1 ⊓ · B2 ⊔ · B3 · B4, B5 ⊓ · B6 ⊔ · B7 · B8, B9, B10-1 · B10-2, 옵션 ② B1-S · B2-S
- 교대 선택 시 ③ 철근(rebar3d)으로 사용: 철근표 · 삽도 선택, 선택만 보기, 색 기준, 3D 클릭 선택 공통
- 교각 rebar3d의 강조 · 색 · 범례를 rebarControls로 공용화
- 테스트: 철근표 개수 = 3D 개수, 모든 철근이 저판 안 (skew · 교대축 직각)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-11-04 16:01:56 +09:00
ypshimandClaude Opus 5.5 d7714f7ef2 feat(abut-rebar): ③ 교대 부재 목록 단순화 · 철근표 선택 / 전체 선택 / 선택만 보기
- 부재 목록: 교각처럼 이름 + 경고 수 배지만. 배근 타입 · 번호 · 다음 단계는 툴팁과 카드 설명 줄로
- 철근표: 선택 칸, 행 클릭 단독 선택, 전체 선택/해제, 선택만 보기 (교각과 같은 rebarSelection · rebarIsolate)
- 삽도: 여러 철근 강조, 선택만 보기면 나머지 숨김. B10 풍선은 B10-1 · B10-2를 함께 선택

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 15:56:57 +09:00
ypshimandClaude Opus 5.5 f4311d7f95 fix(view): ③ 철근배근도 선택한 구조물만 3D에 표시
- 교각을 선택하면 교대를 숨김(②와 같은 규칙). 교대 선택 시 교각 숨김은 기존대로
- ① ↔ ③ 전환 때도 장면을 다시 만들어 표시 규칙이 바로 반영되게 함

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 15:48:09 +09:00
ypshimandClaude Opus 5.5 603066f370 feat(abut): ③ 교대 반투명 보기 · 교대 저판 전용 fck
- ③ 철근배근에서 교대 부재·지반도 교각처럼 반투명(0.5, 깊이 쓰기 끔). setRebarViewOn으로 공통화, 끄면 원래 불투명도 복원(지반 0.35 유지)
- 교대 저판 fck(abutFooting, 기본 27)를 교각 기초 fck와 분리. ② 확대기초·말뚝 카드에 입력, ③ 저판 배근·이음 길이에 반영
- 테스트 713개 통과

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 14:28:47 +09:00
ypshimandClaude Haiku 5.5 b06bf368b6 feat(abut-rebar): ③ 교대 화면 틀과 확대기초 배근 카드
- ③에서 교대를 고르면 부재 · 교대 목록(받침블럭 → 벽체 → 흉벽·브라켓 → 확대기초 → 날개벽 → 접속슬래브)과 카드를 보여 줌.
  확대기초만 규칙이 있고 나머지는 '다음 단계'. 선택한 교대만 3D에 그림
- 확대기초 카드: 보기 탭 3개(교대축 직각 단면 · 교대축 단면 · 상면 평면)와 번호 풍선 강조, 범례,
  기본간격 · 배근 방향 ① / ② · 피복 · 여유 · B9 배수 입력, 자동 값(cycle 간격 · B10 위 여유 · l_s · 옆붙임),
  철근표(묶음별 실간격 수정 · 수정됨 · 되돌리기, 비고 펼치기), 근거 줄, RB-AF 알림
- 입력은 AbutRecord.rebar.footing에 바꾼 값만 저장 · 프로젝트 파일 검증. fck는 공통 설정의 기초부 값

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-11-04 11:12:43 +09:00
ypshimandClaude Haiku 5.5 e00e43ca61 feat(abut-rebar): 교대 저판 배근 규칙 B1–B10 (AD3) · skew 배근 · 겹침이음 공통 (AD1 · AD2)
- skew-layout: 교대축을 따라 잰 격자, 끝면 피복 환산, 치수 문자열
- splice: l_s = 1.3 l_d (올림 1 mm), 최소 조각 수, ± l_s 엇갈림
- footing: 층 높이, B1–B4 옵션 ① skew / ② 교대축 직각 (가운데 격자 + 끝 skew 철근 B1-S · B2-S),
  B5–B8 (상면 벽체 밑 제외), B9 교차 배치, B10-1 · B10-2, 철근별 간격 수정, 검사 RB-AF01–10
- 교대확대기초철근.md §13 기대값 테스트 (뒷굽 짧음 합성은 §5.2 규칙에 맞춰 뒷굽 150으로 확인)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-11-04 11:06:14 +09:00
ypshimandClaude Haiku 5.5 59c2607418 feat(abut): 교대 지반·지층 카드 — 교각과 같은 위치 · 수위 · 지층 행 입력, 교대 단면 삽도, 검사
- 위치 (육상 · 하상 · 해상)와 위치별 수위 입력, 지층 행 표 (교각과 공용 strataRowsEditor), 지반 모델 지층 불러오기
- 검사: 교각과 같은 G01–G14, 말뚝 근입 G04, 지반고 < 저판 하면이면 정보 AI01
- 삽도: 지층 · 수위 띠를 교각과 공용으로 빼고, 교대축 직각 단면(저판 · 벽체 · 흉벽 · 말뚝 열)을 EL. 표기로 그림
- 자동 값: 지반고 (엑셀 입력), 연암층 상면 EL, 지층 수. 프로젝트 파일 저장 · 검증

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-11-04 10:36:23 +09:00
ypshimandClaude Haiku 5.5 9835fb42b3 feat(abut): 접속슬래브 경사는 형식이 정함, 단면 · 접합 상세를 파단선 단면 하나로 합침, 흉벽 높이 입력
- 경사 선택 삭제: 매립형 = 하향 4 %, 노출형 = 종단경사 따라 (노트 표기). 예전 파일의 slope 값은 읽고 무시
- 단면 + 접합 상세: 흉벽 쪽과 먼 끝만 그리고 중간 생략, 부속재 · 치수(7000 · 450 · 850 · 295 · 헌치) · EL을 한 그림에
- 흉벽 높이 (브라켓 상면 ~ 흉벽 상단) 입력 = 벽체·흉벽 카드 브라켓 높이와 같은 값
- 자동 값: 브라켓 얹힘 길이 = 브라켓 폭 − 흉벽과 간격 (295)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-11-04 10:27:20 +09:00
ypshimandClaude Haiku 5.5 4b8b80ae97 feat(abut): 접속슬래브 카드 — 매립형 / 노출형 형식, 경사, 치수 입력, 삽도 3개, 모따기 3D 반영
- 노출형 (삽도 기준, QA-8 가정): 상면 = 흉벽 상단, 끝은 브라켓 위(끝 두께 = 브라켓 높이), 헌치 길이 기본 2000
- 형식을 고르면 경사가 기본값으로 바뀜 (매립형 하향 4 %, 노출형 종단경사), 경사 타일로 변경 가능
- 길이 · 두께 · 흉벽과 간격 · 날개벽과 간격 입력이 3D, 폭, 날개벽 평면에 반영
- 슬래브 모따기 = 날개벽 헌치 (3D와 평면), 날개벽 없는 측은 skew 교좌면 끝선
- 3D 슬래브를 단면 기준 입체로 다시 만듦, 프로젝트 파일 저장 · 검증

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-11-04 10:11:39 +09:00
ypshimandClaude Haiku 5.5 fc0370883f feat(abut): 날개벽 측면 삽도에 길이 · 끝단 높이 · 뒷굽 끝 치수선 추가
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-11-04 09:49:30 +09:00
ypshimandClaude Haiku 5.5 41b3165543 feat(abut): 날개벽 측면 삽도에 흉벽 · 브라켓 단차 · 저판을 함께 그리고 교대 삽도 EL 표기를 EL.xx.xxx로 통일
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-11-04 09:45:53 +09:00
ypshimandClaude Opus 5.5 886347471e feat(plan): 날개벽 형식을 좌 · 우 1행으로 요약하고 "반대쪽에도 적용" 추가
- 선택 줄을 "날개벽 형식  좌 [일반형 · 단축형 · 없음] │ 우 [일반형 · 단축형 · 없음]" 한 줄로 합침 (작은 그림 타일). "좌우" · "편집할 측" 줄과 ① 쪽 / ⑤ 쪽 표시는 없앰 (위치는 모델창에서 확인)
- 타일이나 "좌" / "우" 글자를 누르면 그 측이 편집 대상이 되고, 편집 중인 묶음은 진한 테두리로 표시. 아래 치수 입력과 측면 삽도가 그 측을 따름
- "좌우 같게" 삭제: 형식과 치수는 측별로만 저장. 예전 파일의 wingsLinked 값은 읽기만 하고 쓰지 않음
- 반대쪽에도 적용 (copyWingDims): 두께 · 끝 높이 · 헌치를 한 번 복사하고, 길이는 두 측 형식이 같을 때만 복사. 반대쪽이 없음이면 비활성
- 테스트 갱신. 전체 670건 통과, 타입 검사 · 빌드 통과. 브라우저 시각 확인은 미실시

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 09:36:53 +09:00
ypshimandClaude Opus 5.5 4ef3650454 feat(plan): 날개벽 카드 타일에 현재 형식을 반영한 그림 추가
- 좌우: 평면 그림. 같게 = 좌측 형식의 날개벽 두 개와 "=", 따로 = 현재 좌우 형식 (같으면 긴 것 · 짧은 것으로 예시)
- 편집할 측: 현재 좌우 형식대로 두 날개벽을 그리고, 고른 측을 굵게, 다른 측은 흐린 점선. 글자는 "좌 · ① 쪽"만 남김
- 형식: 측면 그림. 일반형 = 길고 아랫면이 비스듬한 사다리꼴, 단축형 = 짧은 직사각형, 없음 = 벽체 선과 점선 윤곽
- 그림은 48 × 24, 선 색 = 글자 색 (선택 파랑 · 비활성 회색). 테스트 669건 통과, 빌드 통과. 브라우저 시각 확인은 미실시

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 09:25:00 +09:00
ypshimandClaude Opus 5.5 80e5302192 feat(abut): ② 날개벽 카드를 시안대로 작성 (좌우 같게 · 측별 치수 · 삽도 3개)
- 선택 줄 (타일): 좌우 같게(기본) / 따로 정하기, 편집할 측 (좌 · ① 쪽 / 우 · ⑤ 쪽), 형식 일반형(기본) / 단축형 / 없음
- 측별 치수 입력 (WingDims, 교대 기록 wingDims): 길이 6500, 두께 450, 끝 높이 1500(일반형), 헌치 300. 단축형 길이 기본 = 뒷굽 끝(1.807 m). 좌우 같게이면 양쪽에 함께 적용, 켤 때 좌측 값을 우측에 복사
- 연동: 두께 → 날개벽 3D · 접속슬래브 폭(geometry slabWidthMm) · 슬래브 평면, 헌치 → 연직 헌치 · 저판 상면 헌치 (0이면 없음)
- 자동 값: 경사 (A1 = −i), 등단면 · 변화단면 경계(뒷굽 끝), 끝 EL 상단 / 하단 (가정), 단축형 하단 = 확대기초 상면 (뒷굽 밖은 AI02 안내)
- 삽도 3개 (src/view/abut-wing-svg.ts): 평면(좌우 날개벽 · 접속슬래브, abutPlanOutline 공유), 측면(수평 단 · 뒷굽 끝), 날개벽–흉벽 모서리 모식도(헌치 · 브라켓 · 슬래브 모따기 · 간격)
- "생성되는 날개벽" 줄과 시안 메모. 프로젝트 파일 검사에 wingDims · wingsLinked 추가
- 테스트 7건 추가 · 갱신. 전체 669건 통과, 타입 검사 · 빌드 통과. 브라우저 시각 확인은 미실시

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 09:20:29 +09:00
ypshimandClaude Opus 5.5 a025a61209 feat(plan): 교대 카드 선택 줄을 시안 타일로 바꿈 (아이콘, 기본 배지)
- 공통 타일 helper(optionTiles): 아이콘 타일(.opt)과 글자 타일(.optt), 선택은 파란 테두리, 기본값은 "기본" 배지, 고를 수 없는 것은 점선과 사유 툴팁, 아래 안내문
- 벽체 형식: 시안 아이콘 4개(기본형 · 브라켓형 · 오목형 · 반일체식), 기본형만 선택 (QA-8)
- 같은 타일 적용: 블럭 배치(분리 · 일체 아이콘), 상세 보기(받침 ①–⑤), 확대기초 평면(skew · 직사각), 말뚝(원기둥 · 강관 · PHC)
- 사용하지 않게 된 버튼 줄 스타일 삭제. 테스트 662건 통과, 빌드 통과. 브라우저 시각 확인은 미실시

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 08:59:26 +09:00
ypshimandClaude Opus 5.5 4eb92d381d fix(abut): 말뚝 머리를 저판 안으로 100 mm 넣어 바닥면 줄무늬(z-fighting) 제거
- 말뚝 윗면이 저판 바닥·버림 윗면과 같은 높이에 있어, 아래에서 보면 원 안에 줄무늬가 생겼음
- 그리는 말뚝 머리를 저판 안으로 0.1 m 넣어 윗면이 저판 속에 숨도록 함. 선단 EL은 그대로
- 테스트 1건 추가. 전체 662건 통과, 타입 검사 · 빌드 통과

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 08:53:46 +09:00
ypshimandClaude Opus 5.5 372d759eb7 feat(abut): ② 확대기초 · 말뚝 카드를 시안대로 작성 (평면 삽도, 버림, 열별 본수)
- 선택: 평면 skew 평행사변형(기본) / 직사각(비활성, 다음 단계), 말뚝 원기둥 D600 / 강관 · PHC(비활성, 규칙 미정)
- 평면 삽도 (src/view/abut-footing-svg.ts): 저판, 벽체 윤곽, 교대축, 말뚝 3열(skew만큼 밀림), 열 위치 · 본수, 폭 치수
- 입력: 폭 4600, 두께 1100, 앞굽 1200 (뒷굽 자동 1000), 버림 여유 · 두께 100 / 100, 말뚝 지름 600 (QA-5), 연단 650, 본수 규칙 간격 2000
- 열 위치 "650 · 2000 · 1300 · 650" 입력 (전면 연단 자동, 합 = 폭 검사), 열별 말뚝 본수 표 − / + (0 = 본수 규칙)
- 자동 값: 뒷굽, 길이(교좌면 길이), 말뚝 길이(①에서 수정), 선단 EL, 본수 규칙 문구. 시안 메모
- 3D: 교대 버림을 저판 아래에 그림 (부재 표시 "버림" 토글 적용), 말뚝 지름 입력 반영, 말뚝 열을 skew만큼 s 방향으로 밀어 배치
- 예전 저장 파일의 앞굽 끝 · 뒷굽 끝은 폭 · 앞굽으로 바꿔 읽음
- 테스트 추가 · 갱신. 전체 661건 통과, 타입 검사 · 빌드 통과. 브라우저 시각 확인은 미실시

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 08:46:58 +09:00
ypshimandClaude Opus 5.5 55b679932e feat(abut): ② 벽체·흉벽 카드를 시안대로 작성 (삽도 2개, 브라켓 폭 입력, 확대기초·말뚝 분리)
- 삽도 2개 (src/view/abut-wall-svg.ts): 교대축 직각 단면(① 구간, "300 + 500 + 1600 = 2400", 흉벽 상단·브라켓 상면·교좌면·저판 EL)과 정면·배면도(교좌면 계단, 흉벽 상단 경사, 받침, 수축줄눈). 생성본 값, 가로·세로 같은 축척
- 벽체 두께는 자동 (= 브라켓 폭 + 흉벽 두께 + 교좌면 폭). 입력은 브라켓 폭(기본 300). 예전 저장 파일의 벽체 두께는 브라켓 폭으로 바꿔 읽음 (normalizeShape)
- 인접 무시 기준(기본 5 mm) 입력: 이웃 받침의 교좌면 EL 차이가 이 값보다 작으면 한 구간으로 묶음 (A1 단차 71 mm, 변화 없음)
- 카드 구성: 벽체 형식(기본형만, QA-8) → 삽도 → 입력 격자(시안 순서, QA-4 표시) → 자동 값(벽체 두께, 흉벽 상단 IP3/IP1, 교대축) → 구간 표 → 확인 필요 → 시안 메모
- 저판·말뚝 입력 5개를 새 "확대기초 · 말뚝" 카드로 분리. 부재 목록 6행(시안 순서), 변경 표시도 카드별로 나눔
- 테스트 갱신·추가. 전체 655건 통과, 타입 검사·빌드 통과. 브라우저 시각 확인은 미실시

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 18:51:03 +09:00
ypshimandClaude Opus 5.5 796a267205 refactor(abut): 교량받침 카드에서 받침 종류 · 용량 입력 삭제
- 받침 종류 · 용량 입력칸과 받침 규칙의 kind 값을 제거
- 예전 프로젝트 파일에 저장된 kind 값은 읽을 때 무시 (불러오기 오류 없음)
- 테스트 갱신. 전체 651건 통과, 타입 검사·빌드 통과

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 18:40:16 +09:00
ypshimandClaude Opus 5.5 6b969f4a7c fix(abut): 교대 받침 공통 규칙을 교각 솔플레이트 규칙 형식으로 변경
- 이전 가정(받침 A×B 700×640, h3 181, T 30, 여유 100)을 없애고, 교각과 같은 솔플레이트 규칙(src/core/solplate-rule.ts)을 씀 (결정 2026-11-03: 교각 규칙을 기본값으로)
- 입력: 솔플레이트 교직 L 1000, 교축 W 1100, 종단 기울기 S −0.5 %. 받침높이 H는 교대 받침 시트 값(A1 275 mm)으로 고정
- 계산: T = ⌈22 + L·|S|/200⌉ = 25, h3 = H − T = 250, T1 27.5 / T2 22.5, E = 400. 교각 카드와 같은 안내 줄 표시
- 받침 종류 · 용량 입력은 유지. ELEVATION TABLE의 A·B 열 삭제, 삽도는 솔플레이트 L × W 기준
- 테스트 갱신. 전체 651건 통과, 타입 검사·빌드 통과. 브라우저 시각 확인은 미실시

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 18:37:17 +09:00
ypshimandClaude Opus 5.5 4b02dde153 feat(plan): ② 교대 카드를 3D 창 아래로 옮기고 교량받침 삽도 3개 추가
- 교대 카드를 왼쪽 목록 아래에서 가운데 3D 창 아래(교각 부재 카드 자리)로 옮김. 부재 · 교대 목록에서 고른 부재의 내용을 보여 주고, 머리줄에 부재 이름과 요약을 표시
- 왼쪽 편집 영역 정리: ②의 편집 버튼과 접히는 편집 영역을 없앰. 왼쪽에는 구조물 목록과 부재 · 교대 목록만 남김
- 교량받침 카드: 블럭 배치(분리 고정, 일체 비활성), 상세 보기(받침 ①–⑤), 삽도 3개(평면 · 단면 · 솔플레이트 상세, src/view/abut-bearing-svg.ts), ELEVATION TABLE, 솔플레이트 TABLE, 공통 · 규칙 입력, 검사 줄
- 삽도 테스트 3건 추가. 전체 650건 통과, 타입 검사·빌드 통과. 브라우저 시각 확인은 미실시

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 18:28:29 +09:00
ypshimandClaude Opus 5.5 4e3950eb67 fix(abut): 교대 받침 방위각을 읽어 받침블럭 회전에 반영
- 원인: 교대 파서가 받침 시트의 방위각 열(6번째)을 읽지 않아, 블럭이 항상 교대축에 맞춰 그려졌음
- 방위각을 교각과 같은 변환(swapXYAzimuth)으로 읽고, 교대축 직각 기준 블럭 회전각(rotDeg)을 계산해 블럭·몰탈패드를 블럭 중심에서 회전
- 운곡교 A1: 회전 약 4.42° (교각 규칙과 같은 값). 날개벽(skew) 선과의 차이는 0.63°로 시안 AW01 값과 같음
- ② 받침 ELEVATION TABLE에 방위각 CAD / 진북, 블럭 회전 열 추가. 읽을 수 없는 방위각은 AP08 경고 후 축 방향 유지
- 테스트 5건 추가, 전체 647건 통과, 타입 검사·빌드 통과. 브라우저 시각 확인은 미실시

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 18:20:05 +09:00
ypshimandClaude Opus 5.5 7e3ba306f3 feat(abut): ② 교량받침·받침블럭·솔플레이트 카드를 시안대로 구현
- 받침 ELEVATION TABLE (위치 s, 구간, 교좌면 EL, h1 75, h2 50, z = EL.X, h3, h4, E, 솔플 상면 EL, A, B, 블럭~계단 경계)과 솔플레이트 상세 TABLE (S, L, W, T, T1, T2, 검산)을 교대 모델에서 계산 (src/core/abut/bearing-table.ts)
- 공통 · 규칙 입력 4개: 받침 높이 h3 181, 솔플레이트 T 30, 솔플 여유 100, 받침 종류 탄성받침. 기본값은 교각 P1 가정 (QA-15). 교대 기록의 bearingRule에 저장, 프로젝트 파일 검사 포함
- 검사 줄: W02, W04, W03, I01
- ②에서는 받침별 z·교좌면 입력칸 대신 표를 보여 줌 (①은 그대로). 삽도는 넣지 않음
- 테스트 5건 추가, 전체 642건 통과, 타입 검사·빌드 통과. 브라우저 시각 확인은 미실시

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 18:14:29 +09:00
ypshimandClaude Haiku 5.5 5bdd360235 refactor(abut): 교대 카드의 상세 (계산값) 삭제
- ② 교대 카드에서 상세 (계산값) 접힘 영역과 채우는 코드를 제거. ① 교대 입력 패널의 상세는 그대로 둠
- 테스트 637건 통과, 타입 검사·빌드 통과. 브라우저 시각 확인은 미실시

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-11-03 18:06:59 +09:00
ypshimandClaude Haiku 5.5 e1f85e5d86 refactor(abut): 벽체·흉벽 카드의 삽도 삭제
- 형상 카드의 삽도 SVG 표시를 제거하고, 삽도 모듈(src/view/abut-section-svg.ts)과 테스트를 삭제
- 입력 격자, 구간 표, 확인 필요 노트는 그대로 둠
- 테스트 637건 통과, 타입 검사·빌드 통과. 브라우저 시각 확인은 미실시

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-11-03 18:05:32 +09:00
ypshimandClaude Haiku 5.5 842d372312 feat(abut): 벽체·흉벽 카드를 시안대로 정리 (구간 표, 교좌면 폭, 형상 카드 상시 표시)
- 형상 카드(삽도, 입력 격자, 확인 필요 노트)를 접히는 편집 영역 밖으로 옮김. 벽체·흉벽 카드를 열면 편집 토글과 무관하게 보임
- 전면 입력 라벨을 "교좌면 폭 (흉벽 전면 기준, mm)"으로 바꿈 (시안의 교좌면 폭 1600과 같은 값)
- 구간 표 추가: 구간, 시작, 끝, 교좌면 EL, 받침블럭 높이, 벽체 높이 (모델과 부재에서 계산)
- 확인 필요 패널은 아직 형상 카드 안(입력 격자 아래)에 있음. 시안처럼 오른쪽 열로 옮기지는 않음
- 테스트 641건 통과, 타입 검사·빌드 통과. 브라우저 시각 확인은 미실시

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-11-03 18:01:10 +09:00
ypshimandClaude Haiku 5.5 a14497a771 feat(abut): 형상 입력을 삽도 아래 격자로 배치하고 확인 필요 패널 추가
- 형상 입력(라벨·입력·기본값)을 3열 격자로 배치. 삽도는 격자 전체 폭을 차지
- ② 형상계획 편집 영역 끝에 "확인 필요 (spec_abut)" 패널: QA-4~QA-8 (교대 형상 관련)
- 인접 무시 기준(5 mm)은 검사 설정 화면이 없어 이번 단계에서는 옮기지 않음
- 테스트 641건 통과, 타입 검사·빌드 통과. 브라우저 시각 확인은 미실시

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-11-03 17:57:30 +09:00
ypshimandClaude Haiku 5.5 08b1138c96 feat(abut): 벽체·흉벽 평면을 skew로 변경
- 벽체 블록, 흉벽 배면 단차부, 흉벽 본체를 날개벽 선 방향(s = s_axis + (ws/wt)·t)으로 밀린 프리즘으로 만듦
- 벽체 전면·배면이 저판과 같은 skew 평행사변형을 이루고, 날개벽 바깥 선과 맞닿음
- 테스트: 같은 교좌면 단계 안에서 벽체와 흉벽이 공통 체적 없이 맞닿음 (인접 단계는 경계 상자만 겹치므로 제외). 전체 641건 통과, 타입 검사·빌드 통과. 브라우저 시각 확인은 미실시

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-11-03 17:43:29 +09:00
ypshimandClaude Haiku 5.5 a756fd11ce feat(abut): 저판 평면을 skew 평행사변형으로 변경
- 저판 양 끝을 날개벽 바깥 선(s = sEnd + (ws/wt)·t)을 따라 잘라, 평면이 직사각형이 아니라 skew 평행사변형이 되도록 함
- 날개벽 하면이 저판 가장자리와 맞닿음
- 테스트: 저판 뒷면 모서리가 날개벽 바깥 선 위에 있음. 전체 641건 통과, 타입 검사·빌드 통과. 브라우저 시각 확인은 미실시

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-11-03 17:40:54 +09:00
ypshimandClaude Haiku 5.5 d3440e9158 fix(view): 교대만 그려진 상태에서 정면·측면·평면 버튼이 동작하도록 함
- 원인: 교대를 고르면 교각 장면이 없어서 setView가 바로 반환. 정면 버튼이 아무 동작도 하지 않았음
- 교대만 보일 때는 교대 그룹의 좌표계(+x = 교대축, +z = 전면)에서 카메라 방향을 잡고, 보이는 교대 전체를 맞춤
- 테스트 640건 통과, 타입 검사·빌드 통과. 브라우저 시각 확인은 미실시

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-11-03 17:37:43 +09:00
ypshimandClaude Haiku 5.5 686d87bad9 fix(abut): 연직 헌치를 날개벽 내측면(두 날개벽 사이 면) 위에 놓음
- 헌치 모서리를 축 끝에서 날개벽 두께(0.45 m)만큼 안쪽, 즉 내측면으로 옮김. 이전에는 날개벽 위치와 겹쳤음
- 헌치는 내측면에서 중앙 방향으로만 0.3 m 뻗음
- 테스트: 헌치 정점이 날개벽 내측면의 중앙 쪽에 있음 (이전 배치에서는 실패)
- 전체 테스트 통과, 타입 검사·빌드 통과. 브라우저 시각 확인은 미실시

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-11-03 17:35:06 +09:00
ypshim 804bd5c8f8 Revert "Reapply "fix(abut): 날개벽의 연직 헌치 삭제""
This reverts commit 61ef001034.
2026-11-03 17:32:21 +09:00
ypshim d2d679a8e6 Revert "feat(abut): 흉벽 배면 헌치를 삽도 A-A대로 추가 (날개벽 내측면)"
This reverts commit aaa9f57908.
2026-11-03 17:29:59 +09:00
ypshimandClaude Haiku 5.5 aaa9f57908 feat(abut): 흉벽 배면 헌치를 삽도 A-A대로 추가 (날개벽 내측면)
- 흉벽 배면(t −0.5)과 브라켓 상면이 만나는 곳에 (t, EL) 직각삼각형 헌치 (다리 0.3 m)
- 날개벽 내측면에서 중앙 방향으로 0.3 m 밀어 만듦. 브라켓 상면이 s 방향으로 기울어 있어 두 끝 중 높은 쪽에 맞춤
- 테스트: 헌치 개수 4 (흉벽 배면 + 저판 상면), 흉벽 배면 헌치가 브라켓 상면 위에 있음. 전체 638건 통과, 타입 검사·빌드 통과. 브라우저 시각 확인은 미실시

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-11-03 17:29:28 +09:00
ypshim 61ef001034 Reapply "fix(abut): 날개벽의 연직 헌치 삭제"
This reverts commit 4b51ef186f.
2026-11-03 17:26:03 +09:00
ypshim 4b51ef186f Revert "fix(abut): 날개벽의 연직 헌치 삭제"
This reverts commit 786c4a73dd.
2026-11-03 17:22:23 +09:00
ypshimandClaude Haiku 5.5 786c4a73dd fix(abut): 날개벽의 연직 헌치 삭제
- 위 연직 헌치(흉벽 배면)와 아래 연직 헌치(벽체 배면) 삭제. 관련 코드와 Haunch 타입 제거
- 저판 상면 헌치는 유지
- 테스트 갱신. 전체 637건 통과, 타입 검사·빌드 통과. 브라우저 시각 확인은 미실시

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-11-03 17:19:35 +09:00
ypshimandClaude Haiku 5.5 e5134cdb68 fix(abut): 날개벽 헌치를 내측면 기준으로 재배치
- 뒷굽 헌치 삭제
- 연직 헌치 2개(흉벽 배면, 벽체 배면)를 날개벽 내측면 모서리에 평면 삼각형 기둥으로 둠
- 저판 상면 헌치 추가: 저판 상면과 날개벽 내측면 사이의 수평 헌치 (벽체 배면 → 뒷굽 구간, 다리 0.3 m)
- 테스트 갱신 (헌치 6개, 저판 상면 헌치 범위). 전체 639건 통과, 타입 검사·빌드 통과. 브라우저 시각 확인은 미실시

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-11-03 17:16:06 +09:00
ypshimandClaude Haiku 5.5 ea36df58cc fix(abut): 뒷굽 헌치를 삽도대로 날개벽과 확대기초 사이에 둠
- 벽체 배면 쪽 아래 헌치를 삭제 (삽도에 근거 없음)
- 뒷굽 헌치 추가: 뒷굽 끝 H = (저판 뒷굽, 저판 상면)에서 날개벽 하면을 따라 0.3 m, 뒷굽 면을 따라 아래로 0.3 m인 (t, EL) 직각삼각형을 날개벽 두께 0.45 m만큼 s 방향으로 밈. 단축형 날개벽에는 없음
- 위 헌치(흉벽 배면)는 그대로
- 테스트 갱신. 전체 638건 통과, 타입 검사·빌드 통과. 브라우저 시각 확인은 미실시

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-11-03 16:51:25 +09:00
ypshimandClaude Haiku 5.5 3aa7cc06b6 fix(abut): 날개벽을 삽도대로 흉벽·벽체 배면에 맞닿게 나눔
- 날개벽을 상·하 두 부분으로 나눔. 브라켓 상면 위는 흉벽 배면(−0.5)에, 아래는 벽체 배면(−0.8)에 맞닿음 (삽도 A-A와 평면)
- 브라켓 상면이 s 방향으로 기울어 있으므로, 두께 0.45 m 안에서 가장 높은 브라켓 상면을 기준으로 자름
- 헌치는 기존 위치 유지: 위 헌치는 흉벽 배면 모서리, 아래 헌치는 벽체 배면 모서리
- 테스트 갱신: 날개벽 상부·하부 경계, 날개벽 개수 4. 전체 638건 통과, 타입 검사·빌드 통과. 브라우저 시각 확인은 미실시

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-11-03 16:42:10 +09:00
ypshimandClaude Haiku 5.5 7fab9b6685 fix(abut): 날개벽·헌치·접속슬래브가 벽체와 흉벽 안으로 들어가지 않도록
- 날개벽: 벽체 배면(t −0.8) 뒤로만 세움. 벽체·흉벽 앞쪽 부분은 잘라냄 (프로파일을 dM ≥ 벽체 배면 위치로 자름)
- 위 헌치: 브라켓 상면이 s 방향으로 기울므로, 헌치 면적 안에서 가장 높은 브라켓 상면 위에 놓음
- 접속슬래브: 흉벽 배면 단차 위(t ≥ 벽체 배면)는 브라켓 상면에 평평하게 얹고, 벽체 뒤쪽에서만 4 % 하향
- 테스트: 날개벽 t ≤ −0.8, 벽체 배면 위치, 위 헌치와 슬래브가 배면 단차 아래로 내려가지 않음. 전체 638건 통과, 타입 검사·빌드 통과. 브라우저 시각 확인은 미실시

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-11-03 16:35:48 +09:00
ypshimandClaude Haiku 5.5 b425a6010b fix(abut): 벽체와 흉벽을 삽도대로 분리
- 벽체: 배면 −0.8 … 전면 1.6의 2400 블록을 기초 상면부터 교좌면 단차까지 (삽도의 회색 블록)
- 흉벽: 교좌면 단차 위에 얹음. 배면 단차부(−0.8 … −0.5)는 브라켓 상면(흉벽 상단 − 0.85)까지, 흉벽(−0.5 … 0)은 흉벽 상단까지
- 둘은 교좌면 단차에서 맞닿고 겹치지 않음. 배면 단차부는 흉벽 부재로 묶음
- 테스트 636건 통과, 타입 검사·빌드 통과. 브라우저 시각 확인은 미실시

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-11-03 16:31:35 +09:00
ypshimandClaude Haiku 5.5 43edcdc3c5 fix(abut): 흉벽과 벽체가 간섭하지 않도록 벽체 배면 단차를 나눔
- 벽체를 전면부(t −0.5 … 1.6, 교좌면 단차까지)와 배면 단차부(t −0.8 … −0.5, 브라켓 상면까지)로 나눔. 배면 단차 위가 접속슬래브 받침 (흉벽 상단 − 0.85)
- 흉벽은 교좌면 단차 위(t −0.5 … 0)에서 흉벽 상단까지. 벽체와 겹치지 않음
- 배면은 연직 단차 없이 같은 면(−0.8, −0.5)을 따라 올라가고, 바뀌는 곳은 수평 단차 1개
- 테스트: 벽체와 흉벽 사이 공통 체적 0, 벽체 배면 위치. 전체 636건 통과, 타입 검사·빌드 통과. 브라우저 시각 확인은 미실시

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-11-03 16:24:43 +09:00
ypshimandClaude Haiku 5.5 986418024a fix(parts): 헌치를 날개벽 부재 토글에 포함
- 헌치는 날개벽 끝마다 만들어지므로(wings.ts) 벽체·흉벽이 아니라 날개벽 토글에 연결
- 테스트 635건 통과, 타입 검사·빌드 통과

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-11-03 16:20:58 +09:00
ypshimandClaude Haiku 5.5 67bebe5ec2 feat(parts): 부재 표시 항목을 교각·교대 선택과 연동
- 부재 표시 토글은 화면에 그려진 대상에만 적용. 선택한 교각에는 교각 부재, 교대에는 교대 부재가 살아 있고, 나머지 항목은 비활성 (선택 대상만)
- 교대 항목 추가: 벽체·흉벽, 날개벽. 받침·받침블럭, 확대기초, 말뚝, 지반면, 접속슬래브는 교각과 교대에 함께 적용
- 값은 살아 있는 대상에서 읽고, 요약 개수는 활성 토글만 셈
- 테스트 635건 통과, 타입 검사·빌드 통과. 브라우저 시각 확인은 미실시

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-11-03 16:18:11 +09:00
ypshimandClaude Haiku 5.5 fbc315fdba fix(abut): 접속슬래브를 날개벽 안쪽면 사이에 두어 간섭 제거
- 슬래브 평면을 날개벽 안쪽면에서 20 mm 안쪽으로 잡음 (spec §4.8 "측면 = 날개벽 안쪽면 + 20"). 날개벽은 축 방향 프리즘(두께 0.45 m, s 방향)으로 그리므로 안쪽면 s = sEnd + a·0.45 + (ws/wt)·t
- 배면 끝은 흉벽 배면 −0.505에서 7 m (t 기준)
- 간섭 검사 테스트: 슬래브 꼭짓점이 어느 날개벽 평면 영역에도 들어가지 않음
- 테스트 635건 통과, 타입 검사·빌드 통과. 브라우저 시각 확인은 미실시

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-11-03 16:11:53 +09:00
ypshimandClaude Haiku 5.5 8f75c6dc9f feat(abut): 교대 3D에 받침블럭·몰탈패드와 접속슬래브 추가
- 받침마다 블럭 1200 × 1200 (교좌면 ~ z − 50)과 몰탈패드 1000 × 1000 × 50 (spec §4.2, §6.6). 평면은 교대축 방향
- 접속슬래브 450 두께, 브라켓 상면 위, 흉벽 배면에서 5 mm 떨어져 7 m 배면으로, 배면 쪽 4 % 하향 (spec §4.8). 폭은 날개벽 사이 slabWidthMm, 축 중심
- 부재 표시: 받침·받침블럭 토글이 교대 블럭에도 적용, 접속슬래브 토글 추가
- 테스트 추가 (tests/abut-blocks-slab.test.ts). 전체 634건 통과, 타입 검사·빌드 통과. 브라우저 시각 확인은 미실시

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-11-03 15:59:51 +09:00
ypshimandClaude Haiku 5.5 5c2b23211e refactor(plan): 형상계획 ②의 "A1 입력값" 읽기 전용 목록과 입력 배지 제거
- ②의 교대 제목은 이름과 편집 버튼만 남김. 편집 버튼은 받침·날개벽·형상 카드를 여는 역할을 유지
- 읽기 전용 9행 목록은 ②에서 그리지 않음. 같은 값은 ①의 교대 입력 패널과 삽도 라벨, 부재 · 교대 행 요약에 있음
- 테스트 631건 통과, 타입 검사·빌드 통과. 브라우저 시각 확인은 미실시

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-11-03 15:36:21 +09:00
ypshimandClaude Haiku 5.5 789f62adf0 refactor(plan): 형상계획 ②의 교대 기본 정보 입력칸 제거, 삽도에 EL 표시
- ②의 교대 편집 영역에서 기본 정보 6칸과 기본값 복원 버튼을 뺌. 기본 정보 편집과 복원은 ①에만 남김
- ②의 삽도에 교좌면·기초 하단·지반고 값을 읽기 전용 라벨로 표시 (abut-section-svg.ts의 AbutLevels)
- 날개벽 형식은 ②의 날개벽 카드에 그대로 둠
- 테스트 631건 통과, 타입 검사·빌드 통과. 브라우저 시각 확인은 미실시

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-11-03 15:31:54 +09:00
ypshimandClaude Haiku 5.5 18524460e8 feat(plan): 형상계획 ②에 교대 부재 목록과 부재별 카드 추가 (1단계)
- 부재 · 교대 목록(받침, 벽체·흉벽, 날개벽, 접속슬래브, 지반·지층)과 각 행의 값·변경 표시 (src/core/abut/member-list.ts)
- 행을 누르면 그 부재의 카드만 표시. 날개벽 형식은 별도 카드로 분리
- 접속슬래브·지반·지층은 값만 표시 (입력은 2단계)
- 모델 변경 없음. 테스트 630건 통과, 타입 검사·빌드 통과. 브라우저 시각 확인은 미실시

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-11-03 15:10:47 +09:00
ypshimandClaude Haiku 5.5 929f8eb4a2 feat(plan): 형상계획 ②에서 선택한 교대만 3D와 카드에 표시
- 교대를 고르면 교각 장면·철근을 만들지 않고 선택한 교대만 그림. 교각을 고르면 교대를 숨김 (src/core/abut/visibility.ts)
- ①의 전체 보기와 ③·④는 기존 동작 유지
- 교대 선택 시 ②의 부재 목록·코핑 카드·속성 복사를 숨기고 교대 카드만 표시
- 카메라 맞춤은 보이는 객체만 포함
- 테스트 623건 통과, 타입 검사·빌드 통과. 브라우저 시각 확인은 미실시

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-11-03 15:06:50 +09:00
ypshimandClaude Haiku 5.5 5af88a511e feat(abut): 교대 벽체·흉벽 삽도와 수축줄눈·흉벽 Level 입력 추가
- 벽체·흉벽 개략 단면 SVG(src/view/abut-section-svg.ts)를 형상 카드 맨 위에 추가. 치수 글자는 카드 입력값을 mm로 표시하고, 값을 바꾸면 다시 그림
- 형상 입력에 수축줄눈 개수(정수 0 이상)와 흉벽 상단 Level 좌·우(450·995, 계산 미사용·QA-4)를 추가. 기하 계산에는 쓰지 않음
- 테스트 619건 통과, 타입 검사·빌드 통과

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-11-03 14:57:38 +09:00
ypshimandClaude Haiku 5.5 1d31d2f3a9 feat(abut): 교대 형상·입력 패널과 교각 받침·솔플레이트 개편
- 교대 형상(src/core/abut, 3D 장면, 목록·카메라·맞춤)과 교대 입력 패널 추가
- 교각 받침 기준 카드 제거, 솔플레이트 규칙을 교각별 입력으로 변경
- 받침 ELEVATION·솔플레이트 TABLE을 모델값으로 연결
- 교대 입력값 카드: 형식 행에 시점·종점 표시, 제목을 "A1 입력값 [입력]"으로 변경
- 테스트 614건 통과, 타입 검사 통과

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-11-03 14:51:34 +09:00
ypshimandClaude Haiku 5.5 b1b2ad1ddb feat(bearing): 교량받침 ELEVATION TABLE과 솔플레이트 TABLE을 모델값으로 추가
- 모델값만 사용 (도면값 없음). A·B 열은 제거
- ELEVATION: EL.X(엑셀 z), h1 블록, h2 몰탈, h3 받침, h4 솔플(T), E, C1–C6,
  S1·S2 (블록 철근의 본수·길이, 전열 행에 일체 쌍 표시)
- 솔플레이트: S, L, W, T, T1, T2 (소수 한 자리), 검산 T1−T2 ≈ L·|S|/100
- 계산은 core/bearing-table.ts 순수 함수, 테스트 포함

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-11-03 11:22:45 +09:00
ypshimandClaude Haiku 5.5 6cbda0e126 refactor(bearing): 받침높이 H를 UI에서 빼고 고정값(211 mm)으로 둠
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-11-03 11:14:47 +09:00
ypshimandClaude Haiku 5.5 36b26209df feat(bearing): 솔플레이트 규칙을 교각별 입력으로 바꾸고 도면 불러오기 제거 (C안)
- 받침 기준 카드, 도면 불러오기(xlsx·csv·dxf), 도면 참조 표·오버레이 제거
- 교각마다 솔플레이트 규칙 입력: 받침높이 H(솔플레이트 포함) 211, 교직 L 1000,
  교축 W 1100, 종단 기울기 S −0.5 % (기본값, 가정 표시)
- 중심 두께 T = ⌈22 + L·|S|/200⌉ 로 계산해 얇은 쪽 T2가 22 mm 이상이 되게 함
  (기본값 T 25, T2 22.5, h3 = H − T = 186)
- geometry·scene·SVG는 교각 규칙에서 만든 같은 솔플레이트 계획을 모든 받침에 사용

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-11-03 11:07:51 +09:00
182 changed files with 18694 additions and 1663 deletions

No files matched your search

+1
View File
@@ -12,5 +12,6 @@ __pycache__/
*.zip
pierzainer_ground/data/cache/
pierzainer_ground/data/output/**/cache/
backup/
verify/
도면추출/output/
+33 -2
View File
@@ -1,6 +1,14 @@
# PIERZAINER · PIER & ABUT
교각 Excel을 읽어 형상을 생성하고 원도면 기반 지반면·위성사진·2D 지도를 함께 표시하는 프로젝트입니다.
교각·교대 Excel을 읽어 형상과 철근배근을 생성하고 원도면 기반 지반면·위성사진·2D 지도를 함께 표시하는 프로젝트입니다.
## 현재 작업 상태 (2026-11-05 확인)
- 현재 로컬 작업 브랜치는 `master`이며 제품 코드 기준 커밋은 `91c4a4a`입니다. 아래 11월 2일 복구 기록은 당시 이력입니다.
- 교대 ② 형상계획 카드 6개와 ③ 철근배근 6개 부재(받침블럭·벽체·흉벽/브라켓·확대기초·날개벽·접속슬래브)가 구현되어 있습니다.
- A1 표본으로 ③ 입력 재계산·삽도/3D 표시·철근 선택·파일 다운로드·새 페이지 저장 복원을 검증했습니다. 설계 미결 사항과 다른 입력의 동작은 별도 확인 대상입니다.
- 최신 진행 기록은 `../../context-migration/컨텍스트_2026-11-03_교각_교대_진행상황.md`, 남은 항목은 `../../context-migration/OPEN_미해결목록.md`에서 관리합니다.
- 로컬 `origin/master` 추적 정보 대비 89커밋 앞선 상태였습니다. 실제 원격은 조회하지 않았습니다. 브랜치 전환이나 이력 변경 전에 현재 상태를 확인하세요.
## 실행
@@ -23,10 +31,33 @@ npm run dev -- --host 127.0.0.1 --port 5173
- `랜딩페이지/`: 랜딩 화면과 지도 포함 통합 샘플 12개
- `pierzainer_ground/`: 지반 생성 프로그램, 원본 자료, 최종 지반 JSON 및 재현에 필요한 데이터
- `도면추출/`: 도면 추출 프로그램과 교각 샘플 Excel (기본 P1 샘플 포함)
- `tests/`: 현재 교각·Excel·지도 기능의 자동 검사
- `tests/`, `scripts/`, `verify/`: 현재 교각·Excel·지도 기능의 자동 검사와 유지보수 도구
`node_modules/`, `dist/`, `.verify/`, 지반 `cache/`는 실행·검증 중 재생성되는 파일이며 Git에 추가하지 않습니다.
## 검증
```bash
npm test -- --maxWorkers=2
npm run build
```
A1 화면 검증 스크립트는 `scripts/verify-abut-workflow.cjs`입니다. 개발 서버와 별도 프로필 Chrome의 로컬 CDP 포트(기본 9444)가 필요합니다.
현장타설 실배근의 현행 UI 검증은 `node scripts/verify-cast-rebar.cjs`를 사용합니다. Windows Chrome 사용 시 Windows Node로 실행합니다. 샘플 Excel에서 9개 교각·기초 조합을 생성하고 실제 주철근/횡철근/커플러 행·삽도·입력 재생성·불일치 경고·재료표·파일 저장/새 페이지 복원·헤더 3개 폭을 검사합니다. 결과는 `.verify/cast-rebar-20261106/`에 저장합니다. `verify-unified-input.cjs`는 실배근 연결 전 통합 입력 단계의 검증이고, `verify-cast-input.cjs`와 `verify-cast-detail.cjs`는 통합 전 분리 카드 선택자를 사용한 과거 검증 스크립트입니다.
```text
node scripts/verify-abut-workflow.cjs <input.xlsx> <output-directory> <download-directory>
```
Windows Chrome을 쓰면 Windows Node로 실행하고 입력·출력 경로도 Windows 경로 또는 WSL UNC 경로로 전달합니다. 결과 JSON·화면 캡처·SVG·프로젝트 표본은 `.verify/abut-workflow-20261105/`에 보관했습니다. `report.json`의 실행 결과와 범위를 확인하세요. 단순 재열기 검사는 같은 인자 뒤에 `--resume`을 붙일 수 있습니다.
## 2026-11-02 혼합 파일 복구 (당시 기록)
혼합 전 Pierzainer 커밋 `d6735a6`에서 로컬 브랜치 `recovery/pierzainer-20261102`를 만들었습니다. π형 Excel 인식, 전·후열 받침 수 불일치 허용, 랜딩페이지 샘플 연결을 포함합니다. 기존 ITAM 브랜치와 원격 기록은 변경하지 않았습니다.
당시 복구 기록에는 분리 파일을 상위 `_recovery/20261102_mixed_projects/`에 보관하고, 당시 `master`가 ITAM 코드였다고 적혀 있습니다. 2026-11-05 현재 로컬 `master`는 Pierzainer 코드(`91c4a4a`)이므로 과거 안내에 따라 복구 브랜치로 전환하지 마세요. 당시 복구 보관 경로의 현존 여부는 이번 작업에서 재확인하지 않았습니다. 이번 검증·기록 정리에서도 원격 push와 브랜치 전환은 수행하지 않았습니다.
## 정리 기준
실행에 필요한 코드·원본 도면·샘플 Excel·최종 지반 JSON은 유지합니다. 과거 개발용 도구와 옛 좌표 기준의 검증 파일은 정리했으며 백업 목록은 상위 `_recovery/20261103_cleanup/`에 있습니다.
+80 -26
View File
@@ -11,22 +11,26 @@
<header class="top">
<div class="brand">
<strong>Pierzainer</strong>
<span>교각 설계</span>
<span id="project-line" class="proj">프로젝트 미지정</span>
</div>
<nav class="steps" aria-label="단계">
<button id="nav-shape" class="step current" type="button" aria-current="step">① 형상 생성 <em id="step1-status">미생성</em></button>
<button id="nav-plan" class="step" type="button">② 형상계획 <em id="step2-status">예정</em></button>
<button id="nav-rebar" class="step" type="button">③ 철근배근 <em id="step3-status">예정</em></button>
<button id="nav-drawing" class="step" type="button">④ 도면작성 <em id="step-drawing-status">형상 필요</em></button>
<button id="nav-quantity" class="step" type="button">⑤ 재료표 <em id="step4-status">예정</em></button>
<button id="nav-shape" class="step current" data-label="① 형상 생성" aria-label="① 형상 생성" type="button" aria-current="step">① 형상 <em id="step1-status"></em></button>
<button id="nav-plan" class="step" data-label="② 형상계획" aria-label="② 형상계획" type="button">② 계획 <em id="step2-status"></em></button>
<button id="nav-rebar" class="step" data-label="③ 철근배근" aria-label="③ 철근배근" type="button">③ 배근 <em id="step3-status"></em></button>
<button id="nav-drawing" class="step" data-label="④ 도면작성" aria-label="④ 도면작성" type="button">④ 도면 <em id="step-drawing-status"></em></button>
</nav>
<div class="actions">
<span id="save-status" class="savest">저장 전</span>
<button id="home-from-app" class="btn-s" type="button" hidden>홈으로</button>
<button id="home-from-app" class="btn-s" type="button" hidden>홈</button>
<button id="save-project" class="btn-s" type="button" disabled title="프로젝트 파일 저장 (Ctrl+S)">저장</button>
<button id="open-project" class="btn-s" type="button">프로젝트 열기</button>
<button id="regen" class="btn-p" type="button" disabled>선택 교각 형상 생성</button>
<details id="file-menu" class="file-menu">
<summary class="btn-s">불러오기</summary>
<div class="file-menu-items">
<button id="open-import" class="btn small" type="button">엑셀 불러오기</button>
<button id="open-project" class="btn small" type="button">프로젝트 열기</button>
</div>
</details>
<button id="regen" class="btn-p" type="button" disabled>생성</button>
<input id="project-file" type="file" accept=".json" hidden />
</div>
</header>
@@ -43,7 +47,7 @@
<main class="grid" id="view-shape">
<aside class="panel piers" aria-labelledby="pier-title">
<p id="pier-title" class="lbl">교각 목록</p>
<p id="pier-title" class="lbl">교각·교대 목록</p>
<div class="pier-tools">
<label>형식
<select id="type-filter"></select>
@@ -54,7 +58,7 @@
<p id="pier-empty" class="muted">불러온 교각이 없습니다.</p>
<p class="pnote">시트 1장 = 교각 1기입니다. 오류가 난 교각만 건너뛰고 나머지는 생성합니다.</p>
<section class="pier-input" aria-labelledby="input-title">
<section id="pier-info" class="pier-input" aria-labelledby="input-title">
<p id="input-title" class="lbl">입력값</p>
<div class="pier-table-wrap">
<table id="inputs-all" class="grid-table compact">
@@ -71,6 +75,27 @@
<button id="edit-regen" class="btn primary" type="button" disabled>다시 생성 · 변경 0</button>
</section>
</section>
<section id="abut-panel" class="pier-input" hidden aria-labelledby="abut-panel-title">
<p id="abut-panel-title" class="lbl"><span id="abut-panel-name"></span> 입력값 <span class="badge">입력</span>
<button id="abut-panel-edit-toggle" class="btn small" type="button" aria-expanded="false">편집</button></p>
<dl id="abut-panel-input" class="kv wide"></dl>
<div id="abut-panel-editor" hidden>
<div id="abut-panel-basic" class="edit-row"></div>
<details class="abut-group">
<summary>받침 값</summary>
<div id="abut-panel-bearings" class="edit-row"></div>
</details>
<details class="abut-group">
<summary>형상 (벽체·흉벽, 저판, 말뚝)</summary>
<div id="abut-panel-shape" class="edit-row"></div>
</details>
<p id="abut-panel-msg" class="muted" aria-live="polite"></p>
</div>
<details class="abut-group">
<summary>상세 (계산값)</summary>
<dl id="abut-panel-summary" class="kv wide"></dl>
</details>
</section>
</aside>
<section class="panel viewer" id="viewer-block" aria-labelledby="viewer-title">
@@ -159,15 +184,30 @@
<main class="grid plan" id="view-plan" hidden>
<aside class="panel piers" aria-labelledby="plan-pier-title">
<p id="plan-pier-title" class="lbl">교각 목록</p>
<p id="plan-pier-title" class="lbl">교각·교대 목록</p>
<div class="phead" aria-hidden="true"><span>교각</span><span>변경</span></div>
<ul id="plan-pier-items" class="pier-items"></ul>
<p class="lbl abut-members-title">부재 · 교대</p>
<ul id="abut-members" class="members abut-members" aria-label="교대 부재"></ul>
<p class="lbl members-title">부재 · 5개</p>
<ul id="members" class="members"></ul>
</aside>
<div class="center-col">
<div class="viewer-slot" id="slot-plan"></div>
<!-- ② 교대 부재 카드 (시안, 2026-11-03): the card of the row picked in 부재 · 교대, under the 3D view. -->
<section id="abut-panel-plan" class="panel viewer abut-card" hidden aria-labelledby="abut-panel-plan-title">
<div class="viewer-head">
<h2 id="abut-panel-plan-title"><span id="abut-panel-plan-member">교량받침 · 받침블럭 · 솔플레이트</span> <span class="muted small" id="abut-panel-plan-name"></span></h2>
</div>
<div id="abut-panel-plan-bearings" class="edit-row"></div>
<div id="abut-panel-plan-wing" class="edit-row"></div>
<div id="abut-panel-plan-slab" class="edit-row"></div>
<div id="abut-panel-plan-ground" class="edit-row"></div>
<div id="abut-panel-plan-shape" class="edit-row abut-shape-view"></div>
<div id="abut-panel-plan-footing" class="edit-row abut-shape-view"></div>
<p id="abut-panel-plan-msg" class="muted" aria-live="polite"></p>
</section>
<section class="panel viewer" aria-labelledby="member-title">
<div class="viewer-head">
<h2 id="member-title">코핑</h2>
@@ -238,6 +278,7 @@
</aside>
<section class="panel viewer" aria-labelledby="quantity-title">
<div class="viewer-head">
<button id="quantity-back" class="btn-o" type="button">← ③ 철근배근</button>
<h2 id="quantity-title">철근재료표</h2>
<button id="quantity-export" class="btn-o" type="button">엑셀로 내보내기</button>
<button id="quantity-print" class="btn-o" type="button">인쇄</button>
@@ -254,7 +295,16 @@
<ul id="rebar-tabs" class="members"></ul>
<p class="pnote">형상은 ② 형상계획 기준입니다. 형상이 바뀌면 "형상 바뀜"으로 표시합니다.</p>
</aside>
<div class="center-col">
<div class="center-col review-workspace" id="review-workspace">
<div class="workspace-tools" aria-label="검토 공간 조절">
<label class="model-height-control" for="review-model-height">모델 높이
<input id="review-model-height" type="range" min="180" max="560" step="20" value="240" />
<output id="review-model-height-value" for="review-model-height">240 px</output>
</label>
<button id="review-model-toggle" class="btn small" type="button" aria-expanded="true" aria-controls="slot-rebar">모델 접기</button>
<button id="review-inputs" class="btn small" type="button">입력 보기</button>
<button id="review-table" class="btn small" type="button">철근표 보기</button>
</div>
<div class="viewer-slot" id="slot-rebar"></div>
<section class="panel viewer" aria-labelledby="rebar-title">
<div class="viewer-head"><h2 id="rebar-title">철근표</h2></div>
@@ -265,24 +315,26 @@
<aside class="panel results" aria-labelledby="rebar-issues-title">
<h2 id="rebar-issues-title">알림</h2>
<ul id="rebar-issues" class="issues"></ul>
<h2>검토 필요 (결정 대기)</h2>
<ul class="issues">
<li>Q-B5 B1·B3·B5·B7 다리 길이 (제안값 사용)</li>
<li>Q-B6 B2·B6 이음 위치와 길이</li>
<li>Q-C9 P4–B11 간섭: 무시로 결정, 문서 반영 필요</li>
</ul>
<h2>부재 검토</h2>
<p id="review-summary" class="muted small" aria-live="polite"></p>
<ul id="review-tasks" class="issues review-tasks"></ul>
<section class="common" aria-labelledby="common-title">
<p id="common-title" class="lbl">공통 설정</p>
<h3 id="common-title">공통 설정</h3>
<fieldset class="material-fields">
<legend>재료</legend>
<label class="mat">fck (MPa) <input id="mat-fck" type="number" step="any" /></label>
<label class="mat">D 규격 강도 <select id="mat-grade-d"><option value="300" selected>SD300</option><option value="400">SD400</option><option value="500">SD500</option></select></label>
<label class="mat">H 규격 강도 <select id="mat-grade-h"><option value="300">SD300</option><option value="400" selected>SD400</option><option value="500">SD500</option></select></label>
<label class="mat">S 규격 강도 <select id="mat-grade-s"><option value="300">SD300</option><option value="400">SD400</option><option value="500" selected>SD500</option></select></label>
<label class="mat">재료표 할증 (%) · 기본 0 가정 <input id="mat-waste" type="number" step="0.5" min="0" /></label>
<p class="lbl">굽힘 내면반경 (× 철근 지름)</p>
</fieldset>
<fieldset class="material-fields">
<legend>굽힘 내면반경 (× 철근 지름)</legend>
<label class="mat">D16 이하 <input id="bend-small16" type="number" step="0.5" min="0.5" /></label>
<label class="mat">D19–D25 <input id="bend-main25" type="number" step="0.5" min="0.5" /></label>
<label class="mat">D29–D35 <input id="bend-main35" type="number" step="0.5" min="0.5" /></label>
<label class="mat">D38 이상 <input id="bend-main51" type="number" step="0.5" min="0.5" /></label>
</fieldset>
<p id="mat-note" class="muted small"></p>
</section>
</aside>
@@ -355,20 +407,22 @@
<div class="dlg-col">
<section>
<h3>미리보기</h3>
<p id="preview-title" class="muted"></p>
<p id="preview-title" class="muted" aria-live="polite"></p>
<h4>교각 정보</h4>
<div class="pv-scroll"><table id="pv-pier" class="grid-table"></table></div>
<h4>교대 정보</h4>
<div class="pv-scroll"><table id="pv-abuts" class="grid-table"></table></div>
<h4>받침</h4>
<div class="pv-scroll">
<table id="pv-bearings" class="grid-table">
<thead><tr><th>교각</th><th>번호</th><th>x (m)</th><th>y (m)</th><th>z (m)</th><th>방위각</th><th>위치</th></tr></thead>
<thead><tr><th>구조물</th><th>번호</th><th>x (m)</th><th>y (m)</th><th>z (m)</th><th>방위각</th><th>위치</th></tr></thead>
<tbody></tbody>
</table>
</div>
<h4>IP</h4>
<div class="pv-scroll">
<table id="pv-ips" class="grid-table">
<thead><tr><th>교각</th><th>점</th><th>x (m)</th><th>y (m)</th><th>꺾임각</th></tr></thead>
<thead><tr><th>구조물</th><th>점</th><th>x (m)</th><th>y (m)</th><th>꺾임각</th></tr></thead>
<tbody></tbody>
</table>
</div>
@@ -377,9 +431,9 @@
</div>
<footer class="dlg-foot">
<span id="dlg-selected" class="muted"></span>
<span id="dlg-selected" class="muted" aria-live="polite"></span>
<button id="dlg-cancel" class="btn" type="button">취소</button>
<button id="dlg-apply" class="btn primary" type="button" disabled>선택한 교각 0기 형상 생성</button>
<button id="dlg-apply" class="btn primary" type="button" disabled>불러올 구조물을 선택하세요</button>
</footer>
</dialog>
+26
View File
@@ -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(); }
+22
View File
@@ -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; });
+21
View File
@@ -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;});
+49
View File
@@ -0,0 +1,49 @@
// Read-only design sample capture, fresh isolated CDP target. Run via Windows Node.
const fs=require('node:fs'), path=require('node:path');
const root=path.resolve(__dirname,'..'),out=path.join(root,'.verify/impeccable-20261105/a-after');
fs.mkdirSync(out,{recursive:true});
// Preserve the first animation-timed sample before the one allowed corrective batch.
if(fs.existsSync(path.join(out,'raw.json'))){const archive=path.join(out,'initial');fs.mkdirSync(archive,{recursive:true});for(const f of fs.readdirSync(out))if(/\.(png|json)$/.test(f))fs.copyFileSync(path.join(out,f),path.join(archive,f));}
const report={started:new Date().toISOString(),views:[],errors:[]};
const delay=ms=>new Promise(r=>setTimeout(r,ms));let ws;
(async()=>{
const target=await(await fetch('http://127.0.0.1:9444/json/new?'+encodeURIComponent('http://127.0.0.1:5173/'),{method:'PUT'})).json();report.target=target.id;
ws=new WebSocket(target.webSocketDebuggerUrl);await new Promise((r,j)=>{ws.onopen=r;ws.onerror=j});
let id=0;const pending=new Map();ws.onmessage=({data})=>{const m=JSON.parse(data);if(m.id){const p=pending.get(m.id);if(p){pending.delete(m.id);clearTimeout(p.t);m.error?p.j(Error(JSON.stringify(m.error))):p.r(m.result)}}else if(m.method==='Runtime.exceptionThrown')report.errors.push(m.params)};
const send=(method,params={})=>new Promise((r,j)=>{const n=++id,t=setTimeout(()=>j(Error('Timeout '+method)),20000);pending.set(n,{r,j,t});ws.send(JSON.stringify({id:n,method,params}))});
const ev=async expression=>{const r=await send('Runtime.evaluate',{expression,awaitPromise:true,returnByValue:true});if(r.exceptionDetails)throw Error(JSON.stringify(r.exceptionDetails));return r.result.value};
const until=async(expression)=>{for(let n=0;n<100;n++){if(await ev(expression))return;await delay(200)}throw Error('Wait failed '+expression)};
const click=selector=>ev(`(()=>{const e=document.querySelector(${JSON.stringify(selector)});if(!e)throw Error('Missing ${selector}');e.click()})()`);
const cap=async name=>{await delay(600);fs.writeFileSync(path.join(out,name+'.png'),Buffer.from((await send('Page.captureScreenshot',{format:'png'})).data,'base64'));const facts=await ev(`({text:document.body.innerText,viewport:{width:innerWidth,height:innerHeight,scrollWidth:document.documentElement.scrollWidth,scrollHeight:document.documentElement.scrollHeight},controls:[...document.querySelectorAll('button,input,select,summary')].filter(e=>e.getBoundingClientRect().width&&e.getBoundingClientRect().height).map(e=>({tag:e.tagName,id:e.id,text:e.innerText||e.getAttribute('aria-label')||e.getAttribute('placeholder'),r:(()=>{const r=e.getBoundingClientRect();return{x:r.x,y:r.y,w:r.width,h:r.height}})(),font:getComputedStyle(e).fontSize}))})`);report.views.push({name,...facts});fs.writeFileSync(path.join(out,'raw.json'),JSON.stringify(report,null,2))};
await send('Page.enable');await send('Runtime.enable');await send('DOM.enable');
await send('Emulation.setDeviceMetricsOverride',{width:1600,height:1000,deviceScaleFactor:1,mobile:false});
await until(`!!document.querySelector('[data-card="excel"] button')`);await delay(2500);
await click('[data-card="excel"] button');
await ev(`document.getElementById('project-name').value='디자인 검토 A';document.getElementById('project-name').dispatchEvent(new Event('input',{bubbles:true}))`);
const doc=await send('DOM.getDocument'),node=await send('DOM.querySelector',{nodeId:doc.root.nodeId,selector:'#file'});
await send('DOM.setFileInputFiles',{nodeId:node.nodeId,files:[path.resolve(root,'../../RAG/운곡교P1입력.xlsx')]});
await until(`!document.getElementById('dlg-apply').disabled`);await cap('01-import-1600');
report.importBefore=await ev(`({scope:document.querySelector('#dlg-selected').textContent,preview:document.querySelector('#pv-abut')?.innerText,button:document.querySelector('#dlg-apply').textContent})`);
await click('#import-abut-A1');report.importDeselected=await ev(`({scope:document.querySelector('#dlg-selected').textContent,button:document.querySelector('#dlg-apply').textContent,preview:document.querySelector('#pv-abut')?.innerText})`);await click('#import-abut-A1');
await send('Emulation.setDeviceMetricsOverride',{width:1280,height:800,deviceScaleFactor:1,mobile:false});await cap('02-import-1280');
await click('#dlg-apply');await until(`!document.getElementById('import-dialog').open`);
await click('#nav-plan');await until(`[...document.querySelectorAll('#plan-pier-items button')].some(b=>b.textContent.includes('A1')&&b.textContent.includes('교대'))`);
await ev(`[...document.querySelectorAll('#plan-pier-items button')].find(b=>b.textContent.includes('A1')&&b.textContent.includes('교대')).click()`);await delay(4500);
await click('#nav-rebar');await until(`document.querySelectorAll('#rebar-tabs button').length===6`);
await send('Emulation.setDeviceMetricsOverride',{width:1600,height:1000,deviceScaleFactor:1,mobile:false});
if(await ev(`document.querySelector('#review-model-toggle').getAttribute('aria-expanded')==='false'`))await click('#review-model-toggle');
await ev(`document.querySelector('#review-model-height').value='240';document.querySelector('#review-model-height').dispatchEvent(new Event('input',{bubbles:true}))`);await cap('03-rebar-1600');
report.footTasks=await ev(`document.querySelector('#review-summary').textContent+' '+document.querySelector('#review-tasks').innerText`);
await send('Emulation.setDeviceMetricsOverride',{width:1280,height:800,deviceScaleFactor:1,mobile:false});await cap('04-rebar-1280');
await ev(`[...document.querySelectorAll('#rebar-tabs button')].find(b=>b.textContent.includes('날개벽')).click()`);
report.wingTasks=await ev(`document.querySelector('#review-summary').textContent+' '+document.querySelector('#review-tasks').innerText`);
await click('#review-tasks button');report.taskJump=await ev(`({focused:document.activeElement?.innerText,selected:document.querySelector('#rebar-table').innerText.includes('강조 중')})`);
await ev(`[...document.querySelectorAll('#rebar-tabs button')].find(b=>b.textContent.includes('확대기초')).click()`);
await ev(`document.querySelector('#review-model-height').value='180';document.querySelector('#review-model-height').dispatchEvent(new Event('input',{bubbles:true}))`);
await click('#review-inputs');report.inputJump=await ev(`({focused:document.activeElement?.id,scroll:document.querySelector('#review-workspace > .viewer').scrollTop})`);
await click('#review-model-toggle');await click('#review-table');
await ev(`document.querySelector('#view-rebar > .results').scrollTop=250`);await cap('05-collapsed-table-material-1280');
report.end=await ev(`({modelHidden:getComputedStyle(document.querySelector('#slot-rebar')).display,expanded:document.querySelector('#review-model-toggle').getAttribute('aria-expanded'),focused:document.activeElement.tagName,frames:[...document.querySelectorAll('#view-rebar > .panel,#review-workspace,#review-workspace > .viewer')].map(e=>({id:e.id,class:e.className,overflow:getComputedStyle(e).overflow,clientHeight:e.clientHeight,scrollHeight:e.scrollHeight,scrollTop:e.scrollTop}))})`);
report.finished=new Date().toISOString();fs.writeFileSync(path.join(out,'raw.json'),JSON.stringify(report,null,2));console.log(JSON.stringify(report));
await send('Page.close');ws.close();
})().catch(e=>{report.failure=String(e.stack||e);fs.writeFileSync(path.join(out,'raw.json'),JSON.stringify(report,null,2));console.error(e);if(ws)ws.close();process.exitCode=1});
+52
View File
@@ -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();});
+166
View File
@@ -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();
});
+76
View File
@@ -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();
}
})();
+90
View File
@@ -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); });
+241
View File
@@ -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();
});
+140
View File
@@ -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();
});
+121
View File
@@ -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();
});
+102
View File
@@ -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();
});
+130
View File
@@ -0,0 +1,130 @@
// CDP regression checks for the four scoped design improvements. Run with Windows Node.
const fs = require('node:fs');
const path = require('node:path');
const assert = require('node:assert/strict');
const root = path.resolve(__dirname, '..');
const out = path.join(root, '.verify', 'design-fixes-20261105');
const report = { checks: [], errors: [], captures: [], started: new Date().toISOString() };
fs.mkdirSync(out, { recursive: true });
let ws;
let send;
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
async function main() {
const target = await (await fetch('http://127.0.0.1:9444/json/new?http://127.0.0.1:5173/', { method: 'PUT' })).json();
ws = new WebSocket(target.webSocketDebuggerUrl);
await new Promise((resolve, reject) => { ws.onopen = resolve; ws.onerror = reject; });
let seq = 0;
const pending = new Map();
send = (method, params = {}) => new Promise((resolve, reject) => {
const id = ++seq;
const timer = setTimeout(() => { pending.delete(id); reject(new Error(`CDP timeout ${method}`)); }, 20000);
pending.set(id, { resolve, reject, timer });
ws.send(JSON.stringify({ id, method, params }));
});
ws.onmessage = ({ data }) => {
const msg = JSON.parse(data);
if (msg.id) {
const wait = pending.get(msg.id);
if (!wait) return;
clearTimeout(wait.timer); pending.delete(msg.id);
if (msg.error) wait.reject(new Error(JSON.stringify(msg.error))); else wait.resolve(msg.result);
} else if (msg.method === 'Runtime.exceptionThrown') report.errors.push(msg.params.exceptionDetails.exception?.description || msg.params.exceptionDetails.text);
else if (msg.method === 'Runtime.consoleAPICalled' && msg.params.type === 'error') report.errors.push(msg.params.args.map((a) => a.value ?? a.description).join(' '));
};
const ev = async (expression) => {
const r = await send('Runtime.evaluate', { expression, awaitPromise: true, returnByValue: true });
if (r.exceptionDetails) throw new Error(r.exceptionDetails.exception?.description || r.exceptionDetails.text);
return r.result.value;
};
const until = async (expression) => {
for (let i = 0; i < 100; i++) { if (await ev(expression)) return; await delay(150); }
throw new Error(`Timeout: ${expression}`);
};
const click = (expression) => ev(`(() => { const e = ${expression}; if (!e || e.disabled) throw new Error('Missing/disabled control'); e.click(); })()`);
const shot = async (name) => {
await delay(200);
const r = await send('Page.captureScreenshot', { format: 'png' });
fs.writeFileSync(path.join(out, `${name}.png`), Buffer.from(r.data, 'base64'));
report.captures.push(name);
};
const check = (name, condition) => { assert.ok(condition, name); report.checks.push(name); console.log('PASS', name); };
const upload = async () => {
const doc = await send('DOM.getDocument');
const node = await send('DOM.querySelector', { nodeId: doc.root.nodeId, selector: '#file' });
await send('DOM.setFileInputFiles', { nodeId: node.nodeId, files: [] });
await send('DOM.setFileInputFiles', { nodeId: node.nodeId, files: [path.resolve(root, '../../RAG/운곡교P1입력.xlsx')] });
await until(`!document.getElementById('dlg-apply').disabled && document.getElementById('import-abut-A1')?.checked === true`);
};
const member = async (label) => { await click(`[...document.querySelectorAll('#rebar-tabs button')].find(b => b.textContent.startsWith(${JSON.stringify(label)}))`); await delay(100); };
const lower = () => ev(`document.querySelector('#review-workspace > section').getBoundingClientRect().height`);
await send('Page.enable'); await send('Runtime.enable'); await send('DOM.enable');
await send('Emulation.setDeviceMetricsOverride', { width: 1600, height: 1000, deviceScaleFactor: 1, mobile: false });
await until(`!!document.querySelector('[data-card="excel"] button') && typeof window.loadBytesToApp === 'function'`);
await click(`document.querySelector('[data-card="excel"] button')`);
await ev(`document.getElementById('project-name').value = '디자인 개선 검증'; document.getElementById('project-name').dispatchEvent(new Event('input'));`);
await upload();
check('combined import scope', await ev(`document.getElementById('dlg-apply').textContent === '교각 1기 + 교대 1기 가져오기'`));
check('A1 source and dimensions preview', await ev(`document.getElementById('pv-abuts').textContent.includes('A1') && document.getElementById('pv-abuts').textContent.includes('12.197') && document.querySelector('#sheets > li:first-child').textContent.includes('교대 인식 결과')`));
await shot('01-import-both');
await click(`document.getElementById('import-abut-A1')`);
check('uncheck updates preview and count', await ev(`document.getElementById('dlg-apply').textContent.includes('교대 0기') && document.getElementById('pv-abuts').textContent.includes('아니오')`));
await click(`document.getElementById('dlg-apply')`);
check('unchecked abutment is not applied', await ev(`![...document.querySelectorAll('#pier-items button')].some(b => b.textContent.includes('교대'))`));
await click(`document.getElementById('open-import')`);
await upload();
await click(`document.getElementById('dlg-apply')`);
await click(`document.getElementById('nav-rebar')`);
await click(`[...document.querySelectorAll('#rebar-pier-items button')].find(b => b.textContent.includes('A1') && b.textContent.includes('교대'))`);
await member('확대기초');
check('A1 footing excludes pier review questions', await ev(`document.getElementById('review-summary').textContent.includes('A1') && document.getElementById('review-summary').textContent.includes('미결 0건') && !document.getElementById('review-tasks').textContent.includes('Q-C9')`));
await delay(4500); await shot('02-workspace-desktop');
await send('Emulation.setDeviceMetricsOverride', { width: 1280, height: 800, deviceScaleFactor: 1, mobile: false });
await delay(300);
if (await ev(`document.getElementById('review-model-toggle').getAttribute('aria-expanded') === 'false'`)) await click(`document.getElementById('review-model-toggle')`);
await ev(`const size = document.getElementById('review-model-height'); size.value = size.max; size.dispatchEvent(new Event('input'));`);
await delay(200);
report.layoutBefore = await lower();
await ev(`const range = document.getElementById('review-model-height'); range.value = '180'; range.dispatchEvent(new Event('input'));`);
await delay(200);
report.layoutResized = await lower();
check('height control expands evidence area', report.layoutResized >= report.layoutBefore);
check('compact viewer tools stay inside model', await ev(`document.querySelector('.viewer-stack-right').getBoundingClientRect().bottom <= document.getElementById('viewer-block').getBoundingClientRect().bottom + 1`));
await click(`document.getElementById('review-model-toggle')`);
await delay(200);
report.layoutCollapsed = await lower();
check('collapse adds model area to evidence', report.layoutCollapsed > report.layoutResized + 150);
await click(`document.getElementById('review-table')`);
check('table shortcut focuses and reveals table', await ev(`document.activeElement.matches('#rebar-table table.rebar-table') && document.activeElement.getBoundingClientRect().top >= document.querySelector('#review-workspace > section').getBoundingClientRect().top - 2`));
await shot('03-table-laptop');
await click(`document.getElementById('review-inputs')`);
check('input shortcut preserves keyboard tab order', await ev(`document.activeElement.matches('#rebar-table input') && document.activeElement.tabIndex === 0`));
await shot('04-inputs-laptop');
check('layout preference persisted', await ev(`JSON.parse(localStorage.getItem('pierzainer.review-layout')).collapsed === true`));
await click(`document.getElementById('review-model-toggle')`);
await member('받침블럭');
check('block review is scoped', await ev(`document.getElementById('review-tasks').textContent.includes('Q-S4') && !document.getElementById('review-tasks').textContent.includes('Q-C9')`));
await click(`document.querySelector('#review-tasks button')`);
check('review action selects correct bar and row', await ev(`document.querySelector('#rebar-table .rebar-select').textContent.includes('선택 1 /') && document.activeElement.matches('tr') && document.activeElement.textContent.includes('S1')`));
await member('날개벽');
check('wing review shows only relevant unresolved work', await ev(`document.getElementById('review-summary').textContent.includes('미결 2건') && document.getElementById('review-tasks').textContent.includes('E19') && document.getElementById('review-tasks').textContent.includes('V-A2')`));
await shot('05-review-and-materials');
report.fields = await ev(`[...document.querySelectorAll('.material-fields .mat')].map(label => { const r = label.getBoundingClientRect(); const c = label.querySelector('input,select').getBoundingClientRect(); return { label: label.textContent, left: r.left, top: r.top, width: r.width, controlLeft: c.left, controlRight: c.right, bottom: r.bottom }; })`);
check('material fields align without overlap', report.fields.every((f, i, list) => f.controlRight <= f.left + f.width + 1 && (i === 0 || f.top >= list[i - 1].bottom)));
check('document has no horizontal overflow at laptop width', await ev('document.documentElement.scrollWidth === innerWidth'));
await send('Emulation.setDeviceMetricsOverride', { width: 1600, height: 1000, deviceScaleFactor: 1, mobile: false });
await click(`[...document.querySelectorAll('#rebar-pier-items button')].find(b => b.textContent.includes('P1') && !b.textContent.includes('교대'))`);
await member('기둥');
check('existing decision is not presented as unresolved', await ev(`document.getElementById('review-tasks').textContent.includes('Q-C9') && document.getElementById('review-tasks').textContent.includes('결정됨') && document.getElementById('review-summary').textContent.includes('미결 0건')`));
check('no runtime errors', report.errors.length === 0);
report.result = 'PASS';
}
main().catch((error) => { report.result = 'FAIL'; report.failure = error.stack; process.exitCode = 1; }).finally(async () => {
if (report.result === 'FAIL' && send) {
try { const shot = await send('Page.captureScreenshot', { format: 'png' }); fs.writeFileSync(path.join(out, 'failure.png'), Buffer.from(shot.data, 'base64')); } catch { /* preserve original failure */ }
}
report.finished = new Date().toISOString();
fs.writeFileSync(path.join(out, 'report.json'), JSON.stringify(report, null, 2));
console.log(JSON.stringify({ result: report.result, checks: report.checks.length, errors: report.errors, failure: report.failure }));
if (send) { try { await send('Page.close'); } catch { /* tab may already be closed */ } }
ws?.close();
});
+83
View File
@@ -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();
});
+77
View File
@@ -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; });
+151
View File
@@ -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();
});
+192
View File
@@ -0,0 +1,192 @@
/**
* 교대 받침 카드 (2026-11-03, 시안 ② 교량받침·받침블럭·솔플레이트, 교각 카드와 같은 형식). 순수 함수.
*
* - 솔플레이트 (결정 2026-11-06, spec_abut §5·QA-15, src/core/sole-plate.ts): S = 같은 거더의 짝 받침(A1 ↔ P1 전열,
* A2 ↔ Pn)으로 계산, 짝이 없으면 교대 종단경사 i(엑셀 Abut). L = A + 100, W = B + 100 (A × B 기본 700 × 640, 교각 P1
* 전열 값 가정). 받침이 1열이라 T는 교대 전체에 하나: max(22, ⌈22 + L·|S|/2⌉). 거더 하면 고정 → 블럭 상면 = z − (T − T_ref).
* - h3 기본값 = 받침 시트의 받침높이 − T_ref (A1 275 − 30 = 245): 엑셀 받침높이가 정한 거더 하면을 그대로 둔다
* (시안은 교각 P1의 h3 181을 가정했으나 교대 시트에 받침높이가 있어 그 값을 지킨다).
* - 높이: E = h1 (블럭 콘크리트) + h2 (몰탈패드 50) + h3 + h4 (= T). z = 패드 상면 = EL.X.
*/
import type { BearingPair } from '../bearing-pair';
import { type BearingSole, SOLE_DEFAULTS, structureSoles } from '../sole-plate';
import { ABUT_PAD_MM, type AbutModel } from './geometry';
import type { AbutMembers } from './members';
/** 교대 솔플레이트 입력 (2026-11-06). Every value optional; absent = the default. */
export interface AbutBearingRule {
/** 받침 본체 높이 h3 (mm). Absent = 받침높이 − T_ref. */
h3Mm?: number;
tRefMm: number;
tMinMm: number;
/** 솔플 여유 L − A = W − B (mm). */
marginMm: number;
/** 받침 평면 A (거더 방향) × B (mm), every bearing. */
aMm: number;
bMm: number;
/** T typed (mm). Absent = 자동. */
tMm?: number;
/** S typed for every bearing (%). Absent = 짝 받침 계산. */
slopePct?: number;
}
export const ABUT_BEARING_RULE_DEFAULT: AbutBearingRule = { tRefMm: SOLE_DEFAULTS.tRefMm, tMinMm: SOLE_DEFAULTS.tMinMm, marginMm: SOLE_DEFAULTS.marginMm, aMm: SOLE_DEFAULTS.aMm, bMm: SOLE_DEFAULTS.bMm };
/** 받침블럭 평면 (mm, 정사각) and 몰탈패드 평면 (mm), spec_abut §4.2. */
export const ABUT_BLOCK_MM = 1200;
export const ABUT_PAD_PLAN_MM = 1000;
/** I01: 블럭 콘크리트 높이가 피복 50 + 철근 지름 13보다 작으면 블럭 철근을 교좌면 아래로 묻는다 (spec_abut §6.6). */
const I01_MIN_MM = 50 + 13;
const CIRCLED = ['①', '②', '③', '④', '⑤', '⑥', '⑦', '⑧', '⑨', '⑩'];
/** Keys the 2026-11-06 rule reads; values stored by the C안 (lengthMm, widthMm, kind) are ignored. */
const RULE_KEYS = ['h3Mm', 'tRefMm', 'tMinMm', 'marginMm', 'aMm', 'bMm', 'tMm', 'slopePct'] as const;
export function resolveBearingRule(changes?: Partial<AbutBearingRule> & Record<string, unknown>): AbutBearingRule {
const own = Object.fromEntries(Object.entries(changes ?? {}).filter(([k]) => (RULE_KEYS as readonly string[]).includes(k)));
return { ...ABUT_BEARING_RULE_DEFAULT, ...own };
}
/** Bridge context of the abutment card: the girder pairs and the 교대 종단경사 i (decimal). */
export interface AbutSoleContext {
pairs?: BearingPair[];
slopeI?: number;
/** 엑셀 방위각 per bearing number (deg model 진북), when an applied 계산 방위각 is in the model. */
inputAzimuthDeg?: Record<string, number>;
}
/** Solplate of every bearing of the abutment (one row). */
export function abutSoles(model: AbutModel, rule: AbutBearingRule, heightMm: number, ctx: AbutSoleContext = {}): BearingSole[] {
const plates = Object.fromEntries(model.bearings.map((b) => [String(b.no), { aMm: rule.aMm, bMm: rule.bMm }]));
return structureSoles(
model.name,
model.bearings.map((b) => ({ no: b.no, row: '1열', azimuthDeg: b.azimuthDeg ?? 0, inputAzimuthDeg: ctx.inputAzimuthDeg?.[String(b.no)] })),
{
h3Mm: rule.h3Mm ?? heightMm - rule.tRefMm, tRefMm: rule.tRefMm, tMinMm: rule.tMinMm, marginMm: rule.marginMm, plates,
tByRow: rule.tMm === undefined ? undefined : { '1열': rule.tMm },
slopePct: rule.slopePct === undefined ? undefined : Object.fromEntries(model.bearings.map((b) => [String(b.no), rule.slopePct!])),
},
ctx.pairs ?? [],
ctx.slopeI === undefined ? undefined : ctx.slopeI * 100,
);
}
export interface AbutElevationRow {
no: number;
/** IP3(① 쪽)에서 잰 위치 (m). */
sFromIp3M: number;
seg: string;
seatEl: number;
h1: number;
h2: number;
/** 패드 상면 = EL.X (m). */
z: number;
h3: number;
h4: number;
E: number;
/** 솔플레이트 상면 EL (m) = 교좌면 + E. */
solplateTopEl: number;
/** 블럭 끝에서 가장 가까운 계단 경계까지 (m). 경계가 없으면 null. */
stepGapM: number | null;
/** 받침 방위각 (deg): CAD (엑셀 값, 동 = 0° 반시계) / 진북 (북에서 시계). 입력이 없으면 null. */
azimuthCadDeg: number | null;
azimuthNorthDeg: number | null;
/** 블럭 평면 회전 (deg), 교대축 직각 기준. */
rotDeg: number;
}
export function abutElevationRows(model: AbutModel, rule: AbutBearingRule, heightMm: number, ctx: AbutSoleContext = {}): AbutElevationRow[] {
const half = ABUT_BLOCK_MM / 2000;
const soles = new Map(abutSoles(model, rule, heightMm, ctx).map((x) => [x.no, x]));
return model.bearings.map((b) => {
const plan = soles.get(b.no)!;
// 거더 하면 고정: the block concrete loses T − T_ref (2026-11-06, Q-SH7).
const h1 = b.blockHeightMm - ABUT_PAD_MM - plan.dropMm;
const E = h1 + ABUT_PAD_MM + plan.h3 + plan.T;
const gaps = model.stepBounds.map((x) => Math.abs(x - b.s) - half);
return {
no: b.no,
sFromIp3M: model.length - b.s,
seg: CIRCLED[b.no - 1] ?? String(b.no),
seatEl: b.seatEl,
h1,
h2: ABUT_PAD_MM,
z: b.z - plan.dropMm / 1000,
h3: plan.h3,
h4: plan.T,
E,
solplateTopEl: b.seatEl + E / 1000,
stepGapM: gaps.length ? Math.min(...gaps) : null,
azimuthCadDeg: b.azimuthDeg === undefined ? null : (((90 - b.azimuthDeg) % 360) + 360) % 360,
azimuthNorthDeg: b.azimuthDeg === undefined ? null : ((b.azimuthDeg % 360) + 360) % 360,
rotDeg: b.rotDeg,
};
});
}
export interface AbutSolplateRow {
no: number;
/** 기울기 (%). */
S: number;
/** S 근거 (계산 / 입력 / 가정 / 수정됨 / 없음) and the 짝 받침, e.g. "P1-①". */
source: string;
pair?: string;
/** 방위각 검사 (″): input azimuth against the pair direction; absent without a pair. */
azimuthSec?: number;
pairKind?: 'girder' | 'chord';
/** 방위각 입력 (엑셀) and 계산 (짝 받침), deg model 진북; 계산 absent without a pair. */
azimuthInputDeg: number;
azimuthCalcDeg?: number;
L: number;
W: number;
T: number;
T1: number;
T2: number;
/** 검산 문구 "T1 − T2 ≈ L·|S|/100" (W08, 1 mm 이내). */
check: string;
warnings: string[];
}
export function abutSolplateRows(model: AbutModel, rule: AbutBearingRule, heightMm: number, ctx: AbutSoleContext = {}): AbutSolplateRow[] {
return abutSoles(model, rule, heightMm, ctx).map((p) => {
const rhs = (p.L * Math.abs(p.slopePct)) / 100;
return {
no: p.no, S: p.slopePct, source: p.slopeSource, azimuthSec: p.azimuthCheckSec, azimuthInputDeg: p.azimuthInputDeg, azimuthCalcDeg: p.azimuthCalcDeg,
pair: p.pair ? `${p.pair.structure}-${CIRCLED[p.pair.no - 1] ?? p.pair.no}${p.pairKind === 'chord' ? ' (현)' : ''}` : undefined, pairKind: p.pairKind,
L: p.L, W: p.W, T: p.T, T1: p.T1, T2: p.T2,
check: `${p.T1 - p.T2} ≈ ${rhs.toFixed(1)} ${Math.abs(p.T1 - p.T2 - rhs) <= 1 + 1e-9 ? '일치' : '불일치 (W08)'}`,
warnings: p.warnings,
};
});
}
/** Input problems of the 교대 솔플레이트 입력. Empty when it can build a solplate. */
export function abutBearingRuleIssues(rule: AbutBearingRule, heightMm: number): string[] {
const out: string[] = [];
for (const k of ['tRefMm', 'tMinMm', 'aMm', 'bMm'] as const) if (!(Number.isFinite(rule[k]) && rule[k] > 0)) out.push(`${k}는 0보다 커야 합니다.`);
if (!(Number.isFinite(rule.marginMm) && rule.marginMm >= 0)) out.push('솔플 여유는 0 이상이어야 합니다.');
if (rule.tMm !== undefined && !(Number.isFinite(rule.tMm) && rule.tMm > 0)) out.push('T는 0보다 커야 합니다.');
if (rule.slopePct !== undefined && !Number.isFinite(rule.slopePct)) out.push('S는 숫자여야 합니다.');
const h3 = rule.h3Mm ?? heightMm - rule.tRefMm;
if (!(Number.isFinite(h3) && h3 > 0)) out.push(`받침 높이 h3 (${h3})는 0보다 커야 합니다. 받침높이 ${heightMm}와 T_ref를 확인하세요.`);
return out;
}
/** 검사 줄 (시안): W02 블럭 콘크리트, W04 블럭이 교좌면 안, W03 블럭 끝 ~ 계단 경계, I01 블럭 철근. 문제 없으면 "없음". */
export function abutBearingChecks(model: AbutModel, members: AbutMembers, rule: AbutBearingRule, heightMm: number, ctx: AbutSoleContext = {}): string[] {
const rows = abutElevationRows(model, rule, heightMm, ctx);
const minH1 = Math.min(...rows.map((r) => r.h1));
const half = ABUT_BLOCK_MM / 2000;
// Block plan across the wall: centre t = frontSide × lateral, between the 흉벽 front (t 0) and the 벽체 front.
const tCentres = model.bearings.map((b) => model.frontSide * b.lateral);
const backGap = Math.min(...tCentres.map((t) => t - half));
const frontGap = Math.min(...tCentres.map((t) => members.shape.wallFrontM - (t + half)));
const stepGaps = rows.map((r) => r.stepGapM).filter((g): g is number => g !== null);
const minStep = stepGaps.length ? Math.min(...stepGaps) : null;
return [
`블럭 콘크리트 ${minH1} > 0 (W02 ${minH1 > 0 ? '없음' : '있음'})`,
`블럭이 교좌면 안 (흉벽 ${Math.round(backGap * 1000)} · 전면 ${Math.round(frontGap * 1000)}, W04 ${backGap >= 0 && frontGap >= 0 ? '없음' : '있음'})`,
minStep === null ? '블럭 끝 ~ 계단 경계: 경계 없음 (W03 없음)' : `블럭 끝 ~ 계단 경계 최소 ${minStep.toFixed(3)} m (W03 ${minStep >= 0 ? '없음' : '있음'})`,
`${minH1} ≥ ${I01_MIN_MM} → I01 ${minH1 >= I01_MIN_MM ? '없음' : '있음 (블럭 철근을 교좌면 아래로)'}`,
];
}
+297
View File
@@ -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 };
}
+211
View File
@@ -0,0 +1,211 @@
/**
* 교대 형상 · AA1 (2026-11-03, spec_abut §1·§4.1–4.2): 교대축, 전면 쪽, 교좌면 위의 받침, 교좌면 EL과 블럭 높이를 계산한다.
* 순수 함수. 좌표는 (E, N) m 단위, EL은 m 단위, 길이는 별도 표기가 없으면 mm 단위다.
*
* - 축: IP1 → IP3 선(교대중심선), 길이 L. u = IP1 → IP3 단위벡터, nLeft = u를 반시계 90° 돌린 벡터.
* - 받침 s: IP1에서 u 방향으로 잰 거리(m). 축에서 벗어난 거리(lateral): nLeft 방향 거리(m).
* - z = 패드 상면(EL.X), 교각과 같다(2026-11-03 결정 1). 교좌면에서 패드 상면까지 블럭 높이는 125 mm
* (콘크리트 75 + 패드 50). 교좌면 EL은 콘크리트 하면이다.
* - 교좌면: 교대 전체에 교좌면 EL 하나를 입력받는다(첫 받침 ①). 블럭 높이 h = min(z) − 교좌면으로 모든 받침이 같고,
* 받침 i의 교좌면 = z_i − h. 받침이 자기 교좌면 EL(seatEl)을 직접 주면 그 값을 쓰고, 블럭 높이는 z_i − seatEl이 된다
* (2026-11-03 결정 3).
* - 전면: 받침이 있는 쪽. A1은 전면이 1 → n 방향의 왼쪽, 흉벽은 오른쪽에 있다. A2는 반대다(spec_abut §1).
* 맞지 않으면 AW03 경고 대상이다.
*/
export type AbutType = 'A1' | 'A2';
export interface AbutPoint {
e: number;
n: number;
}
export interface AbutBearing {
no: number;
/** 위치 (E, N), m. */
pos: AbutPoint;
/** 패드 상면 EL (z), m. */
z: number;
/** 이 받침의 교좌면 EL (m). 없으면 z에서 교대 블럭 높이를 뺀 값을 쓴다. */
seatEl?: number;
/** 받침 방위각 (deg, model-xy: 북에서 시계방향, 교각과 같은 변환 swapXYAzimuth). 없으면 블럭은 교대축에 맞춘다. */
azimuthDeg?: number;
}
/** 날개벽 형식, 측별(spec_abut §4.7): 일반형 / 단축형 / 없음. */
export type WingForm = 'general' | 'short' | 'none';
export interface AbutInput {
name: string;
type: AbutType;
/** 첫 받침(구간 ①)의 교좌면 EL (m). 블럭 높이 h = min(z) − seatEl. */
seatEl: number;
ip1: AbutPoint;
ip2: AbutPoint;
ip3: AbutPoint;
bearings: AbutBearing[];
/** skew (deg): 받침축을 시계방향으로 돌린 선이 날개벽 선이다(spec_abut §1). 없으면 날개벽 검사를 하지 않는다. */
skewDeg?: number;
/** 종단경사 i (소수, 교량진행방향 기준, (−) = 낮아짐). 날개벽 경사는 A1이 −i, A2가 +i다. */
slopeI?: number;
/** AW01용 거더 방향 CAD 각 (deg). 없으면 AW01을 검사하지 않는다. */
girderAzimuthDeg?: number;
/** 측별 날개벽 형식: 'left' = 받침 ① 쪽 끝, 'right' = 받침 n 쪽 끝(spec §4.7). 기본은 양측 일반형. */
wings?: { left: WingForm; right: WingForm };
/** 측별 날개벽 두께 (mm). 없으면 450. 접속슬래브 폭에 쓴다. */
wingThicknessMm?: { left?: number; right?: number };
/** 접속슬래브와 날개벽 안쪽면 사이 간격 (mm, ② 접속슬래브 카드). 없으면 20. */
slabWingGapMm?: number;
/** 기초 하단 EL (m), 말뚝길이 (mm), 기초 형식 (AA3, 저판·말뚝). 없으면 저판·말뚝을 만들지 않는다. */
foundationBottomEl?: number;
pileLengthMm?: number;
foundationType?: string;
}
export interface AbutWingModel {
/** 날개벽 선, CAD 각 (deg), 배면 쪽(예제: A1 ≈ 255.32, A2 ≈ 75.32). */
lineDeg: number;
/** 날개벽 선과 거더 방향의 사잇각, 0–90°로 접음(AW01 허용 1°). */
girderDiffDeg: number;
aw01Ok: boolean;
/** 날개벽 경사 (%), (+) = 벽체에서 멀어질수록 높아짐. */
slopePct: number;
/** 날개벽 사이 접속슬래브 폭 (mm) = L − Σ(두께 / |sin Δ| + 날개벽과 간격), 있는 날개벽마다. */
slabWidthMm: number;
wings: { left: WingForm; right: WingForm };
}
export interface AbutBearingModel {
no: number;
/** IP1에서 u 방향 거리 (m). */
s: number;
/** nLeft 방향으로 잰 축에서의 부호 있는 거리 (m). */
lateral: number;
z: number;
seatEl: number;
/** 교좌면에서 패드 상면까지 블럭 높이 (mm), 패드 포함. */
blockHeightMm: number;
/** 받침 방위각 (deg, model-xy). 입력이 없으면 undefined. */
azimuthDeg?: number;
/** 블럭 평면 회전 (deg, −90 … 90): 받침 방위각과 교대축 직각(전면) 방향 사이의 각. 장면에서는 rotateY(이 값). 입력이 없으면 0. */
rotDeg: number;
}
export interface AbutModel {
name: string;
type: AbutType;
/** IP1 (축의 시작점, s = 0). */
ip1: AbutPoint;
/** 축 길이 IP1 → IP3 (m). */
length: number;
u: AbutPoint;
nLeft: AbutPoint;
/** 받침이 축의 nLeft 쪽이면 +1, 반대쪽이면 −1. */
frontSide: 1 | -1;
/** 받침이 1 → n 방향의 왼쪽이면 true (A1 배치). */
frontLeftOfBearingDir: boolean;
/** 입력 순서의 받침. */
bearings: AbutBearingModel[];
/** 축 위 계단 경계(IP1에서 m), 이웃 받침 사이의 중간. */
stepBounds: number[];
/** AW03: 배치가 형식과 맞으면 true. */
layoutMatchesType: boolean;
/** 날개벽 선, 경사, AW01, 접속슬래브 폭. 입력에 skew가 없으면 없다. */
wing?: AbutWingModel;
}
const WING_THICKNESS_MM = 450;
const SLAB_CLEARANCE_MM = 20;
const AW01_TOLERANCE_DEG = 1;
/** 공통 규칙의 패드 두께 (mm). 블럭 높이에 포함된다(패드 포함). */
export const ABUT_PAD_MM = 50;
const sub = (a: AbutPoint, b: AbutPoint): AbutPoint => ({ e: a.e - b.e, n: a.n - b.n });
/**
* 블럭 평면 회전 (deg): the bearing azimuth direction d = (sin az, cos az) seen in the axis frame (s along u, t along the front),
* folded to −90 … 90 because the block is symmetric. 0 when the azimuth is missing.
*/
export function bearingRotDeg(azimuthDeg: number | undefined, u: AbutPoint, front: AbutPoint): number {
if (azimuthDeg === undefined || !Number.isFinite(azimuthDeg)) return 0;
const r = (azimuthDeg * Math.PI) / 180;
const d = { e: Math.sin(r), n: Math.cos(r) };
let phi = (Math.atan2(d.e * u.e + d.n * u.n, d.e * front.e + d.n * front.n) * 180) / Math.PI;
if (phi > 90) phi -= 180;
if (phi < -90) phi += 180;
return phi;
}
const dot = (a: AbutPoint, b: AbutPoint) => a.e * b.e + a.n * b.n;
const cross = (a: AbutPoint, b: AbutPoint) => a.e * b.n - a.n * b.e;
export function buildAbut(input: AbutInput): AbutModel {
if (input.bearings.length < 2) throw new Error(`교대 ${input.name}: 받침이 2개 이상 필요합니다.`);
const axisVec = sub(input.ip3, input.ip1);
const length = Math.hypot(axisVec.e, axisVec.n);
if (!(length > 0)) throw new Error(`교대 ${input.name}: IP1과 IP3이 같은 점입니다.`);
const u = { e: axisVec.e / length, n: axisVec.n / length };
const nLeft = { e: -u.n, n: u.e };
const lateral = input.bearings.map((b) => dot(sub(b.pos, input.ip1), nLeft));
const meanLateral = lateral.reduce((a, x) => a + x, 0) / lateral.length;
const frontSide: 1 | -1 = meanLateral >= 0 ? 1 : -1;
// 1 → n 방향: 첫 받침(①)에서 마지막 받침까지. 전면 = 받침 쪽. 형식과 맞는지 확인한다.
const first = input.bearings[0];
const last = input.bearings[input.bearings.length - 1];
const dir = sub(last.pos, first.pos);
const frontVec = { e: nLeft.e * frontSide, n: nLeft.n * frontSide };
const frontLeftOfBearingDir = cross(dir, frontVec) > 0;
const layoutMatchesType = input.type === 'A1' ? frontLeftOfBearingDir : !frontLeftOfBearingDir;
const minZ = Math.min(...input.bearings.map((b) => b.z));
const bearings: AbutBearingModel[] = input.bearings.map((b, i) => {
const s = dot(sub(b.pos, input.ip1), u);
const seatEl = b.seatEl ?? input.seatEl + (b.z - minZ);
const blockHeightMm = Math.round((b.z - seatEl) * 1000);
return { no: b.no, s, lateral: lateral[i], z: b.z, seatEl, blockHeightMm, azimuthDeg: b.azimuthDeg, rotDeg: bearingRotDeg(b.azimuthDeg, u, frontVec) };
});
const sorted = [...bearings].sort((a, b) => a.s - b.s);
const stepBounds = sorted.slice(1).map((b, i) => (sorted[i].s + b.s) / 2);
const wing = input.skewDeg === undefined ? undefined : buildWing(input, u, nLeft, frontSide, dir, length);
return { name: input.name, type: input.type, ip1: input.ip1, length, u, nLeft, frontSide, frontLeftOfBearingDir, bearings, stepBounds, layoutMatchesType, wing };
}
const deg = (rad: number) => (rad * 180) / Math.PI;
const norm360 = (x: number) => ((x % 360) + 360) % 360;
/** 두 선 방향의 차이(deg), 0–90°로 접는다. 선에는 방향이 없다. */
const lineDiff = (a: number, b: number) => {
const d = norm360(a - b) % 180;
return Math.min(d, 180 - d);
};
function buildWing(input: AbutInput, u: AbutPoint, nLeft: AbutPoint, frontSide: 1 | -1, dir: AbutPoint, length: number): AbutWingModel {
const skew = input.skewDeg!;
const bearingCad = norm360(deg(Math.atan2(dir.n, dir.e)));
// skew만큼 시계방향으로 돌린 선이 날개벽 선이다. 배면 쪽(받침 반대쪽)이 방향을 정한다.
const line = norm360(bearingCad - skew);
const back = { e: -nLeft.e * frontSide, n: -nLeft.n * frontSide };
const radLine = (line * Math.PI) / 180;
const pick = Math.cos(radLine) * back.e + Math.sin(radLine) * back.n > 0 ? line : norm360(line + 180);
const axisCad = norm360(deg(Math.atan2(u.n, u.e)));
const sinDelta = Math.abs(Math.sin(((pick - axisCad) * Math.PI) / 180));
const wings = input.wings ?? { left: 'general' as WingForm, right: 'general' as WingForm };
// 날개벽 두께는 측별 입력 (2026-11-04, 없으면 450): 축 방향 폭 = 두께 / |sin Δ|.
const thick = { left: input.wingThicknessMm?.left ?? WING_THICKNESS_MM, right: input.wingThicknessMm?.right ?? WING_THICKNESS_MM };
const gap = input.slabWingGapMm ?? SLAB_CLEARANCE_MM;
const present = (f: WingForm, side: 'left' | 'right') => (f === 'none' ? 0 : thick[side] / sinDelta + gap);
const slabWidthMm = Math.round(length * 1000 - present(wings.left, 'left') - present(wings.right, 'right'));
const girderDiffDeg = input.girderAzimuthDeg === undefined ? NaN : lineDiff(pick, input.girderAzimuthDeg);
const slopeI = input.slopeI ?? 0;
return {
lineDeg: pick,
girderDiffDeg,
aw01Ok: Number.isNaN(girderDiffDeg) ? true : girderDiffDeg <= AW01_TOLERANCE_DEG,
slopePct: (input.type === 'A1' ? -slopeI : slopeI) * 100,
slabWidthMm,
wings,
};
}
+67
View File
@@ -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;
});
}
+35
View File
@@ -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 };
});
}
+37
View File
@@ -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);
}
+94
View File
@@ -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),
},
];
}
+97
View File
@@ -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,
};
}
+6
View File
@@ -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;
}
+24
View File
@@ -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 })),
];
}
+251
View File
@@ -0,0 +1,251 @@
/**
* 교대 입력 읽기 (2026-11-03, spec_abut §2): 입력 엑셀의 교대 행을 읽는다. 교대 표는 교각 표와 같은 시트에 있으며,
* 첫 두 헤더 칸(교대명 + 형식 / IP점 / 받침번호)으로 구분한다. 워크북 객체에 대해 순수하다. 좌표: 시트 x_m = 북, y_m = 동 → 내부 (E, N) = (y_m, x_m).
*
* 받침높이 (2026-11-03 결정 1): 교각과 같은 정의 — 패드 상면(z)에서 솔플레이트 상면까지.
* 받침 행의 값을 그대로 읽는다(A1 시트 값 275). 여기서 계산하지 않는다.
*/
import * as XLSX from 'xlsx';
import { parseDms } from '../dms';
import { swapXYAzimuth } from '../coordinate-frame';
import type { Issue } from '../types';
import { abutNameError } from './name';
import type { AbutInput, AbutPoint, AbutType, WingForm } from './geometry';
import type { AbutShape } from './shape';
import type { AbutBearingRule } from './bearing-table';
import type { WingDims } from './wings';
import type { AbutSlab } from './slab';
import type { AbutGround } from './ground';
import type { AbutFootingRebarSettings } from './rebar/footing';
import type { AbutWallRebarSettings } from './rebar/wall';
import type { AbutParapetRebarSettings } from './rebar/parapet';
import type { AbutWingRebarSettings } from './rebar/wing';
import type { AbutSlabRebarSettings } from './rebar/slab';
import type { AbutBlockSettings } from './rebar/block';
type Cell = string | number | null | undefined;
type Grid = Cell[][];
export interface AbutRecordBearing {
no: number;
/** Position (E, N), m: E = y_m, N = x_m. */
pos: AbutPoint;
z: number;
/** 교좌면_EL of this bearing (m). Absent = the block rule (z − 블럭 높이). */
seatEl?: number;
/** 이 받침에 적힌 받침높이 (mm). */
bearingHeightMm: number;
/** 받침 방위각 (deg, model-xy, 교각과 같은 swapXYAzimuth 변환). 비었거나 읽을 수 없으면 없음. */
azimuthDeg?: number;
row: string;
}
export interface AbutRecord {
name: string;
sheet: string;
type: AbutType;
/** 첫 받침(구간 ①)의 교좌면 EL (m). */
seatEl: number;
/** 기초 하단 EL (m), 지반고 EL (m), 기초 형식, 말뚝길이 (mm). */
foundationBottomEl: number;
groundEl: number;
foundation: string;
pileLengthMm: number;
/** skew (deg) from the DMS text, and 종단경사 i (decimal). */
skewDeg: number;
slopeI: number;
ip1: AbutPoint;
ip2: AbutPoint;
ip3: AbutPoint;
bearings: AbutRecordBearing[];
/** 받침 행에서 읽은 받침높이 (mm). 모든 받침이 같아야 하며, 다르면 AP07 경고. */
bearingHeightMm: number;
/** 측별 날개벽 형식 (편집 카드에서 정함). Absent = 양측 일반형. */
wings?: { left: WingForm; right: WingForm };
/** 형상 입력 (벽체·흉벽, 확대기초·말뚝, mm). Only the changed values; the rest are the defaults. Absent = all defaults. */
shape?: Partial<AbutShape>;
/** 받침 규칙 (② 교량받침 카드: h3, 솔플레이트 T·여유, 받침 종류). Only the changed values. Absent = 교각 P1 가정값 (QA-15). */
bearingRule?: Partial<AbutBearingRule>;
/** 계산 방위각을 적용한 받침: 받침번호 → 방위각 (deg, model 진북). Absent = 엑셀 입력값 (2026-11-06). */
azimuthApplied?: Record<string, number>;
/** 날개벽 치수 (② 날개벽 카드, 측별, mm). Only the changed values. */
wingDims?: { left?: Partial<WingDims>; right?: Partial<WingDims> };
/** 접속슬래브 입력 (② 접속슬래브 카드: 형식, 경사, 치수 mm). Only the changed values. Absent = 매립형 · 하향 4 % 기본값. */
slab?: Partial<AbutSlab>;
/** 지반·지층 입력 (② 지반·지층 카드, 교각과 같은 필드). Absent = 육상, 지하수위 없음, 지층 행 없음. */
ground?: AbutGround;
/** ③ 배근 입력 (부재별, 바꾼 값만). Absent = 엑셀 Abut 시트 기본값. */
rebar?: { footing?: Partial<AbutFootingRebarSettings>; wall?: Partial<AbutWallRebarSettings>; parapet?: Partial<AbutParapetRebarSettings>; wing?: Partial<AbutWingRebarSettings>; slab?: Partial<AbutSlabRebarSettings>; block?: Partial<AbutBlockSettings> };
/** 예전 "좌우 같게" 값 (2026-11-04 이전 파일). 지금은 읽기만 하고 쓰지 않는다. */
wingsLinked?: boolean;
}
export interface AbutParseResult {
abutments: AbutRecord[];
issues: Issue[];
}
const num = (c: Cell) => (c === null || c === undefined || c === '' ? NaN : Number(c));
const isBlank = (c: Cell) => c === null || c === undefined || c === '';
/** 교대 표의 헤더 행 (첫 두 칸). */
const isAbutInfoHeader = (r: Cell[]) => r[0] === '교대명' && r[1] === '형식';
const isAbutIpHeader = (r: Cell[]) => r[0] === '교대명' && r[1] === 'IP점';
const isAbutBearingHeader = (r: Cell[]) => r[0] === '교대명' && r[1] === '받침번호';
/** 헤더 아래 데이터 행, 둘째 칸이 빌 때까지. 교대명 칸이 비면 위 행을 이어받는다. */
function readBlock(grid: Grid, header: number): { cells: Cell[]; row: number }[] {
const out: { cells: Cell[]; row: number }[] = [];
let last: Cell = null;
for (let i = header + 1; i < grid.length; i++) {
const r = grid[i] ?? [];
if (isBlank(r[1])) break;
const name: Cell = isBlank(r[0]) ? last : r[0];
last = name;
out.push({ cells: [name, ...r.slice(1)], row: i + 1 });
}
return out;
}
export function parseAbutments(wb: XLSX.WorkBook, reservedNames: readonly string[] = []): AbutParseResult {
const issues: Issue[] = [];
const info: { cells: Cell[]; row: number; sheet: string }[] = [];
const ips: { cells: Cell[]; row: number; sheet: string }[] = [];
const bearings: { cells: Cell[]; row: number; sheet: string }[] = [];
for (const sheet of wb.SheetNames) {
const grid = XLSX.utils.sheet_to_json<Cell[]>(wb.Sheets[sheet], { header: 1, raw: true, defval: null }) as Grid;
grid.forEach((r, i) => {
const pick = (match: (r: Cell[]) => boolean, into: typeof info) => {
if (!match(r)) return;
for (const rec of readBlock(grid, i)) into.push({ ...rec, sheet });
};
pick(isAbutInfoHeader, info);
pick(isAbutIpHeader, ips);
pick(isAbutBearingHeader, bearings);
});
}
const abutments: AbutRecord[] = [];
const counts = new Map<string, number>();
for (const rec of info) {
const name = String(rec.cells[0] ?? '').trim();
counts.set(name, (counts.get(name) ?? 0) + 1);
}
const reserved = new Set(reservedNames.map((name) => name.trim()));
for (const rec of info) {
const c = rec.cells;
const rawName = String(c[0] ?? '');
const name = rawName.trim();
const where = `${rec.sheet} ${rec.row}행`;
const nameError = abutNameError(rawName);
if (nameError) {
issues.push({ code: 'AP01', level: 'E', pier: name, sheet: rec.sheet, message: `${where}: ${nameError}` });
continue;
}
if ((counts.get(name) ?? 0) > 1 || reserved.has(name)) {
issues.push({ code: 'AP09', level: 'E', pier: name, sheet: rec.sheet, message: `${where}: 구조물명 "${name}"이 중복되었습니다. 교각·교대마다 서로 다른 이름을 사용하세요.` });
continue;
}
const type = String(c[1] ?? '').trim();
if (type !== 'A1' && type !== 'A2') {
issues.push({ code: 'AP02', level: 'E', pier: name, sheet: rec.sheet, message: `${where}: 교대 ${name}의 형식 "${type}"은 A1 또는 A2여야 합니다.` });
continue;
}
const skewDeg = parseDms(String(c[7] ?? ''));
if (skewDeg === null) {
issues.push({ code: 'AP03', level: 'E', pier: name, sheet: rec.sheet, message: `${where}: 교대 ${name}의 skew "${String(c[7] ?? '')}"를 읽지 못했습니다.` });
continue;
}
abutments.push({
name,
sheet: rec.sheet,
type,
seatEl: num(c[2]),
foundationBottomEl: num(c[3]),
groundEl: num(c[4]),
foundation: String(c[5] ?? ''),
pileLengthMm: num(c[6]),
skewDeg,
slopeI: num(c[8]),
ip1: { e: NaN, n: NaN },
ip2: { e: NaN, n: NaN },
ip3: { e: NaN, n: NaN },
bearings: [],
bearingHeightMm: NaN,
});
}
for (const rec of ips) {
const name = String(rec.cells[0] ?? '').trim();
const ab = abutments.find((a) => a.name === name);
if (!ab) {
issues.push({ code: 'AP04', level: 'E', pier: name, sheet: rec.sheet, message: `${rec.sheet} ${rec.row}행: 교대명 ${name}의 교대 정보 행이 없습니다.` });
continue;
}
const point = { e: num(rec.cells[3]), n: num(rec.cells[2]) };
const ipName = String(rec.cells[1] ?? '');
if (ipName === 'IP1') ab.ip1 = point;
else if (ipName === 'IP2') ab.ip2 = point;
else if (ipName === 'IP3') ab.ip3 = point;
}
for (const rec of bearings) {
const c = rec.cells;
const name = String(c[0] ?? '').trim();
const ab = abutments.find((a) => a.name === name);
if (!ab) {
issues.push({ code: 'AP04', level: 'E', pier: name, sheet: rec.sheet, message: `${rec.sheet} ${rec.row}행: 교대명 ${name}의 교대 정보 행이 없습니다.` });
continue;
}
const h = num(c[8]);
if (!isBlank(c[5]) && parseDms(String(c[5])) === null) {
issues.push({ code: 'AP08', level: 'W', pier: ab.name, sheet: rec.sheet, message: `${rec.sheet} ${rec.row}행: 받침 방위각을 읽을 수 없습니다. 예: 75°56'55.9" 형식. 블럭은 교대축에 맞춥니다.` });
}
ab.bearings.push({
no: num(c[1]),
pos: { e: num(c[3]), n: num(c[2]) },
z: num(c[4]),
seatEl: isBlank(c[7]) ? undefined : num(c[7]),
bearingHeightMm: h,
azimuthDeg: (() => {
const deg = isBlank(c[5]) ? null : parseDms(String(c[5]));
return deg === null ? undefined : swapXYAzimuth(deg);
})(),
row: `${rec.sheet} ${rec.row}행`,
});
}
for (const ab of abutments) {
const missing = [ab.ip1, ab.ip2, ab.ip3].some((p) => !Number.isFinite(p.e) || !Number.isFinite(p.n));
if (missing) issues.push({ code: 'AP05', level: 'E', pier: ab.name, sheet: ab.sheet, message: `교대 ${ab.name}: IP1·IP2·IP3 중 빠진 점이 있습니다.` });
if (ab.bearings.length < 2) issues.push({ code: 'AP06', level: 'E', pier: ab.name, sheet: ab.sheet, message: `교대 ${ab.name}: 받침이 2개 이상 필요합니다.` });
const heights = [...new Set(ab.bearings.map((b) => b.bearingHeightMm))];
if (heights.length > 1) issues.push({ code: 'AP07', level: 'W', pier: ab.name, sheet: ab.sheet, message: `교대 ${ab.name}: 받침높이가 받침마다 다릅니다 (${heights.join(', ')} mm). 첫 값을 씁니다.` });
ab.bearingHeightMm = heights[0] ?? NaN;
}
return { abutments: abutments.filter((a) => Number.isFinite(a.ip1.e) && a.bearings.length >= 2), issues };
}
/** The layout input of AA1–AA2 from a parsed 교대 record. */
export function toAbutInput(rec: AbutRecord): AbutInput {
return {
name: rec.name,
type: rec.type,
seatEl: rec.seatEl,
ip1: rec.ip1,
ip2: rec.ip2,
ip3: rec.ip3,
// 계산 방위각을 적용한 받침 (2026-11-06) use that value; the record keeps the workbook value.
bearings: rec.bearings.map((b) => ({ no: b.no, pos: b.pos, z: b.z, seatEl: b.seatEl, azimuthDeg: rec.azimuthApplied?.[String(b.no)] ?? b.azimuthDeg })),
skewDeg: rec.skewDeg,
slopeI: rec.slopeI,
wings: rec.wings,
wingThicknessMm: { left: rec.wingDims?.left?.thicknessMm, right: rec.wingDims?.right?.thicknessMm },
slabWingGapMm: rec.slab?.wingGapMm,
foundationBottomEl: rec.foundationBottomEl,
pileLengthMm: rec.pileLengthMm,
foundationType: rec.foundation,
};
}
+191
View File
@@ -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 };
}
+318
View File
@@ -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,
};
}
+374
View File
@@ -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,
};
}
+58
View File
@@ -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(' · ');
}
+310
View File
@@ -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,
};
}
+35
View File
@@ -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 };
}
+365
View File
@@ -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,
};
}
+512
View File
@@ -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 };
}
+188
View File
@@ -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;
}
+137
View File
@@ -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 };
}
+96
View File
@@ -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),
]);
}
+17
View File
@@ -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;
}
+225
View File
@@ -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;
}
+161
View File
@@ -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) };
}),
};
}
-148
View File
@@ -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 };
});
}
+179
View File
@@ -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),
};
}
+31
View File
@@ -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;
}
+62
View File
@@ -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
View File
@@ -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. */
+2 -2
View File
@@ -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 } {
-61
View File
@@ -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 };
}
-149
View File
@@ -1,149 +0,0 @@
/**
* 도면에서 받침 ELEVATION TABLE 읽기 (승인 2026-10-23). Reads the TEXT entities of a DXF file and places each value
* by position: the header row names the columns (EL.X, h1 … h4, A, B, C1 …), the first column holds the bearing No.
* Pure: works on the text entities (x, y, text) of the drawing, not on the file itself.
*/
import type { DrawingBearing } from './bearing-ref';
export interface DxfText {
x: number;
y: number;
text: string;
}
/** Reads TEXT and MTEXT entities (x, y, string) from DXF text. */
export function readDxfTexts(dxf: string): DxfText[] {
const lines = dxf.split(/\r?\n/);
const pairs: [string, string][] = [];
for (let i = 0; i + 1 < lines.length; i += 2) pairs.push([lines[i].trim(), lines[i + 1].trim()]);
const out: DxfText[] = [];
for (let i = 0; i < pairs.length; i++) {
if (pairs[i][0] !== '0' || (pairs[i][1] !== 'TEXT' && pairs[i][1] !== 'MTEXT')) continue;
let text: string | null = null;
let x = NaN;
let y = NaN;
let j = i + 1;
for (; j < pairs.length && pairs[j][0] !== '0'; j++) {
const [c, v] = pairs[j];
if (c === '1') text = v;
if (c === '10') x = Number(v);
if (c === '20') y = Number(v);
}
if (text !== null && Number.isFinite(x) && Number.isFinite(y)) out.push({ x, y, text });
i = j - 1;
}
return out;
}
const norm = (s: string) => s.replace(/[\s.'"]/g, '').toLowerCase();
const NUMBER = /^-?\d+(\.\d+)?$/;
export interface BearingTableRow {
no: number;
values: Record<string, number>;
}
export interface BearingTableResult {
/** Values per column as read from the drawing (keyed by header name). */
rows: BearingTableRow[];
/** The rows in the form the 받침 입력 uses (EL.X in m, h1 and h2 in mm). */
drawing: DrawingBearing[];
errors: string[];
}
/**
* Reads the bearing ELEVATION TABLE. The header is the text row with "EL.X" (as EL.'X', EL. "X"); the No column is the
* integer column just left of the 교량받침 column; each value sits under its header (x within 150) on the same row (y within 120).
*/
export function parseBearingElevationTable(texts: DxfText[]): BearingTableResult {
const errors: string[] = [];
// The drawing has other "EL.X" labels (leader lines). The table header is the one with h1 and h2 on the same row.
// The header is the EL.X label with the most table column names on its row, to its right (within 2500).
const TABLE_NAMES = ['h1', 'h2', 'h3', 'h4', 'a', 'b', 'c1', 'c2', 'c3', 'c4', 'c5', 'c6', 's1', 's2'];
const rowScore = (t: DxfText) =>
texts.filter((u) => Math.abs(u.y - t.y) <= 60 && u.x > t.x && u.x - t.x <= 2500 && TABLE_NAMES.includes(norm(u.text))).length;
const candidates = texts.filter((t) => norm(t.text) === 'elx');
const best = candidates.map((t) => ({ t, score: rowScore(t) })).sort((a, b) => b.score - a.score)[0];
if (!best || best.score < 2) return { rows: [], drawing: [], errors: ['EL.X 머리글(표의 열 이름과 같은 행)을 찾지 못했습니다.'] };
const elHeader = best.t;
const headerY = elHeader.y;
// Column headers: texts on the header row (within 60 of its y) with a short name (EL.X, h1 … h4, A, B, C1 …).
const headerRow = texts.filter((t) => Math.abs(t.y - headerY) <= 60 && /^(elx|h\d|[a-z]\d?)$/.test(norm(t.text)));
const cols = headerRow.map((t) => ({ key: norm(t.text), x: t.x }));
// The No header is the 'NO' label on the table's header row (the drawing has other 'NO' labels).
const noHeader = texts.find((t) => norm(t.text) === 'no' && Math.abs(t.y - headerY) <= 200);
// The No column: integers 1…10 below the header, within 150 of the No header (or the left-most integer column).
const numbers = texts.filter((t) => NUMBER.test(t.text) && Number.isInteger(Number(t.text)) && Number(t.text) >= 1 && Number(t.text) <= 99 && t.y < headerY - 60);
if (numbers.length === 0) return { rows: [], drawing: [], errors: ['받침 번호 열을 찾지 못했습니다.'] };
const noX = noHeader ? noHeader.x : Math.min(...numbers.map((t) => t.x));
const noCells = numbers.filter((t) => Math.abs(t.x - noX) <= 250).sort((a, b) => b.y - a.y);
const rows: BearingTableRow[] = [];
for (const cell of noCells) {
const values: Record<string, number> = {};
for (const col of cols) {
const hit = texts.find((t) => Math.abs(t.x - col.x) <= 150 && Math.abs(t.y - cell.y) <= 120 && NUMBER.test(t.text));
if (hit) values[col.key] = Number(hit.text);
}
rows.push({ no: Number(cell.text), values });
}
const drawing: DrawingBearing[] = [];
for (const r of rows) {
const elx = r.values.elx;
const h1 = r.values.h1;
const h2 = r.values.h2;
if (elx === undefined || h1 === undefined || h2 === undefined) {
errors.push(`No ${r.no}: EL.X·h1·h2 중 값이 없습니다.`);
continue;
}
drawing.push({ no: r.no, elx, h1, h2, A: r.values.a, B: r.values.b });
}
return { rows, drawing: drawing.sort((a, b) => a.no - b.no), errors };
}
export interface SolplateTableRow {
no: number;
/** 기울기 S (%), negative as drawn. */
S: number;
L: number;
W: number;
/** 중심 두께 T, and the 교각축 직각 (−) and (+) side thicknesses T1, T2 (mm). */
T: number;
T1: number;
T2: number;
}
/**
* 솔플레이트 TABLE (승인 2026-10-23): the header row has NO, S(%), L, W, T, T1, T2; each bearing row gives the values under
* their headers, as in the bearing ELEVATION table.
*/
export function parseSolplateTable(texts: DxfText[]): { rows: SolplateTableRow[]; errors: string[] } {
// The drawing has more than one "S(%)" label: the header is the one with L, W, T, T1 and T2 on its row.
const SOL_NAMES = ['l', 'w', 't', 't1', 't2'];
const solScore = (t: DxfText) => texts.filter((u) => Math.abs(u.y - t.y) <= 60 && SOL_NAMES.includes(norm(u.text))).length;
const sCandidates = texts.filter((t) => norm(t.text) === 's(%)' || norm(t.text) === 's%');
const sBest = sCandidates.map((t) => ({ t, score: solScore(t) })).sort((a, b) => b.score - a.score)[0];
if (!sBest || sBest.score < 5) return { rows: [], errors: ['솔플레이트 TABLE의 머리글(NO, L, W, T, T1, T2)이 모두 있어야 합니다.'] };
const sHeader = sBest.t;
const headerY = sHeader.y;
const headerRow = texts.filter((t) => Math.abs(t.y - headerY) <= 60);
const key = (name: string) => headerRow.find((t) => norm(t.text) === name);
const cols = { S: key('s(%)') ?? key('s%'), L: key('l'), W: key('w'), T: key('t'), T1: key('t1'), T2: key('t2') };
const noHeader = headerRow.find((t) => norm(t.text) === 'no');
if (!cols.L || !cols.W || !cols.T || !cols.T1 || !cols.T2 || !noHeader) {
return { rows: [], errors: ['솔플레이트 TABLE의 머리글(NO, L, W, T, T1, T2)이 모두 있어야 합니다.'] };
}
const noCells = texts.filter((t) => NUMBER.test(t.text) && Number.isInteger(Number(t.text)) && Math.abs(t.x - noHeader.x) <= 250 && t.y < headerY - 60);
const at = (col: DxfText, y: number) => {
const hit = texts.find((t) => Math.abs(t.x - col.x) <= 150 && Math.abs(t.y - y) <= 120 && NUMBER.test(t.text));
return hit ? Number(hit.text) : NaN;
};
const rows: SolplateTableRow[] = [];
const errors: string[] = [];
for (const cell of noCells.sort((a, b) => b.y - a.y)) {
const r = { no: Number(cell.text), S: cols.S ? at(cols.S, cell.y) : NaN, L: at(cols.L, cell.y), W: at(cols.W, cell.y), T: at(cols.T, cell.y), T1: at(cols.T1, cell.y), T2: at(cols.T2, cell.y) };
if (Object.values(r).some((v) => Number.isNaN(v))) errors.push(`No ${r.no}: 솔플레이트 값이 빠졌습니다.`);
else rows.push(r);
}
return { rows: rows.sort((a, b) => a.no - b.no), errors };
}
+86 -29
View File
@@ -1,12 +1,20 @@
import { buildAxis, copingSpan, fromAxis, kinkArc, nearKink, toAxis } from './axis';
import { buildIntegratedBlocks, type IntegratedBlock } from './integrated-block';
import { type BlockRebarSettings, DEFAULT_BLOCK_REBAR } from './rebar/bearing-block';
import { type CopingRebarSettings, DEFAULT_COPING_REBAR, DEFAULT_TOP_SPLICE, type TopSpliceSettings } from './rebar/coping-settings';
import { type CopingRebarSettings, copingDefaults, DEFAULT_TOP_SPLICE, type TopSpliceSettings } from './rebar/coping-settings';
import { type ColumnRebarSettings, DEFAULT_COLUMN_REBAR } from './rebar/column-settings';
import { type FootingRebarSettings, DEFAULT_FOOTING_REBAR } from './rebar/footing-settings';
import { layoutText, resolveRowCounts, rowLayout, type PileGeometry } from './piles';
import type { PierInput, Row } from './types';
import type { PierInput, Row, SolplatePlan } from './types';
import type { BearingPair } from './bearing-pair';
import { type BearingSole, structureSoles } from './sole-plate';
import { type CopingGeom, P1_COPING_GEOM } from './coping-geom';
import { castShape } from './cast-in-place-shape';
/** Default Level width (mm) when no input: T형 2100 (P1), 2·3주형 D + 200 (spec §3, 2주형 DWG 2026-11-06). */
export function defaultLevelWidthMm(type: PierInput['type'], columnDiameterMm: number): number {
return type === 'T형' ? P1_COPING_GEOM.levelWidth : columnDiameterMm + DIM.multiLevelExtra * 1000;
}
/** Dimensions in m. Sources: intent.PR sheet (P1 rules) and spec.md §3 defaults. */
export const DIM = {
@@ -14,8 +22,14 @@ export const DIM = {
copingH1: 1.0,
copingH2: 1.5,
levelWidth: 2.1,
/** Multi-column coping: underside between columns sits this far below Level, with 45° haunches of the same size (spec §4.5, Q3 추천안). */
haunch: 0.3,
/**
* Multi-column coping, 오목형 underside (spec §4.4, 3주형형상배근.md §2.2, 2주형 DWG 2026-11-06):
* from each Level end it rises concaveRise over the horizontal haunch length concaveHaunch, stays flat, and comes back down.
*/
concaveRise: 0.5,
concaveHaunch: 1.5,
/** Multi-column Level width = D + this (spec §3, 2주형 DWG). T형 keeps levelWidth (= D + 100 for P1). */
multiLevelExtra: 0.2,
padThickness: 0.05,
padSize: 1.0,
blockSize: 1.2,
@@ -74,8 +88,10 @@ export interface BlockModel {
padTop: number;
/** Skew (deg, −90…90] of the bearing row against the perpendicular of the stretch axis; the block sides follow the row. */
rowSkewDeg: number;
/** Solplate plan of this bearing (A, B, S, T, margin, h3), when the input gives it. */
plan?: { A: number; B: number; S: number; T: number; margin: number; h3: number };
/** Solplate of this bearing (2026-11-06): S from the girder pair, T per row, T1 on the 오르막 쪽. */
plan: SolplatePlan;
/** Block bottom from the 두부보 상면 slope (before any 교좌면 EL input), for the check against the input (2026-11-03). */
seatCalc: number;
}
/** Skew of a bearing row (deg) from the bearing azimuth and the stretch axis direction u (see {@link BlockModel.rowSkewDeg}). */
@@ -204,6 +220,10 @@ export interface PierModel {
blockHeightBack: number;
frontBackDiffSpread: number;
};
/** Dedicated rebar source; never substitute footing-anchored column inputs. */
castInput?: PierInput;
/** Solplate of every bearing with its S source, pair and warnings (2026-11-06), for the ② card and checks. */
soles: BearingSole[];
}
/**
@@ -228,7 +248,10 @@ export function solveIp4(ip1: Vec2, ip2: Vec2, ip3: Vec2, angleDeg: number): Vec
* Each object carries its stretch k and a local s measured from that stretch's base (k = 0: BP1, k = 1: IP4).
* A bent coping (IP4 angle ≠ 0) puts each footing in its stretch's frame; footings that would cross the kink are not combined.
*/
export function buildPier(input: PierInput): PierModel {
/** Bridge context for one pier: the girder pairs over A1, P1 … Pn, A2 (girderPairs), for the solplate slope S. */
export interface PierContext { pairs?: BearingPair[] }
export function buildPier(input: PierInput, ctx: PierContext = {}): PierModel {
const ip = (name: string) => {
const p = input.ips.find((q) => q.name === name);
if (!p) throw new Error(`IP 누락: ${name}`);
@@ -245,9 +268,9 @@ export function buildPier(input: PierInput): PierModel {
// Coping heights and Level width (m): inputs, with the P1 values when absent (1단계).
const copingH1 = (input.copingH1Mm ?? P1_COPING_GEOM.h1) / 1000;
const copingH2 = (input.copingH2Mm ?? P1_COPING_GEOM.h2) / 1000;
const levelW = (input.levelWidthMm ?? P1_COPING_GEOM.levelWidth) / 1000;
// Column diameter (m): the input, with 2.0 m when absent (승인 2026-10-23).
const colD = (input.columnDiameterMm ?? DIM.columnDiameter * 1000) / 1000;
const levelW = (input.levelWidthMm ?? defaultLevelWidthMm(input.type, colD * 1000)) / 1000;
const planHalfB = B / 2;
const ip1v: Vec2 = { e: ip1.x, n: ip1.y };
const ip2v: Vec2 = { e: ip2.x, n: ip2.y };
@@ -327,7 +350,7 @@ export function buildPier(input: PierInput): PierModel {
const lateralStep = stepSamples.some(s => Math.abs(segmentTop(segments,s)-segmentTop(backSegments,s)) >= DIM.lateralTolerance);
// Columns (spec §4.5). Positions are coping coordinates; a column must stay a Level half-width away from the kink.
const colG = columnPositions(input.type, L);
const colG = columnPositions(input.type, L, input.columnEndOffsetMm, input.columnSpacingMm);
if (kinkG !== null) {
const near = nearKink(axis, colG.map((g) => sStart + g), levelW / 2);
if (near.length) throw new Error('미실시: 꺾임점 근처(Level 반폭 이내)에 기둥이 있습니다. 기둥 위치를 확인하세요.');
@@ -340,7 +363,8 @@ export function buildPier(input: PierInput): PierModel {
const levelEnd = Math.max(...colG) + levelW / 2;
if (levelStart < 0 || levelEnd > L) throw new Error('코핑 길이가 Level 구간보다 짧습니다.');
// Underside from the right end to the left end. Between columns (2·3주형) it drops 300 below Level with 300 × 300 haunches (spec §4.5, Q3).
// Underside from the right end to the left end. Between columns (2·3주형) it is 오목형: from each Level end it rises
// h_r (500) over the horizontal haunch l_h (1500), stays flat, and comes back down (spec §4.4, 3주형형상배근.md §2.2).
const underside: [number, number][] = [[L, endBottom]];
const levelZones = [...colG].sort((a, b) => b - a).map((s) => [s - levelW / 2, s + levelW / 2] as const);
levelZones.forEach(([zoneStart, zoneEnd], k) => {
@@ -348,9 +372,12 @@ export function buildPier(input: PierInput): PierModel {
const next = levelZones[k + 1];
if (!next) return;
const gap = zoneStart - next[1];
if (gap <= 2 * DIM.haunch) throw new Error('기둥 사이 간격이 헌치(300 × 2)보다 좁아 코핑 하면을 만들 수 없습니다.');
const lowerEl = levelBottom - DIM.haunch;
underside.push([zoneStart - DIM.haunch, lowerEl], [next[1] + DIM.haunch, lowerEl], [next[1], levelBottom]);
// RB-M01: no flat part left between the two haunches.
if (gap <= 2 * DIM.concaveHaunch + 1e-9) {
throw new Error(`기둥 사이 간격 ${Math.round(gap * 1000)} mm가 오목형 헌치(${DIM.concaveHaunch * 1000} × 2)보다 짧아 코핑 하면을 만들 수 없습니다 (RB-M01).`);
}
const raisedEl = levelBottom + DIM.concaveRise;
underside.push([zoneStart - DIM.concaveHaunch, raisedEl], [next[1] + DIM.concaveHaunch, raisedEl], [next[1], levelBottom]);
});
// Elevation outline in coping coordinates for one row of tops, closed by the shared underside.
@@ -391,7 +418,9 @@ export function buildPier(input: PierInput): PierModel {
}
}
const footingTop = input.foundationType === '현장타설말뚝' ? input.footingBottomEl : input.footingBottomEl + fd.H;
const cast = castShape(input);
if (input.foundationType === '현장타설말뚝' && (!Number.isFinite(cast.pileTopEl) || cast.pileTopEl >= levelBottom || !Number.isFinite(input.pileLengthMm) || input.pileLengthMm <= 0)) throw new Error('현장타설말뚝은 지반고가 코핑 Level 하면보다 낮고 말뚝 길이가 양수여야 합니다.');
const footingTop = input.foundationType === '현장타설말뚝' ? cast.pileTopEl : input.footingBottomEl + fd.H;
const columns: ColumnModel[] = colG.map((g, i) => ({
s: g - base[colK[i]],
k: colK[i],
@@ -404,11 +433,11 @@ export function buildPier(input: PierInput): PierModel {
const blindings: BlindingModel[] = [];
const piles: PileModel[] = [];
const pileLength = input.pileLengthMm / 1000;
const pileTip = input.footingBottomEl - pileLength;
const pileTip = input.foundationType === '현장타설말뚝' ? cast.pileBottomEl : input.footingBottomEl - pileLength;
if (input.foundationType === '현장타설말뚝') {
columns.forEach((c) => {
piles.push({ s: c.s, k: c.k, n: 0, top: input.footingBottomEl, bottom: pileTip, diameter: colD });
piles.push({ s: c.s, k: c.k, n: 0, top: cast.pileTopEl, bottom: pileTip, diameter: colD });
});
} else {
for (const f of footingSets) {
@@ -430,21 +459,37 @@ export function buildPier(input: PierInput): PierModel {
}
const blocks: BlockModel[] = [];
const bearingAz = new Map(input.bearings.map((r) => [r.no, r.azimuthDeg]));
// 계산 방위각을 적용한 받침 (2026-11-06): that value turns the block, bearing and solplate; the workbook value stays in input.
const azimuthOf = (r: { no: number; azimuthDeg: number }) => input.azimuthApplied?.[String(r.no)] ?? r.azimuthDeg;
const bearingAz = new Map(input.bearings.map((r) => [r.no, azimuthOf(r)]));
const skewOf = (no: number, k: number) => rowSkewDeg(bearingAz.get(no) ?? NaN, axis.segments[k].u);
// Solplate plans from the input (승인 2026-10-23); the 공통 규칙 default is T 30, margin 100, h3 181.
const rules = input.solplate ?? { T: 30, margin: 100, h3: 181 };
const planOf = (no: number) => {
const p = input.bearingPlans?.find((q) => q.no === no);
return p ? { A: p.A, B: p.B, S: p.S, T: rules.T, margin: rules.margin, h3: rules.h3 } : undefined;
// Solplate of every bearing (결정 2026-11-06): S from the girder pair (ctx.pairs over the whole bridge), else the
// 종단경사 input, else the other row; T per row; the girder underside stays, so the block top drops by T − T_ref.
const soles = new Map(structureSoles(input.name, input.bearings.map((b) => ({ no: b.no, row: b.row, azimuthDeg: azimuthOf(b), inputAzimuthDeg: b.azimuthDeg })),
input.sole, ctx.pairs ?? [], input.longitudinalSlope === undefined ? undefined : input.longitudinalSlope * 100).map((s) => [s.no, s]));
const planOf = (no: number, k: number, skewDeg: number): SolplatePlan => {
const s = soles.get(no)!;
// The block's c axis in (E, N): (−sin d)·u + (cos d)·nrm; T1 goes to the end of c that points uphill.
const { u, nrm } = axis.segments[k];
const d = (skewDeg * Math.PI) / 180;
const c = { e: -Math.sin(d) * u.e + Math.cos(d) * nrm.e, n: -Math.sin(d) * u.n + Math.cos(d) * nrm.n };
const t1Side = s.uphill && c.e * s.uphill.e + c.n * s.uphill.n > 0 ? 1 : -1;
return { L: s.L, W: s.W, slopePct: s.slopePct, T: s.T, T1: s.T1, T2: s.T2, h3: s.h3, t1Side };
};
const dropOf = (no: number) => soles.get(no)!.dropMm / 1000;
// 교좌면 EL (2026-11-03): when the input gives it, the block bottom is that EL; otherwise the 두부보 상면 slope.
const seatOf = new Map(input.bearings.map((r) => [r.no, r.seatEl]));
frontPlaced.forEach((b) => {
const bottom = segmentTop(segments, b.g);
blocks.push({ no: b.no, row: '전열', k: b.k, s: b.s, g: b.g, n: b.n, bottom, top: b.z - DIM.padThickness, padTop: b.z, rowSkewDeg: skewOf(b.no, b.k), plan: planOf(b.no) });
const seatCalc = segmentTop(segments, b.g);
const bottom = seatOf.get(b.no) ?? seatCalc;
const skew = skewOf(b.no, b.k), drop = dropOf(b.no);
blocks.push({ no: b.no, row: '전열', k: b.k, s: b.s, g: b.g, n: b.n, bottom, top: b.z - DIM.padThickness - drop, padTop: b.z - drop, rowSkewDeg: skew, plan: planOf(b.no, b.k, skew), seatCalc });
});
backPlaced.forEach((b) => {
const bottom = b.z - h2;
blocks.push({ no: b.no, row: '후열', k: b.k, s: b.s, g: b.g, n: b.n, bottom, top: b.z - DIM.padThickness, padTop: b.z, rowSkewDeg: skewOf(b.no, b.k), plan: planOf(b.no) });
const seatCalc = b.z - h2;
const bottom = seatOf.get(b.no) ?? seatCalc;
const skew = skewOf(b.no, b.k), drop = dropOf(b.no);
blocks.push({ no: b.no, row: '후열', k: b.k, s: b.s, g: b.g, n: b.n, bottom, top: b.z - DIM.padThickness - drop, padTop: b.z - drop, rowSkewDeg: skew, plan: planOf(b.no, b.k, skew), seatCalc });
});
const blockHeightFront = blocks.find((b) => b.row === '전열')!.top - blocks.find((b) => b.row === '전열')!.bottom;
@@ -484,7 +529,7 @@ export function buildPier(input: PierInput): PierModel {
blocks,
integratedBlocks: buildIntegratedBlocks(blocks, input.bearingLayout === '일체'),
blockRebar: { ...DEFAULT_BLOCK_REBAR, ...input.blockRebar },
copingRebar: { ...DEFAULT_COPING_REBAR, ...input.copingRebar },
copingRebar: { ...copingDefaults(columns.length), ...input.copingRebar },
topSplice: { ...DEFAULT_TOP_SPLICE, ...input.topSplice },
columnRebar: { ...DEFAULT_COLUMN_REBAR, ...input.columnRebar },
footingRebar: { ...DEFAULT_FOOTING_REBAR, ...input.footingRebar },
@@ -495,7 +540,7 @@ export function buildPier(input: PierInput): PierModel {
groundEl: input.groundEl,
derived: {
columnHeight: levelBottom - columns[0].bottom,
soilCover: input.groundEl - (input.foundationType === '현장타설말뚝' ? input.footingBottomEl : footingTop),
soilCover: input.foundationType === '현장타설말뚝' ? 0 : input.groundEl - footingTop,
pileCount: piles.length,
pileLayout: input.foundationType === '말뚝기초' ? layoutText(resolveRowCounts(input.pileRows, pileGeometry(footings[0].length, fd.W)), pileGeometry(footings[0].length, fd.W)) : null,
pileTipEl: piles.length ? pileTip : null,
@@ -503,11 +548,23 @@ export function buildPier(input: PierInput): PierModel {
blockHeightBack,
frontBackDiffSpread,
},
soles: [...soles.values()],
...(input.foundationType === '현장타설말뚝' ? { castInput: { ...input } } : {}),
};
}
export function columnPositions(type: PierInput['type'], L: number): number[] {
/**
* Column centres along the coping (m). spec §4.5: L/5 · (L/2) · 4L/5. For 2·3주형 the 끝 여유 e_c and 간격 s_c may be typed
* (Q-M10 미결, 3주형형상배근.md §2.1): e_c + k·s_c, s_c defaulting to the even spacing between e_c and L − e_c.
*/
export function columnPositions(type: PierInput['type'], L: number, endOffsetMm?: number, spacingMm?: number): number[] {
if (type === 'T형') return [L / 2];
const n = type === '2주형' ? 2 : 3;
if (endOffsetMm !== undefined) {
const e = endOffsetMm / 1000;
const step = spacingMm !== undefined ? spacingMm / 1000 : (L - 2 * e) / (n - 1);
return Array.from({ length: n }, (_, k) => e + k * step);
}
if (type === '2주형') return [L / 5, L - L / 5];
return [L / 5, L / 2, L - L / 5];
}
+14 -6
View File
@@ -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)) {
+2
View File
@@ -186,5 +186,7 @@ function toBearing(c: Cell[]): Bearing {
z: Number(c[4]),
azimuthDeg: swapXYAzimuth(parseDms(String(c[5] ?? '')) ?? NaN),
row: String(c[6]) as Row,
// 교좌면_EL (m), optional: blank = not given, so the model keeps the 두부보 상면 slope.
seatEl: c[7] === undefined || c[7] === null || c[7] === '' ? undefined : Number(c[7]),
};
}
+54
View File
@@ -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
View File
@@ -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)}의 값이 맞지 않습니다.`);
}
}
});
}
+124
View File
@@ -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;
}
+234
View File
@@ -0,0 +1,234 @@
import type { PierModel } from '../geometry';
import { castLowerZone, castSocketDiameterMm } from '../cast-in-place-shape';
import { castRebarDefaults, castSettingsIssues, type CastRebarPart } from './cast-in-place';
import { DEFAULT_MATERIAL, developmentLength, hookLength, spliceLength, type Material } from './common';
export type CastPoint = [number, number, number];
/** One physical piece. Coordinates use the existing rebar scene frame, in metres. */
export interface CastBar {
name: string;
member: 'column' | 'pile';
kind: 'main' | 'tie' | 'crosstie' | 'coupler';
k: number;
pts: CastPoint[];
diameterMm: number;
gradeMpa: number;
lengthMm: number;
note: string;
columnIndex: number;
}
export interface CastLayout { bars: CastBar[]; missing: string[] }
/** Shared by the renderer, drawings and quantities; no meshes or UI state here. */
export function castRebarLayout(model: PierModel, material: Material = DEFAULT_MATERIAL): CastLayout {
const bars: CastBar[] = [];
const missing = new Set<string>();
if (!model.castInput) return { bars, missing: [] };
const input = model.castInput;
const saved = castRebarDefaults(input, material);
const issues = castSettingsIssues(saved, input.pileLengthMm, model.columnDiameterMm);
if (issues.length) return { bars, missing: issues };
const parts = ['column', 'pile'] as const;
const names = { column: '기둥', pile: '말뚝' };
const joint = model.groundEl;
const D = model.columnDiameterMm;
const pitch = model.columnRebar.ctcCoupler;
const stagger = model.columnRebar.dzCp2;
const mode = saved.connection!.mode;
const couplingLength = saved.connection!.couplerLengthMm;
const lap = saved.connection!.lapLengthMm;
let owner = 0;
const add = (bar: Omit<CastBar, 'columnIndex'>) => {
if (bar.lengthMm > 0 && Number.isFinite(bar.lengthMm)) bars.push({ ...bar, columnIndex: owner });
};
// The pile cage follows the rock socket D_s (현장타설말뚝철근.md §5, Q-PL3 결정 2026-11-06); the column cage follows D.
const section = { column: D, pile: castSocketDiameterMm({ ...input, columnDiameterMm: D }) };
const radii = (p: CastRebarPart, diameter: number) => {
const outer = diameter / 2 - p.coverMm! - (p.coverReference === 'clear' ? p.tieDiameterMm! + p.mainDiameterMm! / 2 : 0);
return { outer, inner: outer - (p.mainDiameterMm! + p.innerMainDiameterMm!) / 2, tie: outer + (p.mainDiameterMm! + p.tieDiameterMm!) / 2 };
};
const r = { column: radii(saved.column!, section.column), pile: radii(saved.pile!, section.pile) };
const aligned = (['outer', 'inner'] as const).every((layer, i) => {
const count = i ? 'innerMainCount' : 'mainCount';
const diameter = i ? 'innerMainDiameterMm' : 'mainDiameterMm';
return saved.column![count] === saved.pile![count] && saved.column![diameter] === saved.pile![diameter]
&& saved.column!.mainGradeMpa === saved.pile!.mainGradeMpa && Math.abs(r.column[layer] - r.pile[layer]) < 0.01;
});
// A lap joins cages of different radius (비접촉 겹침, RB-PL03); a coupler or a continuous bar needs the same bars on one circle.
if (!aligned && mode !== 'lap') missing.add('접합부 주철근 본수·규격·강도·축 위치 불일치 · 연결 미계산');
const connected = mode === 'lap' ? !!lap : aligned && (mode === 'continuous' || mode === 'coupler' && !!couplingLength);
if (!mode || mode === 'coupler' && !couplingLength || mode === 'lap' && !lap) missing.add('접합부 연결 방식 또는 연결 길이 미입력');
if (connected && mode === 'lap') {
// RB-PL07: the two circles of a non-contact lap at most min(l_s/5, 150) apart.
const gap = Math.abs(r.column.outer - r.pile.outer);
if (gap > Math.min(lap! / 5, 150) + 1e-6) missing.add(`RB-PL07 기둥–말뚝 겹침 두 철근 원 간격 ${Math.round(gap)} mm > min(l_s/5, 150) · 비접촉 겹침 한계 초과`);
}
missing.add('말뚝 저부 P6 검토안 미적용');
model.columns.forEach((column, columnIndex) => {
owner = columnIndex;
const pile = model.piles[columnIndex];
if (!pile) { missing.add('기둥에 대응하는 현장타설말뚝 없음'); return; }
const g = column.s + model.stretches[column.k].base;
const segment = model.segments.find(s => g >= s.s0 && g <= s.s1) ?? model.segments[0];
const c3 = model.copingRefTop - 0.127 - 2 * model.copingRebar.mainLayerGap / 1000;
const point = (radius: number, angle: number, height: number): CastPoint => [column.s + radius / 1000 * Math.cos(angle), height, radius / 1000 * Math.sin(angle)];
const bottomCover = saved.pile!.endCoverMm!;
const mainRadius = Math.max(saved.pile!.mainDiameterMm!, saved.pile!.innerMainDiameterMm!) / 2;
const cageBottom = saved.pile!.cageLengthMm === undefined ? pile.bottom + (bottomCover + mainRadius) / 1000 : joint - saved.pile!.cageLengthMm / 1000;
if (cageBottom < pile.bottom + (bottomCover + mainRadius) / 1000 - 1e-8 || cageBottom >= joint) {
missing.add('말뚝 철근망 하단이 선단 피복 또는 말뚝 범위를 벗어남');
}
const pileValid = cageBottom >= pile.bottom + (bottomCover + mainRadius) / 1000 - 1e-8 && cageBottom < joint;
const ext = saved.column!.topEmbedmentMm;
const tops = [ext === undefined ? c3 - 0.1 : column.top + ext / 1000,
column.top + (ext ?? developmentLength(saved.column!.innerMainDiameterMm!, saved.column!.mainGradeMpa!, saved.column!.concreteStrengthMpa!)) / 1000];
const validRadii = (part: 'column' | 'pile') => {
const p = saved[part]!;
return r[part].inner > p.innerMainDiameterMm! / 2 && r[part].tie + p.tieDiameterMm! / 2 <= section[part] / 2 + 1e-8;
};
for (const part of parts) {
if (!validRadii(part)) { missing.add(`${names[part]} 철근 단면·피복 범위 오류`); continue; }
if (part === 'pile' && !pileValid) continue;
const p = saved[part]!;
if (p.mainCount! > 2000 || p.innerMainCount! > 2000) { missing.add(`${names[part]} 주철근 본수 표시 한도 초과`); continue; }
for (const [layerIndex, layer] of (['outer', 'inner'] as const).entries()) {
const diameter = layerIndex ? p.innerMainDiameterMm! : p.mainDiameterMm!;
const count = layerIndex ? p.innerMainCount! : p.mainCount!;
const top = tops[layerIndex];
if (part === 'column' && (top <= joint || top + diameter / 2000 > segment.top)) {
missing.add(`${names[part]} P${layerIndex + 1} 상단 정착이 코핑 범위를 벗어남`); continue;
}
const offset = layerIndex ? stagger / 1000 : 0;
const boundary = connected && mode === 'coupler' ? joint + offset : joint;
// Lap: the column bars run down past ground by l_s into the pile (현장타설말뚝철근.md §5).
const start = part === 'column' ? (connected && mode === 'lap' ? joint - lap! / 1000 : boundary) : cageBottom;
const end = part === 'column' ? top : boundary;
const combined = connected && mode === 'continuous' && pileValid && validRadii('pile');
if (combined) missing.add('연속 주철근 제작·시공 이음 상세 미확정');
if (combined && part === 'pile') continue;
const lo = combined ? cageBottom : start;
if (end <= lo) { missing.add(`${names[part]} 주철근 유효 길이 없음`); continue; }
const cuts: number[] = [];
// Periodic column joints reuse the existing approved pitch and stagger; no footing reference.
if ((mode === 'coupler' || mode === 'lap') && couplingLength && pitch > 0) {
for (let h = joint + offset + pitch / 1000; h < end - 2.5; h += pitch / 1000) if (h > lo) cuts.push(h);
for (let h = joint + offset - pitch / 1000; h > lo + couplingLength / 1000; h -= pitch / 1000) if (h < end) cuts.push(h);
}
const levels = [lo, ...cuts.sort((a, b) => a - b), end];
for (let i = 0; i < count; i++) {
const angle = 2 * Math.PI * i / count;
const name = `${combined ? '기둥·말뚝' : names[part]}-P${layerIndex + 1}`;
levels.slice(1).forEach((h, j) => add({ name, member: part, kind: 'main', k: column.k,
pts: [point(r[part][layer], angle, levels[j]), point(r[part][layer], angle, h)], diameterMm: diameter,
gradeMpa: p.mainGradeMpa!, lengthMm: (h - levels[j]) * 1000, note: combined ? '연속 주철근 · 기초 갈고리 없음' : '현장타설 전용 주철근 · 마디별 집계' }));
const coupler = (h: number, label: string) => {
if (couplingLength! > pitch || h - couplingLength! / 2000 < cageBottom || h + couplingLength! / 2000 > top) {
missing.add('주철근 커플러 길이가 배치 가능 범위를 벗어남'); return;
}
if (Math.abs(diameter - 31.8) > 0.05) missing.add('주철근 커플러 외경은 표시 가정 · 실제 규격 확인 필요');
add({ name: label, member: part, kind: 'coupler', k: column.k,
pts: [point(r[part][layer], angle, h - couplingLength! / 2000), point(r[part][layer], angle, h + couplingLength! / 2000)],
diameterMm: diameter * 60 / 31.8, gradeMpa: p.mainGradeMpa!, lengthMm: couplingLength!, note: '주철근 중심 맞댐 · 외경은 기존 D32용 비율의 표시 가정' });
};
for (const h of cuts) coupler(h, `${names[part]}-P${layerIndex + 1} 커플러`);
if (part === 'column' && connected && mode === 'coupler' && pileValid && validRadii('pile')) coupler(boundary, `접합-P${layerIndex + 1} 커플러`);
}
}
const upper = part === 'column' ? (column.top + segment.top) / 2 : joint;
const lower = part === 'column' ? joint : cageBottom + p.tieDiameterMm! / 2000;
let zone: [number, number] | undefined;
if (part === 'column' && p.endZoneLengthMm !== undefined) zone = [Math.max(lower, column.top - p.endZoneLengthMm / 1000), upper];
if (part === 'pile') {
const geo = castLowerZone(input);
const bottom = joint - (input.castInPlaceShape?.betaLengthMm ?? 9000) / 1000 - 3 * D / 1000;
if (p.endZoneLengthMm !== undefined) zone = [bottom, bottom + p.endZoneLengthMm / 1000];
else if (geo?.valid) zone = [geo.bottomEl, geo.topEl];
else if (geo) missing.add('말뚝 하단 단부구역 경계 역전');
}
if (!zone) missing.add(`${names[part]} 단부구간 경계 미입력 · 일반 횡철근만 계산`);
if (zone && (zone[0] < lower || zone[1] > upper || zone[1] <= zone[0])) {
missing.add(`${names[part]} 단부구간이 철근망 범위를 벗어남`); zone = undefined;
}
const radius = r[part].tie;
const tieNote = '일반/단부 경계에서 구간별 반간격 시작 · 겹침 원형 이음';
// Pile hoops inside the lap wrap the outer column bars (현장타설말뚝철근.md §6).
const lapBottom = part === 'pile' && connected && mode === 'lap' ? joint - lap! / 1000 : Infinity;
const tieRadius = (h: number) => (h >= lapBottom - 1e-9 ? r.column.tie : radius);
const intervals: { from: number; to: number; step: number; mark: string }[] = [];
const ordinary = (from: number, to: number) => { if (to > from) intervals.push({ from, to, step: p.tieSpacingMm!, mark: 'P3' }); };
const zones = zone ? [zone] : [];
// A column upper-end region that crosses ground continues on the pile side, with pile-section properties.
const columnEnd = saved.column!.endZoneLengthMm;
if (part === 'pile' && columnEnd !== undefined && column.top - columnEnd / 1000 < joint) {
zones.push([Math.max(lower, column.top - columnEnd / 1000), upper]);
}
zones.sort((a, b) => a[0] - b[0]);
const merged: [number, number][] = [];
for (const next of zones) {
const last = merged.at(-1);
if (last && next[0] <= last[1]) last[1] = Math.max(last[1], next[1]);
else merged.push([...next]);
}
let cursor = lower;
for (const dense of merged) {
ordinary(cursor, dense[0]); intervals.push({ from: dense[0], to: dense[1], step: p.endZoneSpacingMm!, mark: 'P5' }); cursor = dense[1];
}
ordinary(cursor, upper);
for (const interval of intervals) {
const { from, to, step, mark } = interval;
if (step <= 0 || !Number.isFinite(step)) continue;
if ((to - from) * 1000 / step > 5000) { missing.add(`${names[part]} 횡철근 개수 과다 · 간격 확인 필요`); continue; }
const lapLength = mark === 'P5' ? 0 : spliceLength(p.tieDiameterMm!, p.tieGradeMpa!, p.concreteStrengthMpa!);
if (lapLength >= 2 * Math.PI * radius) { missing.add(`${names[part]} 횡철근 겹침길이가 원주 이상 · 재료 입력 확인 필요`); continue; }
const makeTie = (pts: CastPoint[], lengthMm: number, note: string) => add({ name: `${names[part]}-${mark}${p.tieKind === 'spiral' ? ' 나선' : ''}`,
member: part, kind: 'tie', k: column.k, pts, diameterMm: p.tieDiameterMm!, gradeMpa: p.tieGradeMpa!, lengthMm, note });
const a = from + step / 2000;
const b = to - p.tieDiameterMm! / 2000;
if (b <= a) { missing.add(`${names[part]} 횡철근 구간 길이 부족`); continue; }
if (p.tieKind === 'spiral') {
const turns = (b - a) * 1000 / step;
const n = Math.ceil(turns * 32);
const pts = Array.from({ length: n + 1 }, (_, j) => point(radius, 2 * Math.PI * turns * j / n, a + (b - a) * j / n));
makeTie(pts, Math.hypot(2 * Math.PI * radius * turns, (b - a) * 1000), '나선 중심선 · 단부 정착·구간 전이 상세 미확정');
missing.add(`${names[part]} 나선철근 단부 정착·구간 전이 미확정`);
} else {
for (let h = a, index = 0; h <= b + 1e-9; h += step / 1000, index++) {
const rad = tieRadius(h);
const angleSpan = 2 * Math.PI + lapLength / rad;
const pts = Array.from({ length: 65 }, (_, j) => point(rad, index % 2 * Math.PI + angleSpan * j / 64, h));
makeTie(pts, 2 * Math.PI * rad + lapLength, mark === 'P5' ? '단부 원형 띠 · 기존 H19용 커플러 폐합' : rad !== radius ? `${tieNote} · 겹침이음 구간, 바깥 기둥 주철근을 감쌈` : tieNote);
if (mark === 'P5') {
const angle = index % 2 * Math.PI + Math.PI / p.mainCount!;
const half = 50 / rad;
add({ name: `${names[part]}-P5 띠철근 커플러`, member: part, kind: 'coupler', k: column.k,
pts: [point(rad, angle - half, h), point(rad, angle + half, h)], diameterMm: 40,
gradeMpa: p.tieGradeMpa!, lengthMm: 100, note: '기존 H19용 L100·Ø40 폐합 상세' });
if (Math.abs(p.tieDiameterMm! - 19.1) > 0.05) missing.add(`${names[part]} 단부 띠철근 커플러 규격 확인 필요`);
}
}
}
}
// Only the column crossties are reused. Pile-bottom U bars remain a separate pending design.
if (part === 'column') {
if (p.mainCount! < 8 || p.mainCount! % 2) { missing.add('기둥 P6 대칭쌍 배치에는 8본 이상 짝수 외측 주철근이 필요'); continue; }
const diameter = 15.9;
const hook = Math.round(hookLength('tie90', diameter));
for (let h = lower + Math.max(p.tieSpacingMm! / 2, hook + diameter / 2) / 1000; h + 3.5 * diameter / 1000 <= upper; h += model.columnRebar.ctcP6 / 1000) {
for (let j = 0; j < 4; j++) {
const index = Math.round(j * p.mainCount! / 8);
const angle = index * 2 * Math.PI / p.mainCount!;
const y = h + j * diameter / 1000;
const a = point(radius, angle, y), b = point(radius, angle + Math.PI, y);
add({ name: '기둥-P6 크로스타이', member: part, kind: 'crosstie', k: column.k,
pts: [[a[0], a[1] - hook / 1000, a[2]], a, b, [b[0], b[1] - hook / 1000, b[2]]],
diameterMm: diameter, gradeMpa: material.grade.H, lengthMm: 2 * radius + 2 * hook,
note: '기둥 크로스타이 @기존 P6간격 · 말뚝 저부 P6와 별개' });
}
}
missing.add('기둥 P6 시작단·적층·굽힘 상세 확인 필요');
}
}
});
return { bars, missing: [...missing] };
}
+1
View File
@@ -5,6 +5,7 @@ export type RebarMemberKey = 'bearing' | 'coping' | 'column' | 'footing' | 'othe
/** Member of a bar from its 철근표 name (C = coping, P = column, B = footing, S = bearing block, 커플러 = column). */
export function memberOfBar(name: string): RebarMemberKey {
if (/^(기둥|말뚝|접합)/.test(name)) return 'column';
if (name.includes('커플러') || /^P\d/.test(name)) return 'column';
if (/^C\d/.test(name)) return 'coping';
if (/^B\d/.test(name)) return 'footing';
+24 -9
View File
@@ -39,6 +39,8 @@ export interface ColumnInput {
/** P4 구간 깊이 비율 r_P4. */
rP4: number;
ctcP6: number;
/** 다주형 삽도의 상·하단부 합집합. 번호는 기존 역할 대응을 유지한다. */
bothEndZones?: boolean;
}
/** 예제 운곡교 P1 (기둥철근.md §1.2 기본값). */
@@ -47,18 +49,23 @@ export interface ColumnInput {
* h_cop = 계단 상면(기둥 중심 구간의 상면) − Level 하면 (2.644 on P1, 43.555 − 40.911). Only these two follow the model;
* the other values stay as in COLUMN_P1.
*/
export function columnInputFor(model: PierModel, base: ColumnInput = COLUMN_P1): ColumnInput {
const col = model.columns[0];
const seg = model.segments.find((g) => col.s >= g.s0 - 1e-9 && col.s <= g.s1 + 1e-9) ?? model.segments[0];
const Hc = Math.round((model.levelBottom - (model.footings[0]?.top ?? col.bottom)) * 1000 * 10) / 10;
const hcop = Math.round((seg.top - model.levelBottom) * 1000 * 10) / 10;
export function columnInputFor(model: PierModel, base: ColumnInput = COLUMN_P1, columnIndex = 0): ColumnInput {
// No footing (현장타설말뚝, merged 2026-11-06 with origin's guard): Hc still runs to the column bottom (= 지반고) and Hf is 0.
// The cast-in-place rebar itself comes from cast-layout.ts; callers only use the footing rules when a footing exists.
const col = model.columns[columnIndex];
const globalS = col.s + model.stretches[col.k].base;
const seg = model.segments.find((g) => globalS >= g.s0 - 1e-9 && globalS <= g.s1 + 1e-9) ?? model.segments[0];
const footing = model.footings.find(f => f.k === col.k && Math.abs(f.s - col.s) <= f.length / 2);
const Hc = Math.round((col.top - col.bottom) * 1000 * 10) / 10;
const hcop = Math.round((seg.top - col.top) * 1000 * 10) / 10;
// C3 (상면 3단) = the lowest coping top −127 (C1 cover) −2·s_lay (상면 주철근 단 간격 input); P1 was 43.170 at 57.2.
// P1 top = C3 − 100 (계산 2026-10-23), so e_P1 = step top − (C3 − 0.1) = 485 on P1.
const c3 = model.copingRefTop - 0.127 - 2 * (model.copingRebar.mainLayerGap / 1000);
const eP1 = Math.round((seg.top - (c3 - 0.1)) * 1000);
// e_top: the coping middle level (Level 하면 + 계단 상면)/2 is the top of P3 and P6 (승인 2026-10-23).
const eTop = Math.round((seg.top - (model.levelBottom + seg.top) / 2) * 1000);
return { ...base, ...model.columnRebar, footing: model.footingRebar, Hc, hcop, eP1, eTop, D: model.columnDiameterMm };
const eTop = Math.round((seg.top - (col.top + seg.top) / 2) * 10000) / 10;
return { ...base, ...model.columnRebar, footing: model.footingRebar, Hc, hcop, eP1, eTop, D: model.columnDiameterMm,
Hf: footing ? (footing.top - footing.bottom) * 1000 : 0, bothEndZones: model.columns.length > 1 };
}
export const COLUMN_P1: ColumnInput = {
@@ -142,9 +149,17 @@ export function columnRebar(x: ColumnInput = COLUMN_P1, m: Material = DEFAULT_MA
const lEmin = Math.max(x.D, zTopT / 6, 450);
const n5 = Math.floor(lEmin / x.ctcP5);
const lE = Math.max(lEmin, n5 * x.ctcP5 + x.ctcP5 / 2);
const p3 = Array.from({ length: Math.floor((zTopT - lE) / x.ctcP3) + 1 }, (_, k) => lE + k * x.ctcP3);
let p3 = Array.from({ length: Math.max(0, Math.floor((zTopT - lE) / x.ctcP3) + 1) }, (_, k) => lE + k * x.ctcP3);
const n5all = Math.floor(lE / x.ctcP5);
const p5 = Array.from({ length: n5all }, (_, j) => lE - (j + 1) * x.ctcP5);
let p5 = Array.from({ length: n5all }, (_, j) => lE - (j + 1) * x.ctcP5);
if (x.bothEndZones) {
// Drawing priority: at least D below the coping, plus half the coping height above it.
// Existing lower-end minimum is retained. Overlapping ends become one dense interval.
const upperStart = Math.max(0, x.Hc - lE);
const intervals = upperStart <= lE ? [[0, zTopT]] : [[0, lE], [upperStart, zTopT]];
p5 = intervals.flatMap(([a, b]) => Array.from({ length: Math.max(0, Math.floor((b - a) / x.ctcP5)) }, (_, i) => a + x.ctcP5 / 2 + i * x.ctcP5));
p3 = upperStart <= lE ? [] : Array.from({ length: Math.max(0, Math.floor((upperStart - lE) / x.ctcP3)) }, (_, i) => lE + x.ctcP3 / 2 + i * x.ctcP3);
}
const z5last = lE - n5all * x.ctcP5;
const m4 = Math.floor((z5last + x.rP4 * x.Hf) / x.ctcP4);
const p4All = Array.from({ length: m4 }, (_, j) => z5last - (j + 1) * x.ctcP4);
+10 -4
View File
@@ -84,15 +84,15 @@ export function materialFrom(raw: unknown): Material {
}
/** Members that can take their own fck on ② (부재 카드). Other members always use the 공통 fck. */
export type FckMember = 'column' | 'coping' | 'footing';
export type FckMember = 'column' | 'coping' | 'footing' | 'abutFooting' | 'abutBody';
/** The members of {@link FckMember}, for the ② cards. */
export const FCK_MEMBERS: readonly FckMember[] = ['column', 'coping', 'footing'];
export const FCK_MEMBERS: readonly FckMember[] = ['column', 'coping', 'footing', 'abutFooting', 'abutBody'];
/** Per-member fck (MPa). A member without an entry uses the ③ 공통 설정 fck. */
export type FckByMember = Partial<Record<FckMember, number>>;
/** 교각도 기준 부재별 기본 fck (MPa). 되돌리기는 이 값으로 돌아가고, 이 값과 다르면 주황색으로 표시합니다. */
export const IMPORTED_FCK: Record<FckMember, number> = { column: 40, coping: 40, footing: 27 };
/** 교각도 · 교대도 기준 부재별 기본 fck (MPa). 되돌리기는 이 값으로 돌아가고, 이 값과 다르면 주황색으로 표시합니다. 교대 저판 27, 교대 벽체 · 흉벽 · 날개벽 · 접속슬래브 35 (spec_abut §6, 2026-11-03). */
export const IMPORTED_FCK: Record<FckMember, number> = { column: 40, coping: 40, footing: 27, abutFooting: 27, abutBody: 35 };
/** True when the member's fck is set and differs from its imported default. An unset member is not changed. */
export function fckChanged(byMember: FckByMember, member: FckMember): boolean {
@@ -137,10 +137,16 @@ export function spliceLength(db: number, fy: number, fck: number, lambda = MATER
/** Nominal diameters d (mm) of the bar sizes used in the documents (코핑철근.md, 기둥철근.md). */
export const NOMINAL_DIAMETER = {
D13: 12.7,
/** 교대 벽체 W7 · W8 (교대벽체철근.md §1.2, 2026-11-04). */
H13: 12.7,
H16: 15.9,
H19: 19.1,
D22: 22.2,
/** 교대 저판 B2·B4 (교대확대기초철근.md §1.3, 2026-11-04). */
H22: 22.2,
H25: 25.4,
/** 교대 흉벽 다웰 P10 (교대흉벽철근.md §1.2, 2026-11-04). */
D25: 25.4,
H29: 28.6,
S32: 31.8,
D35: 34.9,
+9 -2
View File
@@ -44,6 +44,13 @@ export const DEFAULT_COPING_REBAR: CopingRebarSettings = {
c14LowerSpacing: 2000,
};
/** 1106 ‘2주형 3주형’ 전용 삽도 우선(사용자 선택). 명시 입력은 이 기본값보다 우선한다. */
export function copingDefaults(columnCount: number): CopingRebarSettings {
// 다주형 (3주형형상배근.md §1.2): 피복 120 (Q-M6), 전단 @300 · 다리 8개 (Q-M3), 측면 @200, 크로스타이 연직 600.
return columnCount > 1 ? { ...DEFAULT_COPING_REBAR, cover: 120, shearLegs: 8, spacingS: 300, spacingL: 300,
sideSpacing: 200, c14UpperSpacing: 600, c14LowerSpacing: 600 } : { ...DEFAULT_COPING_REBAR };
}
/** Allowed range per input (inclusive, mm). The shear legs are checked for evenness separately (CP02). */
export const COPING_REBAR_RANGES: Record<keyof CopingRebarSettings, [number, number]> = {
cover: [80, 150],
@@ -56,8 +63,8 @@ export const COPING_REBAR_RANGES: Record<keyof CopingRebarSettings, [number, num
spacingL: [200, 350],
sideSpacing: [200, 300],
sideEndGap: [300, 500],
c14UpperSpacing: [800, 1200],
c14LowerSpacing: [1500, 2500],
c14UpperSpacing: [600, 1200],
c14LowerSpacing: [600, 2500],
};
/** 상면 주철근(C1–C6) 이음 (승인 2026-10-26): ignore = 이음 무시 (사양 기본), apply = 정척 초과 시 2조각 겹침. */
+10 -9
View File
@@ -60,16 +60,17 @@ export function shearBars(coping: number, xc = coping / 2, g: CopingGeom = P1_CO
/**
* Side horizontal bars (코핑철근.md §6): seven rows from the bottom at 250 mm.
* Rows below the sloped-bottom break (z_7,end) are C12, the rows at and above it are C13.
* C12 = 상단 (rows at and above the sloped-bottom break z_7,end, to the end face), C13 = 하단 변화구간 (rows below it,
* cut by the sloped bottom) — 엑셀 Pier B357–G358, 2026-11-06 결정 (was the other way round).
*/
/**
* Side rows on the 250 grid from zBottom (코핑철근.md §6.1). With zTop the grid runs up to zTop (상면 여유 400 mm);
* without it the old seven rows are used. Rows below zBreak are C12 (taper band), the rest C13 (end band).
* without it the old seven rows are used. Rows below zBreak are C13 (taper band, 하단 변화구간), the rest C12 (end band, 상단).
*/
export function sideRows(zBottom: number, zBreak: number, zTop?: number, rows = 7): { c12: number[]; c13: number[] } {
const count = zTop === undefined ? rows : Math.floor((zTop - zBottom + 1e-6) / (COPING_REBAR.spacingSide / 1000)) + 1;
const z = Array.from({ length: Math.max(0, count) }, (_, i) => Math.round((zBottom + i * COPING_REBAR.spacingSide / 1000) * 1000) / 1000);
return { c12: z.filter((v) => v < zBreak - 1e-9), c13: z.filter((v) => v >= zBreak - 1e-9) };
return { c12: z.filter((v) => v >= zBreak - 1e-9), c13: z.filter((v) => v < zBreak - 1e-9) };
}
/** C14 counts (코핑철근.md §8): 2 upper lines of 11 plus the lower rows 5 / 6 / 3 / 2 = 38. */
@@ -103,8 +104,8 @@ export function shearULength(x: number, coping: number, g: CopingGeom = P1_COPIN
return SHEAR_U.flat + 2 * shearUHeight(x, coping, g);
}
/** C13 sits 127 in from each end face (코핑철근.md §6.2). */
export const SIDE_C13_END = 127;
/** The upper rows (C12, 상단) sit 127 in from each end face (코핑철근.md §6.2). */
export const SIDE_C12_END = 127;
/** Leg of each ㄷ형 bar along the side, 1.3·l_d = 1525 (Q-K9). */
export const SIDE_HOOK_LEG = 1525;
@@ -120,8 +121,8 @@ export interface SideBar {
/**
* C12 and C13 rows for a coping of length L (mm), T형 (코핑철근.md §6.2).
* C12 meets the sloped bottom at x_w = c + (z_break − z_k)·(x_L0 − c)/h2, with x_L0 = L/2 − W_L/2.
* C13 starts at x_w = 127. Each row has two 일자 bars (side faces) and two ㄷ bars (one at each end).
* C13 (하단 변화구간) meets the sloped bottom at x_w = c + (z_break − z_k)·(x_L0 − c)/h2, with x_L0 = L/2 − W_L/2.
* C12 (상단) starts at x_w = 127. Each row has two 일자 bars (side faces) and two ㄷ bars (one at each end).
*/
export function copingSideBars(L: number, zBottom: number, zBreak: number, g: CopingGeom = P1_COPING_GEOM, zTop?: number): { c12: SideBar[]; c13: SideBar[] } {
const c = COPING_REBAR.cover;
@@ -129,8 +130,8 @@ export function copingSideBars(L: number, zBottom: number, zBreak: number, g: Co
const bar = (z: number, xw: number): SideBar => ({ z, xw, straight: L - 2 * xw });
const rows = sideRows(zBottom, zBreak, zTop);
return {
c12: rows.c12.map((z) => bar(z, c + ((zBreak - z) * 1000 * (xL0 - c)) / g.h2)),
c13: rows.c13.map((z) => bar(z, SIDE_C13_END)),
c12: rows.c12.map((z) => bar(z, SIDE_C12_END)),
c13: rows.c13.map((z) => bar(z, c + ((zBreak - z) * 1000 * (xL0 - c)) / g.h2)),
};
}
+308
View File
@@ -0,0 +1,308 @@
/**
* 다주형(2·3주형) 코핑 철근 — RAG/3주형형상배근.md §4–§8 (2026-11-06, 2주형 DWG 실측 규칙, 번호 = 역할 Q-M1).
* Pure. One physical piece per entry; 3D, 삽도, 철근표, 재료표 share these pieces. T형 keeps its own generator.
*
* Coordinates: x along the coping from BP1 (m), EL (m), y across the width from the axis (m, the scene's third axis).
* Rules still open (Q-M2 밀집 구간, Q-M3 다리 8개, Q-M4 3주형 확장, Q-M5 측면 형상, Q-M6 피복, Q-M7 계단부, Q-M12 이음)
* use the document's current assumptions and are flagged in the summary.
*/
import type { PierModel } from '../geometry';
import { DEFAULT_MATERIAL, developmentLength, fyOf, hookLength, type Material, NOMINAL_DIAMETER, spliceLength } from './common';
export type CopingPoint = [number, number, number];
export interface CopingBar { name: string; size: string; diameterMm: number; pts: CopingPoint[]; length: number }
/** Actual concrete lower envelope, including every haunch vertex. Coordinates are m, EL m. */
export function copingEnvelope(model: PierModel) {
const polygons = model.copingParts.filter(p => p.k === 0).map(p => p.profile);
const xs = [...new Set(polygons.flatMap(p => p.map(v => v[0])))].sort((a, b) => a - b);
const under = (x: number) => Math.max(...polygons.map(p => {
const hits = p.flatMap((a, i) => {
const b = p[(i + 1) % p.length];
if (Math.abs(b[0] - a[0]) < 1e-9 || x < Math.min(a[0], b[0]) - 1e-9 || x > Math.max(a[0], b[0]) + 1e-9) return [];
return [a[1] + (b[1] - a[1]) * (x - a[0]) / (b[0] - a[0])];
});
return Math.min(...hits);
}));
const slopes = xs.slice(1).map((x, i) => (under(x) - under(xs[i])) / (x - xs[i]));
// Conservative vertical inset at a corner: both adjoining faces retain the requested normal cover.
const inset = (x: number, cover: number) => under(x) + cover * Math.max(1, ...slopes.filter((_, i) => x >= xs[i] - 1e-9 && x <= xs[i + 1] + 1e-9).map(s => Math.hypot(1, s)));
return { xs, under, inset };
}
export function copingPathLength(pts: CopingPoint[]): number {
return pts.slice(1).reduce((sum, p, i) => sum + Math.hypot(...p.map((v, j) => v - pts[i][j])), 0) * 1000;
}
/** Document values that are not screen inputs yet (3주형형상배근.md §1.2, m unless noted). */
export const MULTI_REBAR = {
/** 밀집 간격 s_d (mm) and counts: outer (end side) / inner (span side) of a column zone (Q-M2). */
denseSpacingMm: 150, denseOuter: 14, denseInner: 16,
/** Column zone around the centre: outer columns a_o … a_i (span side +), middle columns ± a_m (Q-M2). */
zoneOuter: 1.15, zoneInner: 1.25, zoneMiddle: 1.2,
/** Gap between the two leg groups (Q-M3). */
legGroupGap: 0.25,
/** 상면 2단 끊는 위치 l_cut and C8 끝 l_8 from the column centre. */
topCut: 4.45, c8End: 2.23,
/** 정척 for the 상면 1단 이음 (Q-M9, mm). */
standardMm: 8000,
/** C14: vertical pitch, pitch along the axis, stagger between rows. */
c14Vertical: 0.6, c14Along: 1.2, c14Stagger: 0.6,
/** h_16: a step at least this much above the end top gets C15 (m). */
stepMin: 0.1,
} as const;
export interface MulticolInfo { bars: CopingBar[]; warnings: { code: string; message: string }[] }
/**
* Shear positions along the coping (§4.1): end, dense, column zone, dense, span … (sorted, m). Positions outside the cover
* lines are dropped; `short` lists spans where the dense zones meet or overlap (RB-M01) and `tight` spans whose equal bay
* is below s_d (RB-M02).
*/
export function multicolShearLayout(L: number, columns: number[], c: number, s: number, sd: number): { positions: number[]; short: number[]; tight: number[] } {
const xs = [...columns].sort((a, b) => a - b);
const n = xs.length;
const out: number[] = [];
const run = (from: number, to: number, count: number) => { for (let j = 0; j <= count; j++) out.push(from + (to - from) * j / count); };
const groups = xs.map((x, k) => {
const zone: [number, number] = n === 1 ? [x - MULTI_REBAR.zoneMiddle, x + MULTI_REBAR.zoneMiddle]
: k === 0 ? [x - MULTI_REBAR.zoneOuter, x + MULTI_REBAR.zoneInner]
: k === n - 1 ? [x - MULTI_REBAR.zoneInner, x + MULTI_REBAR.zoneOuter]
: [x - MULTI_REBAR.zoneMiddle, x + MULTI_REBAR.zoneMiddle];
const left = k === 0 ? MULTI_REBAR.denseOuter : MULTI_REBAR.denseInner;
const right = k === n - 1 ? MULTI_REBAR.denseOuter : MULTI_REBAR.denseInner;
const start = zone[0] - left * sd, end = zone[1] + right * sd;
run(start, zone[0], left);
run(zone[0], zone[1], Math.max(1, Math.round((zone[1] - zone[0]) / s)));
run(zone[1], end, right);
return [start, end] as const;
});
// Ends: the bar at the cover line, then @s from the dense start; the remainder is the first space (§4.1).
out.push(c, L - c);
for (let x = groups[0][0] - s; x > c + 1e-6; x -= s) out.push(x);
for (let x = groups[n - 1][1] + s; x < L - c - 1e-6; x += s) out.push(x);
// Spans between dense zones: m = ceil(l / s) equal bays.
const short: number[] = [], tight: number[] = [];
for (let k = 1; k < n; k++) {
const a = groups[k - 1][1], b = groups[k][0];
if (b - a < 1e-6) { short.push(k); continue; }
const m = Math.ceil((b - a) / s - 1e-9);
if ((b - a) / m < sd - 1e-9) tight.push(k);
for (let j = 1; j < m; j++) out.push(a + (b - a) * j / m);
}
if (groups[0][0] < c - 1e-9 || groups[n - 1][1] > L - c + 1e-9) short.push(0);
const inside = out.filter(x => x >= c - 1e-9 && x <= L - c + 1e-9);
return { positions: [...new Map(inside.map(x => [Math.round(x * 1e6), x])).values()].sort((a, b) => a - b), short, tight };
}
/** Shear positions only (see multicolShearLayout). */
export function multicolShearPositions(L: number, columns: number[], c: number, s: number, sd: number): number[] {
return multicolShearLayout(L, columns, c, s, sd).positions;
}
/** Leg positions across the width (§4.3): two groups of four, 250 apart; otherwise equal spacing. Closures by index. */
export function multicolLegs(B: number, c: number, legs: number): { y: number[]; closures: [number, number][] } {
if (legs === 8) {
const p = (B - 2 * c - MULTI_REBAR.legGroupGap) / 6;
const y = [0, 1, 2, 3].map(i => -B / 2 + c + i * p);
y.push(...[0, 1, 2, 3].map(i => -B / 2 + c + 3 * p + MULTI_REBAR.legGroupGap + i * p));
return { y, closures: [[0, 2], [1, 4], [3, 6], [5, 7]] };
}
const pitch = (B - 2 * c) / (legs - 1);
const y = Array.from({ length: legs }, (_, i) => -B / 2 + c + i * pitch);
return { y, closures: Array.from({ length: legs / 2 }, (_, i) => [i, i + legs / 2] as [number, number]) };
}
/** Bars across the width: the edge bars at the cover line and `spaces` bays of `pitch` between, leftover split (§5.1). */
function widthBars(B: number, c: number, pitch: number): number[] {
const spaces = Math.floor((B - 2 * c) / pitch + 1e-9) - 2;
const g = (B - 2 * c - spaces * pitch) / 2;
return [-B / 2 + c, ...Array.from({ length: spaces + 1 }, (_, i) => -B / 2 + c + g + i * pitch), B / 2 - c];
}
/** Shared physical pieces for multi-column 3D and quantities, with the RB-M warnings of §11. */
export function multicolCopingInfo(model: PierModel, material: Material = DEFAULT_MATERIAL): MulticolInfo {
const bars: CopingBar[] = [];
const warnings: MulticolInfo['warnings'] = [];
const L = model.copingL, B = model.copingB;
const settings = model.copingRebar;
if ([settings.spacingS, settings.sideSpacing, settings.mainSpacing, settings.mainLayerGap, settings.cover].some(v => !Number.isFinite(v) || v <= 0)
|| !Number.isInteger(settings.shearLegs) || settings.shearLegs < 2 || settings.shearLegs > 8 || settings.shearLegs % 2) {
throw new Error('다주형 배근 간격은 양수, 전단 다리 수는 2~8의 짝수여야 합니다.');
}
const c = settings.cover / 1000, s = settings.spacingS / 1000, sd = MULTI_REBAR.denseSpacingMm / 1000;
const envelope = copingEnvelope(model);
const add = (name: string, size: keyof typeof NOMINAL_DIAMETER, pts: CopingPoint[]) => {
if (pts.length < 2) return;
bars.push({ name, size, diameterMm: NOMINAL_DIAMETER[size], pts, length: copingPathLength(pts) });
};
const columns = model.columns.map(col => col.s).sort((a, b) => a - b);
const lw = model.copingGeom.levelWidth / 1000;
const levelBottom = model.levelBottom;
const hr = Math.max(0, ...envelope.xs.map(x => envelope.under(x) - levelBottom).filter(v => v < (model.endBottom - levelBottom) - 1e-6));
const steps = [...model.segments, ...(model.backSegments ?? [])];
const stepTop = (x: number) => Math.min(...steps.filter(g => x >= g.s0 - 1e-9 && x <= g.s1 + 1e-9).map(g => g.top));
const d = (size: keyof typeof NOMINAL_DIAMETER) => NOMINAL_DIAMETER[size];
const fy = (size: string) => fyOf(material, size);
// --- 전단 C9 · C10 · C11 (§4) -------------------------------------------------------------------
const legs = multicolLegs(B, c, settings.shearLegs);
const ds = d('H22') / 1000;
const shearName = (x: number) => {
const rel = envelope.under(x) - levelBottom;
if (Math.abs(rel) < 1e-6) return 'C10';
if (hr > 0 && Math.abs(rel - hr) < 1e-6) return 'C11';
return 'C9';
};
const layout = multicolShearLayout(L, columns, c, s, sd);
if (layout.short.length) warnings.push({ code: 'RB-M01', message: `코핑이 짧아 기둥 옆 밀집 구간(바깥 ${MULTI_REBAR.denseOuter} · 안쪽 ${MULTI_REBAR.denseInner}칸 @${MULTI_REBAR.denseSpacingMm})이 ${layout.short.includes(0) ? '코핑 끝을 넘거나 ' : ''}서로 겹칩니다. 겹친 위치를 합쳐 표시했습니다 — 3주형형상배근.md는 오류로 정합니다 (Q-M2 밀집 구간 규칙 확인).` });
if (layout.tight.length) warnings.push({ code: 'RB-M02', message: `기둥 사이 등분 간격이 밀집 간격 ${MULTI_REBAR.denseSpacingMm}보다 작습니다 (경간 ${layout.tight.join(', ')}).` });
for (const x of layout.positions) {
const name = shearName(x);
// The closures (U + cap each) sit side by side, centred on the position so the end-line bars stay inside the coping.
const span = (legs.closures.length * 2 - 1) * ds;
legs.closures.forEach(([i, j], q) => {
const xu = x - span / 2 + q * 2 * ds, xc = xu + ds;
const yTop = stepTop(x) - c;
const bottom = envelope.inset(xu, c);
const capBottom = envelope.inset(xc, c) + 0.1;
add(name, 'H22', [[xu, yTop, legs.y[i]], [xu, bottom, legs.y[i]], [xu, bottom, legs.y[j]], [xu, yTop, legs.y[j]]]);
add(name, 'H22', [[xc, capBottom, legs.y[i]], [xc, yTop, legs.y[i]], [xc, yTop, legs.y[j]], [xc, capBottom, legs.y[j]]]);
});
}
// --- 상면 C1 · C4 (1단, 8 m 이음) · C3 · C2 · C3-1 · C2-1 (2단) (§5.2) -------------------------------
const top = widthBars(B, c, settings.mainSpacing / 1000);
const z1 = model.copingRefTop - c, z2 = z1 - settings.mainLayerGap / 1000;
const h25 = hookLength('main90', d('H25')) / 1000;
const ls25 = spliceLength(d('H25'), fy('H25'), material.fck) / 1000;
const a = c, b = L - c;
// Smallest n with every piece ≤ 8 m (hooks included), joints at the n-section points ∓ l_s (C1 −, C4 +).
const pieces = (shift: number, hooks: boolean) => {
for (let n = 1; n < 100; n++) {
const cuts = Array.from({ length: n - 1 }, (_, j) => a + (b - a) * (j + 1) / n + shift);
const ends = [a, ...cuts, b];
const spans = ends.slice(1).map((e, i) => [i === 0 ? ends[0] : ends[i] - ls25 / 2, i === n - 1 ? e : e + ls25 / 2] as [number, number]);
const lengths = spans.map(([p, q], i) => (q - p) + (hooks && i === 0 ? h25 : 0) + (hooks && i === n - 1 ? h25 : 0));
if (lengths.every(l => l <= MULTI_REBAR.standardMm / 1000 + 1e-9)) return { spans, cuts };
}
throw new Error('상면 1단 이음 조각을 정할 수 없습니다.');
};
const c1 = pieces(-ls25, true), c4 = pieces(ls25, false);
top.forEach((y, i) => {
const cycle1 = i % 2 === 0;
const plan = cycle1 ? c1 : c4;
plan.spans.forEach(([p, q], k) => {
const pts: CopingPoint[] = [[p, z1, y], [q, z1, y]];
if (cycle1 && k === 0) pts.unshift([p, z1 - h25, y]);
if (cycle1 && k === plan.spans.length - 1) pts.push([q, z1 - h25, y]);
add(cycle1 ? 'C1' : 'C4', 'H25', pts);
});
// 2단: 끝단 쪽은 기둥 중심 + l_cut까지 (C3 끝 갈고리 / C2 직선), 가운데 기둥 위는 ± l_cut (C3-1 / C2-1).
const xFirst = columns[0], xLast = columns.at(-1)!;
const outerName = cycle1 ? 'C3' : 'C2', midName = cycle1 ? 'C3-1' : 'C2-1';
add(outerName, 'H25', [...(cycle1 ? [[a, z2 - h25, y] as CopingPoint] : []), [a, z2, y], [xFirst + MULTI_REBAR.topCut, z2, y]]);
add(outerName, 'H25', [[xLast - MULTI_REBAR.topCut, z2, y], [b, z2, y], ...(cycle1 ? [[b, z2 - h25, y] as CopingPoint] : [])]);
for (const x of columns.slice(1, -1)) add(midName, 'H25', [[x - MULTI_REBAR.topCut, z2, y], [x + MULTI_REBAR.topCut, z2, y]]);
});
// RB-M05: 상면 1단 이음 중심이 기둥 구간 안 (정보).
const zoneOf = (x: number) => columns.some((col, k) => {
const n = columns.length;
const [lo, hi] = k === 0 ? [col - MULTI_REBAR.zoneOuter, col + MULTI_REBAR.zoneInner] : k === n - 1 ? [col - MULTI_REBAR.zoneInner, col + MULTI_REBAR.zoneOuter] : [col - MULTI_REBAR.zoneMiddle, col + MULTI_REBAR.zoneMiddle];
return x >= lo - 1e-9 && x <= hi + 1e-9;
});
const inZone = [...c1.cuts.map(x => ['C1', x] as const), ...c4.cuts.map(x => ['C4', x] as const)].filter(([, x]) => zoneOf(x));
if (inZone.length) warnings.push({ code: 'RB-M05', message: `상면 1단 8 m 이음 중심 ${inZone.length}곳이 기둥 구간 안입니다 (${inZone.map(([nm, x]) => `${nm} ${x.toFixed(3)}`).join(' · ')}). 정보.` });
// --- 하면 C7 (전 길이, Level 꺾임점 겹침) · C8 (경간마다) (§5.3–5.4) ---------------------------------
const bottomBars = widthBars(B, c, 2 * settings.mainSpacing / 1000);
const h29 = hookLength('main90', d('H29')) / 1000;
const ls29 = spliceLength(d('H29'), fy('H29'), material.fck) / 1000;
const path = (from: number, to: number, y: number): CopingPoint[] =>
[from, ...envelope.xs.filter(x => x > from + 1e-9 && x < to - 1e-9), to].map(x => [x, envelope.inset(x, c), y]);
// The lap runs l_s/2 along the bar on each side of a Level kink (not l_s/2 horizontally, which is longer on a haunch).
const walk = (x0: number, dist: number, dir: 1 | -1) => {
const stops = [...envelope.xs.filter(x => dir > 0 ? x > x0 + 1e-9 : x < x0 - 1e-9).sort((p, q) => dir * (p - q)), dir > 0 ? b : a];
let x = x0, left = dist;
for (const next of stops) {
const seg = Math.hypot(next - x, envelope.under(next) - envelope.under(x));
if (seg >= left - 1e-12) return x + (next - x) * left / seg;
left -= seg; x = next;
}
return x;
};
const kinks = columns.flatMap(x => [x - lw / 2, x + lw / 2]).slice(1, -1);
const c7Ends = [a, ...kinks, b];
for (const y of bottomBars) {
for (let k = 1; k < c7Ends.length; k++) {
const from = k === 1 ? a : walk(c7Ends[k - 1], ls29 / 2, -1), to = k === c7Ends.length - 1 ? b : walk(c7Ends[k], ls29 / 2, 1);
const pts = path(from, to, y);
if (k === 1) pts.unshift([a, pts[0][1] + h29, y]);
if (k === c7Ends.length - 1) pts.push([b, pts.at(-1)![1] + h29, y]);
add('C7', 'H29', pts);
}
}
const c8y = bottomBars.slice(1).map((y, i) => (y + bottomBars[i]) / 2);
for (let k = 1; k < columns.length; k++) {
const from = columns[k - 1] + MULTI_REBAR.c8End, to = columns[k] - MULTI_REBAR.c8End;
for (const y of c8y) add('C8', 'H25', [[from, levelBottom + hr + c, y], [to, levelBottom + hr + c, y]]);
}
// --- 측면 수평 C12 (상단, 전 길이) · C13 (하단 변화구간, 하면에서 끊김), 다리 위치마다 (§6) -----------------
const zBreak = model.endBottom + c;
const sideTop = z2 - (d('H25') + d('H16')) / 2000;
const sideRows: { z: number; intervals: [number, number][] }[] = [];
for (let z = levelBottom + c + settings.sideSpacing / 1000; z <= sideTop + 1e-9; z += settings.sideSpacing / 1000) {
// Where the bar clears the underside by c (vertical): one interval per stretch between the walls of the 오목.
const xsAll = [a, ...envelope.xs.filter(x => x > a && x < b), b];
const intervals: [number, number][] = [];
for (let i = 1; i < xsAll.length; i++) {
let p = xsAll[i - 1], q = xsAll[i];
const zp = envelope.under(p) + c, zq = envelope.under(q) + c;
if (Math.min(zp, zq) > z + 1e-9) continue;
if (zp > z) p += (q - p) * (zp - z) / (zp - zq);
else if (zq > z) q = p + (q - p) * (z - zp) / (zq - zp);
const last = intervals.at(-1);
if (last && Math.abs(last[1] - p) < 1e-8) last[1] = q; else intervals.push([p, q]);
}
sideRows.push({ z, intervals });
const name = z < zBreak - 1e-9 ? 'C13' : 'C12';
for (const [p, q] of intervals) for (const y of legs.y) add(name, 'H16', [[p, z, y], [q, z, y]]);
}
// --- C14 크로스타이 (§7): 연직 600, 교각축 1200, 단마다 600 엇갈림 ---------------------------------------
const tie = hookLength('tie90', d('H16')) / 1000;
const yEdge = B / 2 - c;
const c14Rows: number[] = [];
for (let z = levelBottom + c + MULTI_REBAR.c14Vertical; z < sideTop - 1e-9; z += MULTI_REBAR.c14Vertical) c14Rows.push(z);
c14Rows.push(sideTop);
c14Rows.forEach((z, r) => {
for (let x = MULTI_REBAR.c14Along / 2 + (r % 2) * MULTI_REBAR.c14Stagger; x <= L - MULTI_REBAR.c14Along / 2 + 1e-9; x += MULTI_REBAR.c14Along) {
if (z < envelope.under(x) + c + tie) continue;
add('C14', 'H16', [[x, z - tie, -yEdge], [x, z, -yEdge], [x, z, yEdge], [x, z - tie, yEdge]]);
}
});
// --- C15 계단부 (§8): 끝단보다 h_16 이상 높은 계단 전체에 ⊓ 하나 ------------------------------------------
const front = model.segments;
const endTop = Math.min(front[0].top, front.at(-1)!.top);
const high = front.filter(g => g.top - endTop >= MULTI_REBAR.stepMin - 1e-9);
if (high.length) {
const from = high[0].s0 + c, to = high.at(-1)!.s1 - c;
const z15 = Math.min(...high.map(g => g.top)) - c;
const ld = developmentLength(d('H19'), fy('H19'), material.fck) / 1000;
top.filter((_, i) => i % 2 === 0).forEach(y => add('C15', 'H19', [[from, z15 - ld, y], [from, z15, y], [to, z15, y], [to, z15 - ld, y]]));
const above = Math.max(...front.map(g => g.top)) - (z15 + c);
if (above > MULTI_REBAR.stepMin + 1e-9) warnings.push({ code: 'RB-M04', message: `가장 높은 계단 상면이 C15보다 ${Math.round(above * 1000)} mm 높습니다 (h_16 ${MULTI_REBAR.stepMin * 1000} 초과). C16 병행은 미정 (Q-M7).` });
}
// RB-M03: 상면 1단 외 철근이 12 m를 넘으면 이음 미정 (Q-M12).
const long = [...new Set(bars.filter(x => !['C1', 'C4'].includes(x.name) && x.length > 12000).map(x => x.name))];
if (long.length) warnings.push({ code: 'RB-M03', message: `${long.join(' · ')} 길이가 12000을 넘습니다. 상면 1단 외 철근의 8 m 이음 적용은 미정입니다 (Q-M12).` });
return { bars, warnings };
}
/** Shared physical pieces for multi-column 3D and quantities (3주형형상배근.md §4–§8). */
export function multicolCopingBars(model: PierModel, material: Material = DEFAULT_MATERIAL): CopingBar[] {
return multicolCopingInfo(model, material).bars;
}
+10 -8
View File
@@ -60,25 +60,25 @@ export interface Quantity {
ton: number;
}
export function buildQuantity(rows: RebarRow[], material: Material): Quantity {
export function buildQuantity(rows: RebarRow[], material: Material, incomplete: readonly string[] = []): Quantity {
const byGrade = new Map<number, Map<string, QuantityDiameter>>();
const couplers: { name: string; count: number }[] = [];
const missing: string[] = [];
const missing: string[] = [...incomplete];
for (const r of rows) {
if (r.name.includes('커플러')) {
couplers.push({ name: r.name, count: r.count });
continue;
}
if (!isBarSize(r.size)) continue;
if (!isBarSize(r.size) && r.diameterMm === undefined) continue;
// A row with several lengths carries pieces; a row with one length is one piece (count bars of that length).
const pieces = r.pieces ?? (r.length === null ? null : [{ length: r.length, count: r.count }]);
if (!pieces) {
missing.push(r.name);
continue;
}
const grade = fyOf(material, r.size);
const dMm = NOMINAL_DIAMETER[r.size];
const grade = r.gradeMpa ?? fyOf(material, r.size as BarSize);
const dMm = r.diameterMm ?? NOMINAL_DIAMETER[r.size as BarSize];
const kgPerM = unitWeight(dMm);
const pieceCount = pieces.reduce((s, p) => s + p.count, 0);
const totalM = pieces.reduce((s, p) => s + (p.count * p.length) / 1000, 0);
@@ -87,11 +87,12 @@ export function buildQuantity(rows: RebarRow[], material: Material): Quantity {
length: pieces.length === 1 ? pieces[0].length : null, pieces, totalM, kgPerM, kg: totalM * kgPerM, shape: r.shape,
};
const diameters = byGrade.get(grade) ?? new Map<string, QuantityDiameter>();
const d = diameters.get(r.size) ?? { size: r.size, dMm, lines: [], totalM: 0, kg: 0 };
const sameDiameter = [...diameters.values()].find(d => Math.abs(d.dMm - dMm) < 1e-8);
const d = sameDiameter ?? { size: r.size, dMm, lines: [], totalM: 0, kg: 0 };
d.lines.push(line);
d.totalM += totalM;
d.kg += line.kg;
diameters.set(r.size, d);
diameters.set(d.size, d);
byGrade.set(grade, diameters);
}
@@ -119,10 +120,11 @@ export function quantityRows(q: Quantity): (string | number)[][] {
}
out.push([`SD${g.grade} 소계`, '', '', '', '', '', '', '', round(g.kg)]);
}
out.push(['합계', '', '', '', '', '', '', '', round(q.kg)]);
out.push([q.missing.length ? '계산된 부재 소계 · 전체 수량 미완료' : '합계', '', '', '', '', '', '', '', round(q.kg)]);
out.push([`할증 ${q.wastePercent}% 포함`, '', '', '', '', '', '', '', round(q.kgWithWaste)]);
out.push(['총중량 (ton)', '', '', '', '', '', '', '', round(q.ton, 3)]);
for (const c of q.couplers) out.push(['커플러 (개수만)', c.name, '', '', c.count, '', '', '', '']);
for (const name of q.missing) out.push(['미계산 · 합계 미완료', name]);
return out;
}
+108 -21
View File
@@ -1,11 +1,14 @@
import type { PierModel } from '../geometry';
import { barLength, bearingBlockRebar, DEFAULT_BLOCK_REBAR, totals } from './bearing-block';
import { DEFAULT_COPING_REBAR } from './coping-settings';
import { copingDefaults } from './coping-settings';
import { DEFAULT_COLUMN_REBAR } from './column-settings';
import { DEFAULT_FOOTING_REBAR, type FootingRebarSettings } from './footing-settings';
import { bottomPiece, topBarLength, widthLayout, topSplicePieces } from './coping-main';
import { shearBars, shearBarsMulti, copingSideBars, SIDE_HOOK_LEG, c14Bars, c14Length, stairBars, stepDevLength, undersideAt, shearULength, capLength, PIECES_PER_POSITION } from './coping';
import { sideRowLevels } from '../coping-geom';
import { CAST_PENDING } from './cast-in-place';
import { castRebarLayout } from './cast-layout';
import { multicolCopingInfo } from './multicol-coping';
import { columnRebar, columnInputFor } from './column';
import { bottomBars, crossBars, sideBars, assemblyBars, shearB9 } from './footing';
import { DEFAULT_MATERIAL, FckByMember, fyOf, isAssumedMaterial, Material, materialFor, splitPieces, STANDARD_BAR } from './common';
@@ -15,7 +18,7 @@ export type Basis = 'input' | 'default' | 'calc' | 'assumed';
/** One line of a 철근표. length is null where the document gives no length yet. */
export interface RebarRow {
member: 'bearing' | 'coping' | 'column' | 'footing';
member: 'bearing' | 'coping' | 'column' | 'footing' | 'pile';
name: string;
size: string;
count: number;
@@ -31,6 +34,9 @@ export interface RebarRow {
direction: string;
shape: string;
basis: Basis;
/** Explicit dedicated-section properties; absent for legacy rows. */
diameterMm?: number;
gradeMpa?: number;
}
export interface RebarWarning {
@@ -42,6 +48,17 @@ export interface RebarWarning {
bar?: string;
}
/** 다주형 코핑 번호별 위치 (3주형형상배근.md §3, 번호 = 역할). */
const MULTI_NOTE: Record<string, string> = {
C1: '상면 1단 cycle-1, 양끝 갈고리, 8 m 이음', C4: '상면 1단 cycle-2, 직선, 8 m 이음 (± l_s 엇갈림)',
C3: '상면 2단 cycle-1, 끝단 쪽 (기둥 중심 + 4450까지)', C2: '상면 2단 cycle-2, 끝단 쪽',
'C3-1': '상면 2단 cycle-1, 가운데 기둥 위 ± 4450', 'C2-1': '상면 2단 cycle-2, 가운데 기둥 위',
C7: '하면 전 길이, Level 꺾임점 겹침, 양끝 위로 갈고리', C8: '경간마다 오목 하면, 기둥 중심 ± 2230 안쪽',
C9: '전단 · 테이퍼·헌치', C10: '전단 · 기둥 Level', C11: '전단 · 기둥 사이 오목',
C12: '측면 상단 (전 길이), 다리 위치마다', C13: '측면 하단 변화구간 (하면에서 끊김), 다리 위치마다',
C14: '크로스타이 연직 600 · 교각축 1200 엇갈림', C15: '계단부 ⊓ (끝단보다 100 이상 높은 계단 전체)',
};
/** Table metadata per bar (코핑철근.md, 확대기초철근 (1).md, 기둥철근 (1).md, 받침블럭철근.md). */
const META: Record<string, Pick<RebarRow, 'spacing' | 'actual' | 'position' | 'direction' | 'shape' | 'basis'>> = {
S1: { spacing: '125', actual: '125', position: '블럭 상면 − 69.1', direction: '교각축 직각', shape: '양끝 다리 꺾임', basis: 'calc' },
@@ -57,8 +74,8 @@ const META: Record<string, Pick<RebarRow, 'spacing' | 'actual' | 'position' | 'd
C9: { spacing: '250', actual: '250', position: '전단 좌 경사', direction: '연직', shape: '폐합 (하부 U + 상부 삿갓)', basis: 'assumed' },
C10: { spacing: '300', actual: '300', position: '전단 Level', direction: '연직', shape: '폐합 (하부 U + 상부 삿갓)', basis: 'assumed' },
C11: { spacing: '250', actual: '250', position: '전단 우 경사', direction: '연직', shape: '폐합 (하부 U + 상부 삿갓)', basis: 'assumed' },
C12: { spacing: '250', actual: '250', position: '측면 하단 (경사 하면에서 끊김)', direction: '교각축', shape: '일자 + 좌우 ㄷ형', basis: 'calc' },
C13: { spacing: '250', actual: '250', position: '측면 상단 (끝면까지)', direction: '교각축', shape: '일자 + 좌우 ㄷ형', basis: 'calc' },
C12: { spacing: '250', actual: '250', position: '측면 상단 (끝면까지)', direction: '교각축', shape: '일자 + 좌우 ㄷ형', basis: 'calc' },
C13: { spacing: '250', actual: '250', position: '측면 하단 변화구간 (경사 하면에서 끊김)', direction: '교각축', shape: '일자 + 좌우 ㄷ형', basis: 'calc' },
C14: { spacing: '1000 / 2000', actual: '1000', position: '직각 수평 (위 2줄 · 아래 엇갈림)', direction: '교각축 직각', shape: '⊓ (폭 방향)', basis: 'calc' },
C15: { spacing: '250', actual: '250', position: '계단부 가장 높은 5구간', direction: '교각축', shape: '거꾸로 된 U', basis: 'calc' },
C16: { spacing: '250', actual: '250', position: '계단부 3·4구간', direction: '교각축', shape: 'L형', basis: 'calc' },
@@ -128,7 +145,7 @@ const MAIN_BARS = ['C1', 'C2', 'C3', 'C4', 'C5', 'C6', 'C7', 'C8'];
*/
export function linkedSpacing(name: string, model: PierModel): Pick<RebarRow, 'spacing' | 'actual' | 'basis'> | undefined {
const c = model.copingRebar;
const d = DEFAULT_COPING_REBAR;
const d = copingDefaults(model.columns.length);
const b = model.blockRebar;
const bd = DEFAULT_BLOCK_REBAR;
const basis = (changed: boolean): Basis => (changed ? 'input' : 'default');
@@ -186,11 +203,12 @@ export function rebarSummary(
model: PierModel,
common: Material = DEFAULT_MATERIAL,
byMember: FckByMember = {},
): { rows: RebarRow[]; warnings: RebarWarning[] } {
): { rows: RebarRow[]; warnings: RebarWarning[]; incomplete: string[] } {
const rows: RawRow[] = [];
const warnings: RebarWarning[] = [];
const mCoping = materialFor(common, byMember, 'coping');
const mColumn = materialFor(common, byMember, 'column');
const cast = model.castInput ? castRebarLayout(model, mColumn) : undefined;
const mFooting = materialFor(common, byMember, 'footing');
// The I02 note stays while the common values are the assumed ones, unless every fck-bearing member has its own fck.
const ownFck = (['column', 'coping', 'footing'] as const).every((k) => byMember[k] !== undefined);
@@ -217,6 +235,9 @@ export function rebarSummary(
rows.push({ member: 'bearing', name: 'S2', size: 'H19', count: blk.S2, length: barLength(1200, model.blockRebar), note: `블럭 배치 분리, 블럭당 8본 · 묻힘 ${range(emb2)} (블럭 상면 − 50)` });
// Coping (코핑철근.md §5–§8)
const colS = model.columns.map((c) => c.s * 1000);
const nCol = Math.max(1, colS.length);
if (nCol <= 1 || model.kink !== null) {
const width = widthLayout(B, model.copingRebar);
const perRow = width.count / 2; // cycle split, 12 per row
// CP04 (승인 2026-10-26): the cycle split needs an even count (n + 3), so n must be odd. Warning only; the input is kept.
@@ -236,8 +257,6 @@ export function rebarSummary(
const bottom = bottomPiece(mCoping);
rows.push({ member: 'coping', name: 'C7', size: 'H29', count: perRow, length: bottom.piece, note: '하면, 2조각 겹침이음' });
rows.push({ member: 'coping', name: 'C8', size: 'H29', count: perRow, length: bottom.piece, note: '하면, 2조각 겹침이음' });
const colS = model.columns.map((c) => c.s * 1000);
const nCol = Math.max(1, colS.length);
const geom = model.copingGeom;
const shear = nCol > 1 ? shearBarsMulti(L, colS, geom) : { ...shearBars(L, undefined, geom), between: [] as number[] };
const uRange = (xs: number[]) => {
@@ -262,41 +281,86 @@ export function rebarSummary(
rows.push({ member: 'coping', name: 'C9·C11 사이', size: 'H25', count: shear.between.length, length: null, pieces: loopPieces(shear.between), note: `기둥 사이, 양쪽 C10에서 안쪽으로 (추천안, Q-K2), 조각 ${shear.between.length * PIECES_PER_POSITION}` });
}
if (nCol > 1) {
warnings.push({ code: 'RB-M01', message: `다주형 ${nCol}기둥: C14는 T형 기준으로 두었습니다 (기둥마다 반복 규칙 미정, Q-K11).`, target: 'coping' });
warnings.push({ code: 'RB-M01', message: `다주형 ${nCol}기둥: C14는 전용 삽도의 H16·기본600 간격과 실제 하면을 사용합니다. 기둥별 반복·번호 대응 상세는 확인이 필요합니다.`, target: 'coping' });
}
// Per row: two 일자 (side faces) and two ㄷ (one at each end), so 4 bars per row (코핑철근.md §6.2).
const levels = sideRowLevels(model.levelBottom, model.endBottom, model.copingRefTop);
const side = copingSideBars(model.copingL * 1000, levels.zBottom, levels.zBreak, model.copingGeom, levels.zTop);
const hook = model.copingB * 1000 - 2 * 127 + 2 * SIDE_HOOK_LEG; // ㄷ body across the width + two legs
const c12Range = side.c12.length ? `${Math.round(side.c12[0].straight)}–${Math.round(side.c12[side.c12.length - 1].straight)}` : '—';
const c13Range = side.c13.length ? `${Math.round(side.c13[0].straight)}–${Math.round(side.c13[side.c13.length - 1].straight)}` : '—';
// Per bar: 2 일자 (straight) and 2 ㄷ (hook body + legs), so 4 bars per row position.
const sidePieces = (bars: { straight: number }[]) => bars.flatMap((b) => [{ length: b.straight, count: 2 }, { length: hook, count: 2 }]);
rows.push({ member: 'coping', name: 'C12', size: 'H25', count: 4 * side.c12.length, length: null, pieces: sidePieces(side.c12), note: `측면, 경사 하면에서 끊김, 일자 ${c12Range} (${2 * side.c12.length}) · ㄷ ${Math.round(hook)} (${2 * side.c12.length}) · 도면 6단 중 하면 단(EL ${(model.levelBottom + 0.127).toFixed(3)})은 하면 주철근 C7·C8로 계산 (문서 §6.1)` });
rows.push({ member: 'coping', name: 'C13', size: 'H25', count: 4 * side.c13.length, length: null, pieces: sidePieces(side.c13), note: `측면, 끝면까지, 일자 (${2 * side.c13.length}) · ㄷ ${Math.round(hook)} (${2 * side.c13.length})` });
rows.push({ member: 'coping', name: 'C12', size: 'H25', count: 4 * side.c12.length, length: null, pieces: sidePieces(side.c12), note: `측면 상단, 끝면까지, 일자 (${2 * side.c12.length}) · ㄷ ${Math.round(hook)} (${2 * side.c12.length})` });
rows.push({ member: 'coping', name: 'C13', size: 'H25', count: 4 * side.c13.length, length: null, pieces: sidePieces(side.c13), note: `측면 하단 변화구간, 경사 하면에서 끊김, 일자 ${c13Range} (${2 * side.c13.length}) · ㄷ ${Math.round(hook)} (${2 * side.c13.length}) · 도면 하단 단 중 하면 단(EL ${(model.levelBottom + 0.127).toFixed(3)})은 하면 주철근 C7·C8로 계산 (문서 §6.1)` });
// C14 / C15 / C16 are derived from the model (코핑철근.md §7–8); the document's 38 / 11 / 22 and 3852 / 4070 / 4069 are checked in the tests.
const L14 = model.copingL * 1000;
const c14 = c14Bars(L14, model.copingRefTop, [...side.c12, ...side.c13], (x) => undersideAt(L14, x, model.endBottom, model.levelBottom, model.copingGeom.levelWidth), model.copingRebar.mainLayerGap / 1000);
const c14 = c14Bars(L14, model.copingRefTop, [...side.c13, ...side.c12], (x) => undersideAt(L14, x, model.endBottom, model.levelBottom, model.copingGeom.levelWidth), model.copingRebar.mainLayerGap / 1000);
const lineCount = (line: number) => c14.filter((b) => b.line === line).length;
const omitted = c14.filter((b) => !b.legs).length;
rows.push({ member: 'coping', name: 'C14', size: 'H19', count: c14.length, length: c14Length(model.copingB * 1000), note: `위 2 줄 ${lineCount(1)} · ${lineCount(2)}본씩, 아래 ${[3, 4, 5, 6].map(lineCount).join(' · ')} (합 ${c14.length}) · 연직 발 생략 ${omitted}개 (하면 밖)` });
const stairs = stairBars(model.copingL * 1000, model.copingB * 1000, model.copingRefTop, mCoping, 2 * model.copingRebar.mainSpacing);
rows.push({ member: 'coping', name: 'C15', size: 'H19', count: stairs.c15.length, length: stairs.c15[0].length, note: '계단부, 가장 높은 구간 (거꾸로 U)' });
rows.push({ member: 'coping', name: 'C16', size: 'H19', count: stairs.c16.length, length: stairs.c16[0].length, note: `계단부, 3·4구간 (${Math.round(stairs.c16[0].length)} / ${Math.round(stairs.c16[stairs.c16.length - 1].length)})` });
}
// Column (기둥철근.md · plan C4), P1 defaults
const col = columnRebar(columnInputFor(model), mColumn);
rows.push({ member: 'column', name: 'P1', size: 'S32', count: col.mainP1, length: null, pieces: col.p1Pieces.map((length) => ({ length, count: col.mainP1 })), note: `외측 r ${col.r1}, 마디 ${col.p1Pieces.join(' / ')} (첫 마디에 갈고리 ${col.p1Hook}, 기둥철근.md §3.2)` });
rows.push({ member: 'column', name: 'P2', size: 'S32', count: col.mainP2, length: null, pieces: col.p2Pieces.map((length) => ({ length, count: col.mainP2 })), note: `내측 r ${col.r2}, 마디 ${col.p2Pieces.join(' / ')}` });
if (model.footings.length > 0) {
const columnRows: RawRow[] = [];
for (let columnIndex = 0; columnIndex < model.columns.length; columnIndex++) {
const first = rows.length;
const input = columnInputFor(model, undefined, columnIndex);
const col = columnRebar(input, mColumn);
rows.push({ member: 'column', name: 'P1', size: 'S32', count: col.mainP1, length: null, pieces: col.p1Pieces.map((length) => ({ length, count: input.nDiv })), note: `외측 r ${col.r1}, 마디 ${col.p1Pieces.join(' / ')} (첫 마디에 갈고리 ${col.p1Hook}, 기둥철근.md §3.2)` });
rows.push({ member: 'column', name: 'P2', size: 'S32', count: col.mainP2, length: null, pieces: col.p2Pieces.map((length) => ({ length, count: input.nDiv })), note: `내측 r ${col.r2}, 마디 ${col.p2Pieces.join(' / ')}` });
rows.push({ member: 'column', name: '주철근 커플러', size: 'D32용 (L150 Ø60)', count: col.couplers, length: 150, note: `@${model.columnRebar.ctcCoupler}, P2는 +${model.columnRebar.dzCp2} mm 상향 엇갈림` });
const tieCouplerCount = col.p5Count + col.p4Count * 2;
rows.push({ member: 'column', name: '띠철근 커플러', size: 'D19용 (L100 Ø40)', count: tieCouplerCount, length: 100, note: `단부 P5 1개(${col.p5Count}) · 기초안 P4 2개(${col.p4Count * 2}) 폐합용` });
rows.push({ member: 'column', name: 'P3', size: 'H19', count: col.p3Count, length: col.p3Length, note: `일반 구간 띠철근 @${model.columnRebar.ctcP3}, 겹침 ${col.lapP3} (추천안, Q-C6)` });
rows.push({ member: 'column', name: 'P5', size: 'H19', count: col.p5Count, length: col.p5Length, note: `단부 띠철근 @${model.columnRebar.ctcP5}, 커플러 1개` });
rows.push({ member: 'column', name: 'P4', size: 'H19', count: col.p4Count, length: col.p4SemicircleLength * 2, note: '확대기초 안, 반원 2개 (길이는 반원 2개 합) · B11 간섭 무시' });
rows.push({ member: 'column', name: 'P6', size: 'H16', count: col.p6Count, length: col.p6Length, note: '크로스타이, 8단 × 4' });
rows.push({ member: 'column', name: 'P6', size: 'H16', count: col.p6Count, length: col.p6Length, note: `크로스타이, ${col.p6Levels.length}단 × 4` });
// Column bars and couplers repeat for each column (기둥철근 is per column; 다주형은 기둥마다).
for (const r of rows) if (r.member === 'column') r.count *= nCol;
columnRows.push(...rows.splice(first));
}
for (const r of columnRows) {
const existing = rows.find(v => v.member === 'column' && v.name === r.name);
if (!existing) rows.push({ ...r, pieces: r.pieces ? [...r.pieces] : undefined });
else {
if (existing.pieces || r.pieces || existing.length !== r.length) {
existing.pieces ??= [{ length: existing.length!, count: existing.count }];
existing.pieces.push(...(r.pieces ?? [{ length: r.length!, count: r.count }]));
existing.length = null;
}
existing.count += r.count;
existing.note = '기둥별 실제 배치·길이 합산 · 마디별 수량은 각 기둥의 원주 등분 수';
}
}
} else if (model.castInput) {
const layout = cast!;
const grouped = new Map<string, typeof layout.bars>();
for (const bar of layout.bars) {
const key = `${bar.name}|${bar.diameterMm}|${bar.gradeMpa}`;
const group = grouped.get(key) ?? [];
group.push(bar); grouped.set(key, group);
}
for (const group of grouped.values()) {
const first = group[0];
const byLength = new Map<number, { length: number; count: number }>();
for (const bar of group) {
const key = Math.round(bar.lengthMm * 1e6);
const piece = byLength.get(key) ?? { length: bar.lengthMm, count: 0 };
piece.count++; byLength.set(key, piece);
}
const pieces = [...byLength.values()];
rows.push({ member: first.member, name: first.name, size: first.kind === 'coupler' ? `커플러 Ø${first.diameterMm.toFixed(1)}` : `Ø${first.diameterMm}`,
count: group.length, length: pieces.length === 1 ? pieces[0].length : null, pieces,
diameterMm: first.diameterMm, gradeMpa: first.gradeMpa, note: first.note });
}
layout.missing.forEach(message => warnings.push({ code: 'RB-CIP01', message, target: 'column' }));
} else {
warnings.push({ code: 'RB-CIP01', message: CAST_PENDING.join(' · ') + ' — 전용 입력을 저장할 수 있으며 3D·철근표·재료표 계산은 상세 확정 후 제공합니다.', target: 'column' });
warnings.push({ code: 'RB-CIP02', message: '현장타설말뚝 철근 수량은 미계산입니다. 지원되는 다른 부재만 집계합니다.', target: 'pile' });
}
// Footing (확대기초철근 (1).md · plan C5). Only when the footing exists (not 현장타설말뚝).
if (model.footings.length > 0) {
// Each footing (분리 or 일체) gives the same bar set. Several footings add their counts per bar name;
@@ -327,10 +391,33 @@ export function rebarSummary(
} else {
warnings.push({ code: 'RB-F00', message: '현장타설말뚝은 확대기초가 없어 확대기초 철근을 만들지 않습니다.', target: 'footing' });
}
if (nCol > 1 && model.kink === null) {
const info = multicolCopingInfo(model, mCoping);
const grouped = new Map<string, typeof info.bars>();
for (const b of info.bars) {
const group = grouped.get(b.name) ?? [];
group.push(b); grouped.set(b.name, group);
}
for (const [name, bars] of grouped) rows.push({ member: 'coping', name, size: bars[0].size,
count: bars.length, length: null, pieces: bars.map(b => ({ length: b.length, count: 1 })),
note: `다주형 (3주형형상배근.md) · ${MULTI_NOTE[name] ?? ''} · 조각별 중심선 길이` });
for (const w of info.warnings) warnings.push({ code: w.code, target: 'coping', message: w.message });
warnings.push({ code: 'RB-M06', target: 'coping', message: '다주형 전단 다리와 코핑 안 기둥 주철근의 간섭은 검토하지 않았습니다 (T형 Q-K3과 같음). 밀집 구간·다리 8개·측면 형상·피복 120은 가정값입니다 (Q-M2·Q-M3·Q-M5·Q-M6).' });
warnings.push({ code: 'RB-M08', target: 'column', message: '다주형 기둥 단부는 지름 이상·코핑 반높이 연장을 적용했습니다 (그림 D, Pier A337 1.5D — Q-M8 미결).' });
}
// Table metadata per bar (구분·간격·실간격·위치·방향·형상·근거). The spacing rows take their values from the inputs (승인 2026-10-26).
const withMeta = rows.map((r) => {
if (r.diameterMm !== undefined) return { ...r, spacing: '입력 구간별', actual: '중심선 계산',
position: r.name.startsWith('기둥·말뚝') ? '기둥~말뚝 연속' : r.member === 'pile' ? '지중 말뚝' : '지상 기둥·코핑', direction: '배근 경로',
shape: r.name.includes('커플러') ? '커플러' : r.name.includes('나선') ? '나선' : r.name.includes('크로스타이') ? '양끝 갈고리' : /P[35]/.test(r.name) ? '원형' : '직선', basis: 'calc' as const };
const linked = linkedSpacing(r.name, model);
return { ...r, ...(META[r.name] ?? FALLBACK), ...(linked ?? {}) };
const multi = nCol > 1 && r.member === 'coping';
return { ...r, ...(META[r.name] ?? FALLBACK), ...(linked ?? {}), ...(nCol > 1 && r.member === 'column' && r.name === 'P5' ? { position: '상·하 단부 · 코핑 반높이까지 (번호 확인 중)' } : {}), ...(multi ? {
shape: '다주형 조각별 경로 (검토용)', position: '다주형 전용 삽도 · 실제 하면',
...(r.name === 'C9·C11 사이' ? { spacing: String(model.copingRebar.spacingS), actual: String(model.copingRebar.spacingS) } : {}) } : {}) };
});
return { rows: withMeta, warnings: warnings.map((w) => ({ ...w, target: w.target ?? 'common' })) };
return { rows: withMeta, warnings: warnings.map((w) => ({ ...w, target: w.target ?? 'common' })), incomplete: [
...(cast ? cast.missing : model.footings.length ? [] : CAST_PENDING),
...(nCol > 1 ? ['다주형 철근 번호 대응·cycle별 이음 위치·수평철근 갈고리 상세 확인 필요'] : []),
] };
}
+154
View File
@@ -0,0 +1,154 @@
/**
* 솔플레이트 (결정 2026-11-06, RAG/받침방위각_솔플레이트검토.md §3, spec.md §5.3, 시안 Version 110–113). Pure.
* Replaces the 2026-11-03 C안 (받침높이 H 고정, h3 = H − T, S 입력) by the user's decision to follow the 시안:
*
* - 평면: L = A + 여유 (거더 방향 = 방위각), W = B + 여유 (여유 100). A × B per bearing; absent = 700 × 640 (P1 전열 값, 가정).
* - S: 같은 거더 양끝 받침의 z 차 / 수평거리 (girderPairs). 없으면 교각 종단경사 입력 → 같은 구조물 다른 열의 같은 순번 값
* ("가정", WS03) → 0 % (WS03). 받침별 직접 수정 = "수정됨".
* - T: 열마다 max(T_min, ⌈max(T_min + L·|S|/2)⌉). 직접 고치면 "수정됨", T2 < T_min이면 WS02.
* T1 = round(T + L·|S|/2) 오르막 쪽, T2 = round(T − L·|S|/2).
* - 높이: 거더 하면 고정 — 블럭 상면 = z − (T − T_ref); 받침 본체 h3 고정 (181, P1 상세도).
*/
import { type BearingPair, pairOf } from './bearing-pair';
export interface SoleSettings {
/** 받침 본체 높이 h3 (mm). */
h3Mm?: number;
/** T_ref: the solplate thickness the workbook z assumed (mm). */
tRefMm?: number;
/** T_min: the thin-end minimum (mm). */
tMinMm?: number;
/** 솔플 여유 L − A = W − B (mm). */
marginMm?: number;
/** 받침 평면 A (거더 방향) × B per bearing number (mm). */
plates?: Record<string, { aMm: number; bMm: number }>;
/** T per row, when typed (mm). Absent = 자동. */
tByRow?: Partial<Record<string, number>>;
/** S per bearing number, when typed (%). */
slopePct?: Record<string, number>;
}
export const SOLE_DEFAULTS = { h3Mm: 181, tRefMm: 30, tMinMm: 22, marginMm: 100, aMm: 700, bMm: 640 } as const;
export type SlopeSource = '계산' | '입력' | '가정' | '수정됨' | '없음';
export interface BearingSole {
no: number;
row: string;
pair?: { structure: string; no: number };
/** 방위각 검사 (″): this bearing's input azimuth against the direction to its pair (Q-SH4, WS01 > 10″). Absent without a pair. */
azimuthCheckSec?: number;
/** 입력 방위각 and the one computed from the pair (deg, model 진북); the second is absent without a pair. */
azimuthInputDeg: number;
azimuthCalcDeg?: number;
/** girder (같은 거더) or chord (교대만, A1 ↔ A2 현). */
pairKind?: 'girder' | 'chord';
slopePct: number;
slopeSource: SlopeSource;
aMm: number;
bMm: number;
/** L along the girder, W across it (mm). */
L: number;
W: number;
/** Row thickness T and whether it was typed; T1 on the 오르막 쪽, T2 on the other end (mm). */
T: number;
tTyped: boolean;
T1: number;
T2: number;
/** This bearing's requirement T_min + L·|S|/2 (mm). */
tReq: number;
h3: number;
/** Block top drop T − T_ref (mm, + = the block gets lower). */
dropMm: number;
/** Unit vector (E, N) towards the 오르막 쪽, or null when S = 0. */
uphill: { e: number; n: number } | null;
warnings: ('WS01' | 'WS02' | 'WS03' | 'WS04')[];
}
export interface SoleBearing {
no: number;
row: string;
/** The azimuth in use (an applied 계산 방위각 when chosen) — for the 오르막 direction. */
azimuthDeg: number;
/** The workbook azimuth, when it differs from the one in use. */
inputAzimuthDeg?: number;
}
const DEG = Math.PI / 180;
/**
* Solplates of one structure. `pairs` are the girder pairs of the whole bridge (girderPairs over A1, P1 … Pn, A2);
* `fallbackSlopePct` is the structure's 종단경사 input (%), when given.
*/
export function structureSoles(structure: string, bearings: SoleBearing[], settings: SoleSettings = {}, pairs: BearingPair[] = [], fallbackSlopePct?: number): BearingSole[] {
const s = { ...SOLE_DEFAULTS, ...settings };
const rows = [...new Set(bearings.map((b) => b.row))];
const order = (b: SoleBearing) => bearings.filter((q) => q.row === b.row).indexOf(b);
const paired = new Map(bearings.map((b) => [b.no, pairOf(pairs, structure, b.no)]));
const pairSlope = (b: SoleBearing) => paired.get(b.no)?.slopePct;
const slopes = bearings.map((b) => {
const typed = settings.slopePct?.[String(b.no)];
if (typed !== undefined) return { slopePct: typed, source: '수정됨' as SlopeSource };
const own = pairSlope(b);
if (own !== undefined) return { slopePct: own, source: '계산' as SlopeSource };
if (fallbackSlopePct !== undefined) return { slopePct: fallbackSlopePct, source: '입력' as SlopeSource };
// The same position in another row that has a pair (시안: 후열 ⑥ ← 전열 ①).
for (const other of rows.filter((r) => r !== b.row)) {
const twin = bearings.filter((q) => q.row === other)[order(b)];
const v = twin && pairSlope(twin);
if (v !== undefined) return { slopePct: v, source: '가정' as SlopeSource };
}
return { slopePct: 0, source: '없음' as SlopeSource };
});
const plate = (b: SoleBearing) => s.plates?.[String(b.no)] ?? { aMm: s.aMm, bMm: s.bMm };
const req = bearings.map((b, i) => s.tMinMm + ((plate(b).aMm + s.marginMm) * Math.abs(slopes[i].slopePct)) / 200);
const rowT = new Map(rows.map((r) => {
const typed = settings.tByRow?.[r];
const auto = Math.max(s.tMinMm, Math.ceil(Math.max(...bearings.map((b, i) => (b.row === r ? req[i] : -Infinity))) - 1e-9));
return [r, { T: typed ?? auto, typed: typed !== undefined }];
}));
return bearings.map((b, i) => {
const { aMm, bMm } = plate(b);
const L = aMm + s.marginMm, W = bMm + s.marginMm;
const { slopePct, source } = slopes[i];
const { T, typed } = rowT.get(b.row)!;
const half = (L * Math.abs(slopePct)) / 200;
const T1 = Math.round(T + half), T2 = Math.round(T - half);
const pair = paired.get(b.no);
const warnings: BearingSole['warnings'] = [...(pair?.warnings ?? [])];
if (T2 < s.tMinMm) warnings.push('WS02');
if (source === '가정' || source === '없음') warnings.push('WS03');
// S runs along the structure order, which is the azimuth direction (girderPairs looks ahead along it).
const dir = { e: Math.sin(b.azimuthDeg * DEG), n: Math.cos(b.azimuthDeg * DEG) };
const sign = Math.sign(slopePct);
return {
no: b.no, row: b.row, pair: pair ? (pair.from.structure === structure ? pair.to : pair.from) : undefined,
pairKind: pair?.kind,
azimuthInputDeg: b.inputAzimuthDeg ?? b.azimuthDeg,
azimuthCalcDeg: pair ? (pair.from.structure === structure && pair.from.no === b.no ? pair.fromCalcDeg : pair.toCalcDeg) : undefined,
azimuthCheckSec: pair ? (pair.from.structure === structure && pair.from.no === b.no ? pair.fromCheckSec : pair.toCheckSec) : undefined,
slopePct, slopeSource: source, aMm, bMm, L, W, T, tTyped: typed, T1, T2, tReq: req[i], h3: s.h3Mm,
dropMm: T - s.tRefMm, uphill: sign ? { e: sign * dir.e, n: sign * dir.n } : null, warnings,
};
});
}
/** Input problems of the settings, in Korean; empty when they can build solplates. */
export function soleSettingsIssues(settings: SoleSettings | undefined): string[] {
if (!settings) return [];
const out: string[] = [];
const positive = (v: unknown, label: string) => {
if (v !== undefined && !(typeof v === 'number' && Number.isFinite(v) && v > 0)) out.push(`${label}은(는) 0보다 커야 합니다.`);
};
positive(settings.h3Mm, '받침 높이 h3');
positive(settings.tRefMm, 'T_ref');
positive(settings.tMinMm, 'T_min');
if (settings.marginMm !== undefined && !(Number.isFinite(settings.marginMm) && settings.marginMm >= 0)) out.push('솔플 여유는 0 이상이어야 합니다.');
for (const [no, p] of Object.entries(settings.plates ?? {})) { positive(p.aMm, `받침 ${no} A`); positive(p.bMm, `받침 ${no} B`); }
for (const [row, t] of Object.entries(settings.tByRow ?? {})) positive(t, `${row} T`);
for (const [no, v] of Object.entries(settings.slopePct ?? {})) if (!Number.isFinite(v)) out.push(`받침 ${no} S는 숫자여야 합니다.`);
return out;
}
+15 -27
View File
@@ -1,25 +1,14 @@
/**
* 솔플레이트 기하 (승인 2026-10-23). Pure: the solplate of one bearing, from its block (position, rowSkew) and the
* 공통 규칙 values. The plan is L = A + margin across the 교각축 직각 (n) and W = B + margin along the 교축 (s). The top
* tapers across L: the thick end T1 is at the (−) side of the 교각축 직각 direction (n = −L/2), the thin end T2 at (+) (n = +L/2).
* 솔플레이트 기하 (승인 2026-10-23, 2026-11-06 결정). Pure: the solplate of one bearing, from its block (position, rowSkew)
* and the bearing's solplate plan. L runs along the block's c axis (the girder, by the azimuth) and W along a. The top
* tapers across L: the thick end T1 is on the 오르막 쪽 (c = t1Side·L/2), the thin end T2 at the other end.
* The underside is the bearing top (block top EL.X + h3). Lengths in m, as in the model.
*/
import { blockFramePoint, type BlockModel } from './geometry';
export interface SolplateParams {
/** 받침 평면 A (mm, across the 교각축 직각) and B (mm, along the 교축). */
A: number;
B: number;
/** 솔플 여유 (mm): L = A + margin, W = B + margin. */
margin: number;
/** 중심 두께 T (mm). */
T: number;
/** 기울기 S (%), negative as drawn: thickness changes by L·|S|/2 from the centre. */
slopePct: number;
}
import type { SolplatePlan } from './types';
export interface SolplateGeometry {
/** Length across the 교각축 직각 (m, = A + margin) and width along the 교축 (m, = B + margin). */
/** Length across the 교각축 직각 (m) and width along the 교축 (m). */
L: number;
W: number;
/** Thickness at the (−) side (T1) and the (+) side (T2) of the 교각축 직각 direction (m). */
@@ -33,17 +22,16 @@ export interface SolplateGeometry {
volume: number;
}
/** Solplate of the bearing at `block`, with the bearing height h3 (mm) and the 공통 규칙 parameters. */
export function solplateGeometry(block: BlockModel, h3: number, p: SolplateParams): SolplateGeometry {
const L = (p.A + p.margin) / 1000; // across the 교각축 직각 (n)
const W = (p.B + p.margin) / 1000; // along the 교축 (s)
// Thickness change from the centre to each side: L·|S|/2, with S in % (mm).
const drop = ((p.A + p.margin) * Math.abs(p.slopePct)) / 200 / 1000;
const T = p.T / 1000;
const T1 = T + drop;
const T2 = T - drop;
const bottom = block.padTop + h3 / 1000;
const thickAt = (c: number) => T1 + ((T2 - T1) * (c + L / 2)) / L;
/** Solplate of the bearing at `block`, from the pier's solplate plan (mm). */
export function solplateGeometry(block: BlockModel, plan: SolplatePlan): SolplateGeometry {
const L = plan.L / 1000; // across the 교각축 직각 (n)
const W = plan.W / 1000; // along the 교축 (s)
const T1 = plan.T1 / 1000;
const T2 = plan.T2 / 1000;
const bottom = block.padTop + plan.h3 / 1000;
// T1 at c = t1Side·L/2 (the 오르막 쪽), T2 at the other end.
const side = plan.t1Side ?? -1;
const thickAt = (c: number) => T1 + ((T2 - T1) * (side * -c + L / 2)) / L;
const corners: [number, number, number][] = [];
for (const a of [-W / 2, W / 2]) {
for (const c of [-L / 2, L / 2]) {
+1 -1
View File
@@ -22,7 +22,7 @@ export function buildTemplateWorkbook(): XLSX.WorkBook {
const bearing = [
note('받침 1행당 받침 1개. 전열·후열 수는 같아야 합니다. 위치는 전열 또는 후열.'),
note('z_m은 받침 EL(m), 방위각은 거더 방향입니다.'),
['교각명', '받침번호', 'x_m', 'y_m', 'z_m', '방위각', '위치'],
['교각명', '받침번호', 'x_m', 'y_m', 'z_m', '방위각', '위치', '교좌면_EL'],
];
XLSX.utils.book_append_sheet(wb, XLSX.utils.aoa_to_sheet(pier), '교각');
+47 -4
View File
@@ -1,6 +1,9 @@
import type { SoleSettings } from './sole-plate';
import type { CopingRebarSettings, TopSpliceSettings } from './rebar/coping-settings';
import type { ColumnRebarSettings } from './rebar/column-settings';
import type { FootingRebarSettings } from './rebar/footing-settings';
import type { CastInPlaceRebar } from './rebar/cast-in-place';
import type { CastInPlaceShapeSettings } from './cast-in-place-shape';
export type PierType = 'T형' | '2주형' | '3주형';
export type FoundationType = '말뚝기초' | '직접기초' | '현장타설말뚝';
@@ -21,6 +24,8 @@ export interface Bearing {
z: number;
azimuthDeg: number;
row: Row;
/** 교좌면 EL (m): the block bottom under this bearing. Absent = the 두부보 상면 slope (the coping top). */
seatEl?: number;
}
export interface PierInput {
@@ -48,8 +53,6 @@ export interface PierInput {
bearings: Bearing[];
/** Angles normalized with the imported X/Y swap. Absent in legacy saved projects = workbook frame. */
bearingAzimuthFrame?: 'model-xy';
/** 솔플레이트 plan per bearing (승인 2026-10-23): A, B (mm) and the slope S (%). Absent = no solplate. */
bearingPlans?: { no: number; A: number; B: number; S: number }[];
/** 확대기초 L (축 방향), W, 높이 h1 (mm); 비림 여유 and 비림 두께 (mm); 말뚝 외경 D and 두께 t (mm). Absent = the 1단계 constants. */
footingLengthMm?: number;
footingWidthMm?: number;
@@ -60,8 +63,17 @@ export interface PierInput {
pileThicknessMm?: number;
/** 말뚝 종류 (규격 목록): 강관 (default) or PHC. */
pileKind?: '강관' | 'PHC';
/** 공통 규칙 for the solplate: centre thickness T, 솔플 여유 margin (mm), bearing height h3 (mm). */
solplate?: { T: number; margin: number; h3: number };
/** Obsolete C안 (2026-11-03) rule: kept so that old project files still open; no longer used (replaced by `sole`, 2026-11-06). */
solplate?: SolplateRule;
/** 솔플레이트 입력 (결정 2026-11-06, 시안): h3, T_ref, T_min, 여유, 받침별 A × B, 열 T·받침 S 직접 수정. Absent = 기본값. */
sole?: SoleSettings;
/** 계산 방위각을 적용한 받침: 받침번호 → 방위각 (deg, model 진북). Absent = 엑셀 입력값 (2026-11-06). */
azimuthApplied?: Record<string, number>;
/** 다주형 기둥 위치 입력 (Q-M10 미결): 끝 ~ 기둥1 중심 e_c, 기둥 간격 s_c (mm). Absent = L/5 규칙. */
columnEndOffsetMm?: number;
columnSpacingMm?: number;
/** 교각 종단경사 (소수, (−) = 교량진행방향으로 낮아짐; Pier 시트 "종단경사" 열, Q-SH2). 짝 받침이 없을 때 S에 쓴다. */
longitudinalSlope?: number;
/** 지반·지층 (승인 2026-10-26): 위치, 지하수위 고려와 EL, 지층별 행 (위에서 아래, 행의 EL = 그 층의 하단 EL).
* Absent = 육상, 지하수위 없음, 지층 행 없음. */
groundPosition?: GroundPosition;
@@ -88,7 +100,38 @@ export interface PierInput {
columnRebar?: Partial<ColumnRebarSettings>;
/** 확대기초 배근 입력 (승인 2026-10-26, 시안 ③ 확대기초). Absent = the 확대기초철근 (1).md defaults. */
footingRebar?: Partial<FootingRebarSettings>;
/** 현장타설 전용 입력. 상세 규칙 확정 전에는 계산된 배근으로 취급하지 않는다. */
castInPlaceRebar?: CastInPlaceRebar;
strataRows?: StrataRow[];
/** 현장타설 형상·암반·희생강관 입력. */
castInPlaceShape?: CastInPlaceShapeSettings;
}
/** 솔플레이트 규칙 of one pier (C안, 2026-11-03): 받침높이 (solplate included), plan size (mm) and the slope S (%). */
export interface SolplateRule {
/** 받침높이 H (mm): from the mortar top (EL.X) to the solplate top, solplate included (H = h3 + T). */
heightMm: number;
/** 교직 length L (mm, across the 교각축 직각 (n)). */
lengthMm: number;
/** 교축 width W (mm, along the 교축 (s)). */
widthMm: number;
/** 기울기 S (%), negative as drawn. */
slopePct: number;
}
/** The solplate of one bearing (mm): T is the row thickness, T1 the 오르막 쪽 end, h3 the bearing body (2026-11-06). */
export interface SolplatePlan {
L: number;
W: number;
slopePct: number;
T: number;
/** Thickness at the 오르막 쪽 (T1) and the other end (T2). */
T1: number;
T2: number;
/** Side of the block's c axis (L direction) where T1 sits: −1 = c = −L/2, +1 = c = +L/2. Absent = −1. */
t1Side?: -1 | 1;
/** Bearing body height h3 (mm) between the mortar top and the solplate underside. */
h3: number;
}
export type GroundPosition = '육상' | '하상' | '해상';
+23
View File
@@ -3,6 +3,8 @@ import type { Issue, PierInput, PierType } from './types';
import { validateGround, validatePileEmbedment, validateStrataNValues } from './ground';
import { integratedCheck } from './bearing-layout';
import { checkBlockRebar } from './rebar/block-check';
import { castSettingsIssues } from './rebar/cast-in-place';
import { castShape, castShapeIssues } from './cast-in-place-shape';
const EXPECTED_COLUMNS: Record<PierType, number> = { 'T형': 1, '2주형': 2, '3주형': 3 };
@@ -16,6 +18,13 @@ export const NOT_IMPLEMENTED_RULES = [
/** Input-level checks (spec §10 E01, E02). Returns false when the pier must not be built. */
export function validateInput(input: PierInput, issues: Issue[]): boolean {
let ok = true;
if (input.foundationType === '현장타설말뚝') {
for (const message of castShapeIssues(input.castInPlaceShape)) issues.push({ code: 'CIP02', level: 'W', pier: input.name, message });
const shape = castShape(input);
if (shape.casingOn && shape.casingTopEl <= shape.casingBottomEl) issues.push({ code: 'CIP02', level: 'W', pier: input.name, message: '희생강관 상단 EL은 하단 EL보다 높아야 합니다.' });
if (shape.weatheredRockEl < shape.bedrockEl) issues.push({ code: 'CIP02', level: 'W', pier: input.name, message: '풍화암 EL이 기반암 EL보다 낮습니다. 암반 입력을 확인하세요.' });
for (const message of castSettingsIssues(input.castInPlaceRebar, input.pileLengthMm, input.columnDiameterMm ?? 2000)) issues.push({ code: 'CIP01', level: 'W', pier: input.name, message });
}
if (input.columnCount !== EXPECTED_COLUMNS[input.type]) {
issues.push({
code: 'E01',
@@ -38,6 +47,20 @@ export function validateInput(input: PierInput, issues: Issue[]): boolean {
/** Geometry-level checks (spec §10 W01–W05, W07, I01) on a built model. */
export function validatePier(input: PierInput, model: PierModel, issues: Issue[]): void {
// W10 (2026-11-03): the input 교좌면 EL should match the 두부보 상면 slope within 5 mm; the input wins.
for (const b of model.blocks) {
const seat = input.bearings.find((r) => r.no === b.no)?.seatEl;
if (seat === undefined) continue;
const diffMm = (seat - b.seatCalc) * 1000;
if (Math.abs(diffMm) > 5) {
issues.push({
code: 'W10',
level: 'W',
pier: input.name,
message: `${b.row} 받침 ${b.no}번 교좌면 EL ${seat.toFixed(3)}은(는) 두부보 상면 계산값 ${b.seatCalc.toFixed(3)}과 ${diffMm.toFixed(1)} mm 차이가 납니다 (허용 5 mm). 입력값을 씁니다.`,
});
}
}
const ip2Gap = Math.hypot(model.ip2Input.e - model.ip2Calc.e, model.ip2Input.n - model.ip2Calc.n);
if (ip2Gap > 0.001) {
issues.push({
+3381 -510
View File
File diff suppressed because it is too large. Load diff
+164 -26
View File
@@ -212,17 +212,26 @@ select {
list-style: none;
padding: 0;
margin: 0;
max-height: 70vh;
overflow: auto;
max-height: 300px;
overflow-y: auto;
/* The side panel is a flex column; without this the list shrinks to 0 height and shows no rows. */
flex-shrink: 0;
}
/* Rows: name + badge (2 columns). Rows with a checkbox get a 28 px first column. */
.pier-items li {
display: grid;
grid-template-columns: 28px 1fr auto;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: 6px;
gap: 8px;
border-bottom: 1px solid var(--line);
padding: 2px 0;
border-radius: 6px;
padding: 2px 6px 2px 0;
}
.pier-items li:has(> input[type='checkbox']) {
grid-template-columns: 28px minmax(0, 1fr) auto;
gap: 6px;
}
.pier-items input[type='checkbox'] {
@@ -525,6 +534,21 @@ summary {
height: 24px;
}
.sheet-pier label { flex: 1; cursor: pointer; }
.sheet-pier input:disabled + label { color: var(--muted); cursor: default; }
.material-fields { border: 0; padding: 0; margin: 16px 0 0; min-width: 0; }
.material-fields legend { color: var(--muted); font-weight: 600; padding: 0; margin-bottom: 8px; }
.material-fields .mat { display: grid; grid-template-columns: minmax(0, 1fr) minmax(88px, 1fr); align-items: center; gap: 8px; margin-bottom: 8px; }
.material-fields input, .material-fields select { width: 100%; min-width: 0; }
.review-tasks { display: grid; gap: 12px; }
.review-tasks li > p { margin: 4px 0 8px; }
.workspace-tools { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; padding: 8px; background: var(--panel); border: 1px solid var(--line); border-radius: 6px; }
.model-height-control { display: flex; align-items: center; gap: 8px; font-size: 12px; flex: 1 1 240px; }
.model-height-control input { width: 100%; min-width: 72px; flex: 1; accent-color: var(--primary); }
.model-height-control output { min-width: 48px; white-space: nowrap; font-variant-numeric: tabular-nums; }
.workspace-tools button { flex: 0 0 auto; }
.rules {
margin: 0;
padding-left: 18px;
@@ -606,25 +630,6 @@ body { background: #eef1f4; font-family: 'Noto Sans KR', system-ui, sans-serif;
.top .brand strong { font-size: 18px; letter-spacing: 0.01em; }
.top .brand > span:not(.proj) { font-size: 12px; color: #b8c2ce; }
.proj { padding-left: 14px; border-left: 1px solid #33404f; color: #ffffff; font-size: 13px; font-weight: 700; }
.top { display: grid; grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr); flex-wrap: nowrap; }
.top > .steps { justify-self: center; }
.top > .actions { justify-self: end; }
.top .brand { flex: 1 1 0; min-width: 0; align-items: center; }
.top .brand > strong, .top .brand > span:not(.proj) { flex-shrink: 0; white-space: nowrap; }
.top .proj { min-width: 0; overflow: hidden; overflow-wrap: anywhere; line-height: 1.5; max-height: 3em; display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; line-clamp: 2; }
.top .steps, .top .actions { flex: 0 0 auto; flex-wrap: nowrap; align-items: center; }
.top .steps .step, .top .actions button, .top .savest { white-space: nowrap; }
@media (max-width: 1600px) {
.top { display: grid; grid-template-columns: minmax(0, 1fr) auto; }
.top .brand { grid-column: 1; grid-row: 1; }
.top .actions { grid-column: 2; grid-row: 1; justify-content: flex-end; }
.top .steps { grid-column: 1 / -1; grid-row: 2; }
}
@media (max-width: 900px) {
.top { grid-template-columns: minmax(0, 1fr); }
.top .steps, .top .actions { max-width: 100%; overflow-x: auto; }
.top .actions { grid-column: 1; grid-row: 3; justify-content: flex-start; justify-self: start; }
}
.steps .step { color: #dce3ea; min-height: 44px; padding: 4px 14px; border-radius: 6px; background: transparent; border: 0; }
.steps .step.current { background: #ffffff; color: #18212b; }
.steps .step em { font-style: normal; color: #b8c2ce; font-size: 12px; }
@@ -649,7 +654,6 @@ body { background: #eef1f4; font-family: 'Noto Sans KR', system-ui, sans-serif;
.pier-tools { display: flex; align-items: center; gap: 8px; padding: 0 4px 8px; }
.pier-tools select { border: 1px solid #c3cbd4; border-radius: 6px; background: #ffffff; min-height: 36px; }
.phead { display: grid; grid-template-columns: 1fr auto; gap: 8px; padding: 0 10px 6px; font-size: 12px; color: #55606e; border-bottom: 1px solid #d5dbe2; }
.pier-items li { display: grid; grid-template-columns: 1fr auto; align-items: center; gap: 8px; border-radius: 6px; padding-right: 6px; }
.pier-items li:has(.pier-name[aria-pressed='true']) { background: #e3edf8; }
.pier-name { width: 100%; border: 0; background: transparent; color: #18212b; text-align: left; font-size: 13px; min-height: 44px; padding: 0 6px; }
.pier-name[aria-pressed='true'] { color: #123e70; font-weight: 700; }
@@ -692,7 +696,6 @@ body { background: #eef1f4; font-family: 'Noto Sans KR', system-ui, sans-serif;
.chip-opt { min-height: 36px; padding: 0 10px; border: 1px solid #c3cbd4; border-radius: 16px; background: #ffffff; color: #18212b; font-size: 13px; cursor: pointer; }
.chip-opt[aria-pressed='true'] { background: #1f5fa8; border-color: #1f5fa8; color: #ffffff; font-weight: 700; }
.chip-opt:disabled { color: #8a95a3; background: #f2f4f7; border-style: dashed; cursor: default; }
.pier-items { max-height: 300px; overflow-y: auto; }
/* 열별 본수 (C4) */
.pile-rows { margin-top: 10px; display: grid; gap: 6px; }
.pile-row { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
@@ -764,6 +767,8 @@ body { background: #eef1f4; font-family: 'Noto Sans KR', system-ui, sans-serif;
.grid > #viewer-block .canvas { flex: 1 1 auto; height: auto; min-height: 240px; }
/* ②–④: the model keeps a fixed band at the top; the table scrolls below it. */
.center-col { display: grid; grid-template-rows: clamp(280px, 48vh, 560px) minmax(0, 1fr); gap: 12px; }
.review-workspace { grid-template-rows: auto minmax(180px, var(--review-model-height, 240px)) minmax(0, 1fr); }
.review-workspace.model-collapsed { grid-template-rows: auto minmax(0, 1fr); }
.viewer-slot { min-height: 0; height: 100%; display: flex; flex-direction: column; }
.viewer-slot > .viewer { flex: 1 1 auto; min-height: 0; display: flex; flex-direction: column; }
.viewer-slot .canvas { flex: 1 1 auto; height: auto; min-height: 180px; }
@@ -777,9 +782,14 @@ body { background: #eef1f4; font-family: 'Noto Sans KR', system-ui, sans-serif;
.grid > .panel, .grid > .center-col { max-height: 60vh; overflow: auto; }
.center-col { display: flex; flex-direction: column; gap: 16px; }
.viewer-slot .canvas { height: 60vh; min-height: 420px; flex: none; }
.grid > .review-workspace { max-height: none; }
.review-workspace .viewer-slot .canvas { height: var(--review-model-height, 240px); min-height: 180px; }
.review-workspace > .viewer { max-height: 75vh; overflow: auto; }
.rebar-table thead th { position: sticky; top: 0; background: #ffffff; z-index: 1; }
}
.review-workspace.model-collapsed #slot-rebar { display: none; }
/* 3D 영역 (승인 2026-10-23): 제목은 교각 목록으로. 좌측 스택(부재 표시 · 시점), 우측 스택(색 기준 · 원점)을 캔버스 위에 둠. */
#viewer-block { position: relative; }
#viewer-block .canvas { position: relative; }
@@ -796,6 +806,8 @@ body { background: #eef1f4; font-family: 'Noto Sans KR', system-ui, sans-serif;
.viewer-stack { display: flex; flex-direction: column; gap: 8px; width: 168px; z-index: 5; pointer-events: none; }
.viewer-stack > * { pointer-events: auto; }
#viewer-block .viewer-stack-right { position: absolute; top: 8px; right: 8px; width: 180px; align-items: stretch; }
.review-workspace #viewer-block .viewer-stack-right { bottom: 8px; max-height: calc(100% - 16px); overflow-y: auto; overflow-x: hidden; overscroll-behavior: contain; }
.review-workspace #all-toggle { display: none; }
#viewer-block .info-panel { background: rgba(255, 255, 255, 0.92); border: 1px solid #d5dde6; border-radius: 6px; box-shadow: 0 4px 14px rgba(0, 0, 0, 0.12); padding: 0 8px 8px; font-size: 13px; }
#viewer-block .info-panel > summary { min-height: 44px; display: flex; align-items: center; cursor: pointer; font-weight: 700; color: #18212b; list-style-position: outside; }
#viewer-block .ground-map-options { display: flex; flex-direction: column; gap: 4px; }
@@ -844,6 +856,14 @@ body { background: #eef1f4; font-family: 'Noto Sans KR', system-ui, sans-serif;
.footing-grid { grid-template-columns: 1fr; }
}
/* 교량받침 삽도: the plan on the left, the section on its right. Narrow screens stack both. */
.bearing-sketch { display: grid; grid-template-columns: minmax(240px, 1fr) minmax(240px, 1fr); gap: 12px; align-items: start; margin-top: 12px; }
.bearing-sketch figure { margin: 0; }
.bearing-sketch svg { display: block; width: 100%; max-width: none !important; height: auto; background: #ffffff; border: 1px solid #d5dde6; border-radius: 6px; }
@media (max-width: 800px) {
.bearing-sketch { grid-template-columns: 1fr; }
}
/* 입력값: one row per pier; the table scrolls sideways in the narrow left panel. */
.pier-table-wrap { overflow-x: auto; margin-bottom: 8px; }
#inputs-all { font-size: 12px; width: 100%; }
@@ -871,3 +891,121 @@ body { background: #eef1f4; font-family: 'Noto Sans KR', system-ui, sans-serif;
.copy-panel .cp-chips .chk:has(input:disabled) { color: #8a94a3; background: #f4f6f9; }
.quantity-total { margin-bottom: 16px; }
/* 교대 삽도 (벽체·흉벽 개략 단면): fills the panel width; the labels follow the card's values. */
/* ② 형상계획: with an abutment selected, only the abutment card shows; the pier cards are hidden (2026-11-03). */
#view-plan.abut-selected .members-title,
#view-plan.abut-selected #members,
#view-plan.abut-selected section[aria-labelledby="member-title"],
#view-plan.abut-selected #copy-panel { display: none; }
/* ② 부재 · 교대 list: shown only while an abutment is selected (2026-11-03). */
.abut-members-title,
#abut-members { display: none; }
#view-plan.abut-selected .abut-members-title,
#view-plan.abut-selected #abut-members { display: block; }
#abut-members .pier-name span:first-child { display: block; }
/* 교대 형상 입력: a 3-column grid of input · default (시안 ②), the 확인 필요 notes at the end (2026-11-03). */
#abut-panel-plan-shape, #abut-panel-shape { display: grid; grid-template-columns: minmax(0, 1.4fr) minmax(0, 1fr) minmax(0, 1fr); gap: 6px 10px; align-items: center; }
#abut-panel-plan-shape > .abut-qa { grid-column: 1 / -1; }
.abut-qa { border-top: 1px solid #d5dbe2; padding-top: 8px; }
.abut-qa p { margin: 3px 0; }
/* 교대 구간 표 (시안 하단) and the 형상 card shown outside the collapsed editor (2026-11-03). */
#abut-panel-plan-shape.abut-shape-view { display: grid; grid-template-columns: minmax(0, 1.4fr) minmax(0, 1fr) minmax(0, 1fr); gap: 6px 10px; align-items: center; }
#abut-panel-plan-shape > .abut-seg { grid-column: 1 / -1; width: 100%; border-collapse: collapse; font-size: 12px; }
#abut-panel-plan-shape > .abut-seg th, #abut-panel-plan-shape > .abut-seg td { border-bottom: 1px solid #e6eaef; padding: 4px 6px; text-align: right; font-family: 'JetBrains Mono', ui-monospace, monospace; }
#abut-panel-plan-shape > .abut-seg th { color: #55606e; font-weight: 500; font-family: inherit; }
/* ② 교량받침 카드 (2026-11-03): tables and notes span the card; rule inputs use the 3-column grid; wide tables scroll sideways. */
#abut-panel-plan-bearings { display: grid; grid-template-columns: minmax(0, 1.4fr) minmax(0, 1fr) minmax(0, 1fr); gap: 6px 10px; align-items: center; }
#abut-panel-plan-bearings > p, #abut-panel-plan-bearings > table { grid-column: 1 / -1; }
#abut-panel-plan-bearings > .abut-seg { display: block; overflow-x: auto; border-collapse: collapse; font-size: 12px; }
#abut-panel-plan-bearings > .abut-seg th, #abut-panel-plan-bearings > .abut-seg td { border-bottom: 1px solid #e6eaef; padding: 4px 6px; text-align: right; white-space: nowrap; font-family: 'JetBrains Mono', ui-monospace, monospace; }
#abut-panel-plan-bearings > .abut-seg th { color: #55606e; font-weight: 500; font-family: inherit; }
/* ② 교대 카드 under the 3D view (2026-11-03): pickers, the three 받침 삽도 side by side, full-width rows. */
#abut-panel-plan .abut-bearing-figs { grid-column: 1 / -1; display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 10px; }
#abut-panel-plan .abut-fig { margin: 0; background: #f6f8fa; border: 1px solid #d5dbe2; border-radius: 6px; padding: 6px; }
#abut-panel-plan .abut-fig svg { display: block; width: 100%; height: auto; }
#abut-panel-plan > .edit-row { padding: 0 12px 10px; }
@media (max-width: 900px) { #abut-panel-plan .abut-bearing-figs { grid-template-columns: minmax(0, 1fr); } }
/* ② 벽체·흉벽 / 확대기초·말뚝 카드 (2026-11-03): the two 삽도 side by side, notes and tables across the card. */
#abut-panel-plan-footing.abut-shape-view { display: grid; grid-template-columns: minmax(0, 1.4fr) minmax(0, 1fr) minmax(0, 1fr); gap: 6px 10px; align-items: center; }
#abut-panel-plan .abut-wall-figs { grid-column: 1 / -1; display: grid; grid-template-columns: minmax(0, 2fr) minmax(0, 3fr); gap: 10px; }
#abut-panel-plan-shape > p, #abut-panel-plan-shape > .abut-wall-figs { grid-column: 1 / -1; }
#abut-panel-plan .abut-note-warn { color: #8a5a00; }
#abut-panel-plan .abut-memo { background: #f6f8fa; border: 1px solid #d5dbe2; border-radius: 6px; padding: 8px; }
@media (max-width: 900px) { #abut-panel-plan .abut-wall-figs { grid-template-columns: minmax(0, 1fr); } }
/* ② 확대기초 · 말뚝 카드 (2026-11-04): the plan drawing, notes and the 열별 본수 table span the card. */
#abut-panel-plan-footing > figure, #abut-panel-plan-footing > p, #abut-panel-plan-footing > table { grid-column: 1 / -1; }
#abut-panel-plan-footing > .abut-seg { border-collapse: collapse; font-size: 12px; width: 100%; }
#abut-panel-plan-footing > .abut-seg th, #abut-panel-plan-footing > .abut-seg td { border-bottom: 1px solid #e6eaef; padding: 4px 6px; text-align: left; font-family: 'JetBrains Mono', ui-monospace, monospace; }
#abut-panel-plan-footing > .abut-seg th { color: #55606e; font-weight: 500; font-family: inherit; }
#abut-panel-plan .abut-footing-fig { max-width: 760px; }
/* 시안 선택 줄 (.grp · .opts · .opt / .optt, 2026-11-04): icon tiles and text tiles, the 기본 badge, dashed when not selectable. */
#abut-panel-plan .abut-grp { grid-column: 1 / -1; display: flex; flex-direction: column; gap: 6px; margin: 4px 0; }
.abut-grp-title { font-size: 12px; color: #55606e; }
.abut-opts { display: flex; flex-wrap: wrap; gap: 8px; }
.abut-opt { position: relative; font: inherit; font-size: 12px; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 2px; width: 84px; min-height: 56px; padding: 6px 4px; border: 1px solid #c3cbd4; border-radius: 6px; background: #ffffff; color: #18212b; cursor: pointer; }
.abut-optt { position: relative; font: inherit; font-size: 13px; min-width: 64px; min-height: 44px; padding: 0 12px; border: 1px solid #c3cbd4; border-radius: 6px; background: #ffffff; color: #18212b; cursor: pointer; }
.abut-opt.on, .abut-optt.on { border: 2px solid #1f5fa8; background: #f2f7fc; color: #123e70; font-weight: 700; }
.abut-opt:disabled, .abut-optt:disabled { cursor: not-allowed; border-style: dashed; color: #5f6a76; background: #f6f8fa; }
.abut-def { position: absolute; top: -9px; right: 4px; font-size: 11px; font-weight: 600; line-height: 1; padding: 3px 6px; border-radius: 8px; background: #4a5868; color: #ffffff; }
.abut-tbdn { display: block; font-size: 12px; color: #55606e; }
/* ② 날개벽 카드 (2026-11-04): tiles and the three 삽도 across the card, then the per-side inputs in the 3-column grid. */
#abut-panel-plan-wing { display: grid; grid-template-columns: minmax(0, 1.4fr) minmax(0, 1fr) minmax(0, 1fr); gap: 6px 10px; align-items: center; }
#abut-panel-plan-wing > p, #abut-panel-plan-wing > .abut-wall-figs { grid-column: 1 / -1; }
#abut-panel-plan .abut-wing-figs { grid-template-columns: minmax(0, 1.2fr) minmax(0, 1.2fr) minmax(0, 1fr); }
@media (max-width: 1100px) { #abut-panel-plan .abut-wing-figs { grid-template-columns: minmax(0, 1fr); } }
/* ② 날개벽 형식 1행 (2026-11-04): 좌 · 우 묶음을 나란히, 작은 타일, 편집 중인 측은 진한 테두리. */
#abut-panel-plan .abut-wing-forms { grid-column: 1 / -1; display: flex; flex-wrap: wrap; align-items: flex-end; gap: 8px 18px; }
#abut-panel-plan .abut-wing-forms > .abut-grp-title { flex-basis: 100%; }
#abut-panel-plan .abut-wing-forms .abut-grp { margin: 0; padding: 6px 8px 8px; border: 1px solid transparent; border-radius: 8px; }
#abut-panel-plan .abut-wing-forms .abut-grp.editing { border-color: #1f5fa8; background: #f8fbfe; }
#abut-panel-plan .abut-wing-forms .abut-grp .abut-grp-title { cursor: pointer; font-weight: 600; color: #18212b; }
#abut-panel-plan .abut-grp.compact .abut-opt { width: 64px; min-height: 48px; padding: 4px 2px; }
#abut-panel-plan-slab > p, #abut-panel-plan-slab > .abut-wall-figs { grid-column: 1 / -1; }
#abut-panel-plan-ground > p, #abut-panel-plan-ground > .table-scroll, #abut-panel-plan-ground > figure { grid-column: 1 / -1; }
#abut-panel-plan-ground > figure { margin: 4px 0; max-width: 560px; }
/* ③ 교대 배근 (2026-11-04) */
#rebar-table .abut-rebar-head { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; margin: 4px 0; }
#rebar-table .abut-rebar-head .abut-optt { border: 1px solid #c9d2dc; border-radius: 8px; background: #fff; padding: 4px 10px; font-size: 12px; cursor: pointer; }
#rebar-table .abut-rebar-head .abut-optt.on { border-color: #1f5fa8; background: #f0f6fd; font-weight: 600; }
#rebar-table .abut-rebar-fig { margin: 4px 0; }
#rebar-table .abut-rebar-inputs { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 8px 12px; margin: 8px 0; }
#rebar-table .abut-rebar-inputs > .lbl { grid-column: 1 / -1; margin: 0; }
#rebar-table .abut-rebar-inputs label { display: block; font-size: 12px; color: #55606e; }
#rebar-table .abut-rebar-inputs input { width: 100%; }
#rebar-table .abut-rebar-auto { display: flex; flex-direction: column; gap: 2px; padding: 4px 6px; background: #f5f7f9; border-radius: 6px; }
#rebar-table .abut-rebar-tablehead { display: flex; justify-content: space-between; align-items: center; margin: 8px 0 4px; }
#rebar-table .abut-grp { display: flex; flex-direction: column; gap: 6px; margin: 6px 0; }
#rebar-table .abut-opts { display: flex; flex-wrap: wrap; gap: 8px; }
/* Compact desktop header; the file controls retain their native button behaviour. */
.top { flex-wrap: nowrap; }
.top .brand { min-width: 0; flex: 1; }
.top .brand strong { flex-shrink: 0; }
.top .proj { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.top .steps, .top .actions { flex-wrap: nowrap; flex-shrink: 0; align-items: center; white-space: nowrap; }
.top .step em:empty { display: none; }
.file-menu { position: relative; margin: 0; }
.file-menu > summary { cursor: pointer; list-style: none; gap: 8px; }
.file-menu > summary::after { content: '▾'; }
.file-menu-items { position: absolute; right: 0; top: 100%; z-index: 20; display: flex; flex-direction: column; padding: 8px; background: var(--panel); border: 1px solid var(--line); border-radius: 6px; }
.file-menu-items .btn { white-space: nowrap; color: var(--text); }
[data-cast-part='combined'] table .edit-row { display: block; }
[data-cast-part='combined'] table label { display: block; min-width: 0; font-size: 12px; }
[data-cast-part='combined'] table input { width: 110px; }
@media (max-width: 1100px) {
.top { flex-wrap: wrap; }
.top .brand { flex-basis: 100%; }
.top .steps, .top .actions { flex-wrap: wrap; flex-shrink: 1; }
}
+105
View File
@@ -0,0 +1,105 @@
/**
* 교대 받침 상세 삽도 (2026-11-03, 시안 ② 교량받침 카드): 평면, 교축 방향 단면, 솔플레이트 상세. 순수 문자열 함수.
* 입력은 받침 표에서 이미 계산한 값뿐이다(교대 모델을 다시 읽지 않는다). 치수는 mm, EL은 m.
*/
export interface AbutBearingDetail {
no: number;
/** 블럭 평면 (정사각), 몰탈패드 평면 (정사각), mm. */
blockMm: number;
padMm: number;
/** 솔플레이트 교직 L × 교축 W (mm). 받침 본체 평면은 입력이 없어 그리지 않는다. */
L: number;
W: number;
/** 높이 (mm): 블럭 콘크리트 h1, 패드 h2, 받침 h3, 솔플레이트 h4 = T. */
h1: number;
h2: number;
h3: number;
h4: number;
T1: number;
T2: number;
/** 블럭 평면 회전 (deg), 교대축 직각 기준. */
rotDeg: number;
/** 솔플레이트 경사 (%). */
S: number;
seatEl: number;
z: number;
}
const INK = '#18212b';
const MUTED = '#55606e';
const ORANGE = '#c46a1a';
const text = (x: number, y: number, s: string, anchor = 'middle', fill = INK) =>
`<text x="${x.toFixed(1)}" y="${y.toFixed(1)}" text-anchor="${anchor}" font-size="11" fill="${fill}" font-family="JetBrains Mono, monospace">${s}</text>`;
const square = (cx: number, cy: number, w: number, h: number, style: string) =>
`<rect x="${(cx - w / 2).toFixed(1)}" y="${(cy - h / 2).toFixed(1)}" width="${w.toFixed(1)}" height="${h.toFixed(1)}" ${style}/>`;
/** 평면: 블럭 1200, 몰탈패드 1000 (점선), 솔플레이트 L × W (주황). 블럭은 받침 방위각만큼 돈다. */
export function abutBearingPlanSvg(d: AbutBearingDetail): string {
const k = 0.17; // px per mm
const cx = 150;
const cy = 130;
const turn = `transform="rotate(${(-d.rotDeg).toFixed(3)} ${cx} ${cy})"`;
return [
`<svg viewBox="0 0 300 270" width="100%" role="img" aria-label="받침 ${d.no} 평면: 블럭 ${d.blockMm}, 몰탈패드 ${d.padMm}, 솔플레이트 ${d.L} × ${d.W}, 회전 ${d.rotDeg.toFixed(2)}°">`,
`<g ${turn}>`,
square(cx, cy, d.blockMm * k, d.blockMm * k, `fill="#dfe5ec" stroke="${MUTED}"`),
square(cx, cy, d.padMm * k, d.padMm * k, `fill="none" stroke="${MUTED}" stroke-dasharray="4 3"`),
square(cx, cy, d.L * k, d.W * k, `fill="#f0c9a0" fill-opacity="0.6" stroke="${ORANGE}"`),
`</g>`,
text(cx, cy - (d.blockMm * k) / 2 - 8, `${d.blockMm}`),
text(cx, cy + 4, `솔플 ${d.L} × ${d.W}`),
text(cx, cy + (d.blockMm * k) / 2 + 16, `패드 ${d.padMm} (점선)`, 'middle', MUTED),
text(cx, 262, `받침 ${d.no} · 회전 ${d.rotDeg.toFixed(2)}° (방위각)`, 'middle', MUTED),
'</svg>',
].join('');
}
/** 교축 방향 단면 (높이 2배 과장): 블럭 h1, 패드 h2, 받침 h3, 솔플레이트 h4. E = h1 + h2 + h3 + h4. */
export function abutBearingSectionSvg(d: AbutBearingDetail): string {
const kx = 0.17;
const ky = 0.34; // 2× exaggerated heights
const cx = 150;
const base = 215;
const E = d.h1 + d.h2 + d.h3 + d.h4;
const layer = (bottom: number, h: number, w: number, style: string) =>
`<rect x="${(cx - (w * kx) / 2).toFixed(1)}" y="${(base - (bottom + h) * ky).toFixed(1)}" width="${(w * kx).toFixed(1)}" height="${(h * ky).toFixed(1)}" ${style}/>`;
const at = (mm: number) => base - mm * ky;
const right = cx + (d.blockMm * kx) / 2 + 6;
return [
`<svg viewBox="0 0 330 250" width="100%" role="img" aria-label="받침 ${d.no} 단면: h1 ${d.h1}, h2 ${d.h2}, h3 ${d.h3}, h4 ${d.h4}, E ${E}">`,
`<line x1="20" y1="${base}" x2="300" y2="${base}" stroke="${MUTED}"/>`,
layer(0, d.h1, d.blockMm, `fill="#dfe5ec" stroke="${MUTED}"`),
layer(d.h1, d.h2, d.padMm, `fill="#c9d1da" stroke="${MUTED}"`),
layer(d.h1 + d.h2, d.h3, d.L - 100, `fill="#b9c3ce" stroke="${INK}"`),
layer(d.h1 + d.h2 + d.h3, d.h4, d.L, `fill="#f0c9a0" stroke="${ORANGE}"`),
text(right, at(d.h1 / 2) + 4, `h1 ${d.h1}`, 'start'),
text(right, at(d.h1 + d.h2 / 2) + 4, `h2 ${d.h2}`, 'start'),
text(right, at(d.h1 + d.h2 + d.h3 / 2) + 4, `h3 ${d.h3}`, 'start'),
text(right, at(E - d.h4 / 2) + 4, `h4 ${d.h4}`, 'start'),
text(cx, at(E) - 10, `E = ${E}`),
text(24, base + 16, `교좌면 EL.${d.seatEl.toFixed(3)}`, 'start', MUTED),
text(24, at(d.h1 + d.h2) - 4, `z EL.${d.z.toFixed(3)}`, 'start', MUTED),
text(cx, 244, '교축 방향 · 높이 2배 과장', 'middle', MUTED),
'</svg>',
].join('');
}
/** 솔플레이트 상세: 길이 L, 두께 T1 → T2 (경사 S). 두께는 과장해서 그린다. */
export function abutSolplateSvg(d: AbutBearingDetail): string {
const kx = 0.3;
const ky = 2.2; // thickness exaggerated
const x0 = 45;
const x1 = x0 + d.L * kx;
const base = 120;
return [
`<svg viewBox="0 0 330 190" width="100%" role="img" aria-label="받침 ${d.no} 솔플레이트: L ${d.L}, T1 ${d.T1}, T2 ${d.T2}, S ${d.S.toFixed(3)} %">`,
`<polygon points="${x0},${base} ${x1.toFixed(1)},${base} ${x1.toFixed(1)},${(base - d.T2 * ky).toFixed(1)} ${x0},${(base - d.T1 * ky).toFixed(1)}" fill="#f0c9a0" stroke="${ORANGE}"/>`,
text(x0 - 4, base - (d.T1 * ky) / 2 + 4, `T1 ${d.T1}`, 'end'),
text(x1 + 4, base - (d.T2 * ky) / 2 + 4, `T2 ${d.T2}`, 'start'),
text((x0 + x1) / 2, base + 18, `L ${d.L} · W ${d.W}`),
text((x0 + x1) / 2, base - Math.max(d.T1, d.T2) * ky - 10, `S ${d.S.toFixed(3)} % · T ${d.h4}`),
text(165, 180, '솔플레이트 상세 · 두께 과장 · 가정 (QA-15)', 'middle', MUTED),
'</svg>',
].join('');
}
+73
View File
@@ -0,0 +1,73 @@
/**
* 교대 받침블럭 배근 삽도 (2026-11-05, 시안 ③ 받침블럭 카드, 받침블럭철근.md §8): 평면 (블럭 1개, S2 실선 · S1 파선),
* 단면 (교대축 방향 절단: S1 아래 줄, S2 위 줄, 다리, 블럭 · 패드 · 교좌면). 순수 문자열 함수. 철근은 block.ts paths에서 그린다.
* 색: S1 (주철근, 아래) #0F5C66, S2 (보조, 위) #B4531F.
*/
import type { AbutBlockRebar, AbutBlockPath } from '../core/abut/rebar/block';
import { balloon, CONC, MUTED, NO_PICK, opacity, text, WALL, type AbutRebarPick } from './abut-footing-rebar-svg';
export const ABUT_BLOCK_COLORS = { s1: '#0F5C66', s2: '#B4531F' } as const;
const C = ABUT_BLOCK_COLORS;
/** 블럭 한 개의 경로만 쓴다 (첫 블럭, 방위각 0 가정). */
function firstBlock(paths: AbutBlockPath[]): AbutBlockPath[] {
const b = paths[0]?.block;
return paths.filter((p) => p.block === b);
}
/** 평면: 블럭 1200 × 1200. S2 (교대축 간격, 교대축 직각으로 뻗음) 실선, S1 (교대축 직각 간격, 교대축 방향) 파선. */
export function abutBlockPlanSvg(r: AbutBlockRebar, pick: AbutRebarPick = NO_PICK, blockCenter: [number, number] = [0, 0]): string {
const ps = firstBlock(r.paths);
const k = 0.2;
const X = (s: number) => 40 + (s - blockCenter[0] + 600) * k;
const Y = (t: number) => 40 + (blockCenter[1] + 600 - t) * k;
const parts: string[] = [];
parts.push(`<rect x="${X(blockCenter[0] - 600).toFixed(1)}" y="${Y(blockCenter[1] + 600).toFixed(1)}" width="${(1200 * k).toFixed(1)}" height="${(1200 * k).toFixed(1)}" fill="${WALL}" stroke="${MUTED}"/>`);
for (const p of ps) {
const a = p.pts[1];
const b = p.pts[2];
const dashed = p.mark === 'S1' ? ' stroke-dasharray="4 3"' : '';
parts.push(`<line x1="${X(a[0]).toFixed(1)}" y1="${Y(a[1]).toFixed(1)}" x2="${X(b[0]).toFixed(1)}" y2="${Y(b[1]).toFixed(1)}" stroke="${p.mark === 'S1' ? C.s1 : C.s2}" stroke-width="1.6"${dashed} opacity="${opacity(pick, [p.mark])}"/>`);
}
parts.push(text(40 + 600 * k, 40 + 1200 * k + 16, '블럭 1200 × 1200 · S2 실선 (위) · S1 파선 (아래) · 교대축 → ', 'middle', MUTED, 10));
parts.push(balloon(40 - 16, 40 + 600 * k, 'S2', C.s2, pick), balloon(40 + 1200 * k + 16, 40 + 600 * k, 'S1', C.s1, pick));
const W = 40 + 1200 * k + 60;
const H = 40 + 1200 * k + 40;
return `<svg viewBox="0 0 ${W.toFixed(0)} ${H.toFixed(0)}" width="100%" style="max-width:${Math.round(W * 1.4)}px" role="img" aria-label="받침블럭 배근 평면">${parts.join('')}</svg>`;
}
/** 단면 (교대축 방향 절단, 블럭 가운데): 가로 s (블럭 안), 세로 z (교좌면 0, 블럭 상면 = 콘크리트 높이, 패드 위 125). */
export function abutBlockSectionSvg(r: AbutBlockRebar, pick: AbutRebarPick = NO_PICK): string {
const ps = firstBlock(r.paths).map((p) => ({ ...p, pts: p.pts.map(([s, t, z]) => [s, t, z - r.seatZ] as [number, number, number]) }));
const k = 0.42;
const kz = 0.42;
const zTop = r.concreteH + 50;
const zBot = Math.min(...ps.flatMap((p) => p.pts.map((q) => q[2])), 0) - 60;
const sLeft = Math.min(...ps.flatMap((p) => p.pts.map((q) => q[0])));
const X = (s: number) => 50 + (s - sLeft) * k;
const Y = (z: number) => 40 + (zTop - z) * kz;
const W = 50 + 1200 * k + 60;
const H = Y(zBot) + 40;
const parts: string[] = [];
const blockL = Math.min(...ps.flatMap((p) => p.pts.map((q) => q[0])));
parts.push(`<rect x="${X(blockL + 50).toFixed(1)}" y="${Y(r.concreteH).toFixed(1)}" width="${(1200 * k).toFixed(1)}" height="${(r.concreteH * kz).toFixed(1)}" fill="${CONC}" stroke="${MUTED}"/>`);
parts.push(`<rect x="${X(blockL + 50).toFixed(1)}" y="${Y(zTop).toFixed(1)}" width="${(1200 * k).toFixed(1)}" height="${(50 * kz).toFixed(1)}" fill="${WALL}" stroke="${MUTED}" stroke-dasharray="4 3"/>`);
parts.push(text(X(blockL + 50) - 6, Y(zTop) + 4, '몰탈패드 50', 'end', MUTED, 10));
parts.push(`<line x1="${X(blockL).toFixed(1)}" y1="${Y(0).toFixed(1)}" x2="${X(blockL + 1300).toFixed(1)}" y2="${Y(0).toFixed(1)}" stroke="${MUTED}" stroke-dasharray="6 3"/>`);
parts.push(text(X(blockL + 1300) + 6, Y(0) + 4, '교좌면', 'start', MUTED, 10));
for (const p of ps) {
const color = p.mark === 'S1' ? C.s1 : C.s2;
if (p.mark === 'S1') {
// S1: 교대축 방향 줄 (단면 가운데에서 보면 점 · 선). 다리 두 개를 같이 그린다.
const pts = p.pts.map(([s, , zz]) => `${X(s).toFixed(1)},${Y(zz).toFixed(1)}`).join(' ');
parts.push(`<polyline points="${pts}" fill="none" stroke="${color}" stroke-width="1.4" opacity="${opacity(pick, ['S1'])}"/>`);
} else {
// S2: 교대축 간격으로 놓인 본 — 단면에서 점.
const s = p.pts[1][0];
parts.push(`<circle cx="${X(s).toFixed(1)}" cy="${Y(p.pts[1][2]).toFixed(1)}" r="2.6" fill="${color}" opacity="${opacity(pick, ['S2'])}"/>`);
}
}
parts.push(text(X(blockL + 600), Y(zBot) + 14, `블럭 콘크리트 ${r.concreteH} · 묻힘 S2 ${Math.round(r.embedment.S2)} · S1 ${Math.round(r.embedment.S1)}`, 'middle', MUTED, 10));
parts.push(balloon(X(blockL + 600), Y(r.depth.S2 + 0) - 10, 'S2', C.s2, pick), balloon(X(blockL + 600), Y(0) - 22, 'S1', C.s1, pick));
return `<svg viewBox="0 0 ${W.toFixed(0)} ${H.toFixed(0)}" width="100%" style="max-width:${Math.round(W * 1.2)}px" role="img" aria-label="받침블럭 배근 단면">${parts.join('')}</svg>`;
}
+197
View File
@@ -0,0 +1,197 @@
/**
* 교대 저판 배근 삽도 (2026-11-04, 시안 ③ 확대기초 카드, 교대확대기초철근.md §10): 교대축 직각 단면 (폭 W), 교대축 단면 (길이 L),
* 상면 평면. 순수 문자열 함수. 번호 풍선(data-pick)을 누르면 그 철근만 강조한다; 선택한 번호는 `pick`(철근표 번호 집합)으로, 선택만 보기는 `only`로 받는다.
* 색은 역할 기준: 주철근 B1–B4 #0F5C66, 보조 B5–B8 #B4531F, 측면 B10 #2E7D32 점선, 전단 B9 #6B3FA0.
*/
import type { AbutFootingGeom, AbutFootingRebar } from '../core/abut/rebar/footing';
export const ABUT_FOOTING_COLORS = { main: '#0F5C66', dist: '#B4531F', side: '#2E7D32', shear: '#6B3FA0' } as const;
export const INK = '#18212b';
export const MUTED = '#55606e';
export const CONC = '#eef1f4';
export const WALL = '#d9dee4';
export const text = (x: number, y: number, s: string, anchor = 'middle', fill = INK, size = 11) =>
`<text x="${x.toFixed(1)}" y="${y.toFixed(1)}" text-anchor="${anchor}" font-size="${size}" fill="${fill}" font-family="JetBrains Mono, monospace">${s}</text>`;
/** Selected bars (철근표 번호) and 선택만 보기. An empty selection shows every bar. */
export type AbutRebarPick = { marks: ReadonlySet<string>; only: boolean };
export const NO_PICK: AbutRebarPick = { marks: new Set(), only: false };
/** Opacity of one bar group: dimmed (or hidden with 선택만 보기) when other bars are selected. */
export function opacity(pick: AbutRebarPick, marks: string[]): number {
if (pick.marks.size === 0) return 1;
return marks.some((m) => pick.marks.has(m)) ? 1 : pick.only ? 0 : 0.15;
}
/** A clickable 번호 풍선. */
export function balloon(x: number, y: number, mark: string, color: string, pick: AbutRebarPick): string {
// The B10 풍선 stands for B10-1 and B10-2.
const on = [...pick.marks].some((m) => m === mark || (mark === 'B10' && m.startsWith('B10')));
const r = mark.length > 3 ? 15 : 12;
return `<g data-pick="${mark}" style="cursor:pointer" role="button" tabindex="0" aria-label="${mark} 강조"><circle cx="${x.toFixed(1)}" cy="${y.toFixed(1)}" r="${r}" fill="${on ? color : '#fff'}" stroke="${color}" stroke-width="1.4"/>${text(x, y + 4, mark, 'middle', on ? '#fff' : color, 10)}</g>`;
}
/** 교대축 직각 단면 (폭 W): 왼쪽 배면(뒷굽) → 오른쪽 전면(앞굽). B1–B4 선, B5–B8 점, B10-1 점, B9 연직선. */
export function abutFootingSectionWSvg(g: AbutFootingGeom, r: AbutFootingRebar, pick: AbutRebarPick = NO_PICK): string {
const k = 0.12;
const x0 = 60;
const y0 = 46;
const X = (x: number) => x0 + x * k;
const Y = (z: number) => y0 + (g.H - z) * k;
const c = r.settings.cSide;
const C = ABUT_FOOTING_COLORS;
const parts: string[] = [];
parts.push(`<rect x="${X(g.xb)}" y="${y0 - 26}" width="${(g.xf - g.xb) * k}" height="26" fill="${WALL}" stroke="${MUTED}" stroke-dasharray="4 3"/>`);
parts.push(text((X(g.xb) + X(g.xf)) / 2, y0 - 10, `벽체 ${Math.round(g.xf - g.xb)} (상면 B5·B7 없음)`, 'middle', MUTED, 10));
parts.push(`<rect x="${X(0)}" y="${Y(g.H)}" width="${g.W * k}" height="${g.H * k}" fill="${CONC}" stroke="${MUTED}"/>`);
const line = (pts: [number, number][], color: string, marks: string[], w = 1.8, dash = '') =>
`<polyline points="${pts.map(([x, z]) => `${X(x).toFixed(1)},${Y(z).toFixed(1)}`).join(' ')}" fill="none" stroke="${color}" stroke-width="${w}"${dash ? ` stroke-dasharray="${dash}"` : ''} opacity="${opacity(pick, marks)}"/>`;
const { B13, B24, B57, B68 } = r.z;
// B9 (behind the others): vertical lines at the row positions.
for (const row of r.shear.rows) parts.push(line([[row.x, B24], [row.x, B13]], C.shear, ['B9'], 1.2));
// B1 ⊓ (slightly inside so the legs show next to B2's), B2 ⊔, B3 to the 벽체 전면 − c, B4 straight.
const lx = c + 6;
parts.push(line([[lx, B24], [lx, B13], [g.W - lx, B13], [g.W - lx, B24]], C.main, ['B1']));
parts.push(line([[c, B13], [c, B24], [g.W - c, B24], [g.W - c, B13]], C.main, ['B2']));
parts.push(line([[c, B13 + 14], [g.xf - c, B13 + 14]], C.main, ['B3'], 1.6, '6 3'));
parts.push(line([[c, B24 - 14], [g.W - c, B24 - 14]], C.main, ['B4'], 1.6, '6 3'));
// B5–B8 dots.
const dot = (x: number, z: number, color: string, mark: string) =>
`<circle cx="${X(x).toFixed(1)}" cy="${Y(z).toFixed(1)}" r="2.6" fill="${color}" opacity="${opacity(pick, [mark])}"/>`;
r.dist.positions.forEach((x, i) => {
if (r.dist.topKept[i]) parts.push(dot(x + (i % 2 === 0 ? r.sideShift.B5 : 0), B57, C.dist, i % 2 === 0 ? 'B5' : 'B7'));
parts.push(dot(x, B68, C.dist, i % 2 === 0 ? 'B6' : 'B8'));
});
// B10-1 dots on both faces.
for (const z of r.side.levels) {
const zz = z + 19.1;
parts.push(dot(r.side.depth1, zz, C.side, 'B10-1'), dot(g.W - r.side.depth1, zz, C.side, 'B10-1'));
}
// 치수 문자열.
parts.push(text(X(g.W / 2), Y(0) + 18, r.dist.bottomText + ' (하면)', 'middle', INK, 10));
parts.push(text(X(g.W / 2), Y(g.H) - 32, r.dist.topText + ' (상면)', 'middle', INK, 10));
parts.push(text(X(0) - 6, Y(g.H / 2), r.side.text, 'end', MUTED, 10));
// 번호 풍선.
const by = Y(g.H) - 2;
parts.push(balloon(X(g.xb / 2), Y(B13) + 26, 'B1', C.main, pick), balloon(X(g.xb / 2) + 30, Y(B13) + 26, 'B3', C.main, pick));
parts.push(balloon(X(g.xb / 2), Y(B24) - 26, 'B2', C.main, pick), balloon(X(g.xb / 2) + 30, Y(B24) - 26, 'B4', C.main, pick));
parts.push(balloon(X(g.xf + (g.W - g.xf) / 2) - 15, Y(B57) + 30, 'B5', C.dist, pick), balloon(X(g.xf + (g.W - g.xf) / 2) + 15, Y(B57) + 30, 'B7', C.dist, pick));
parts.push(balloon(X(g.xf + (g.W - g.xf) / 2) - 15, Y(B68) - 30, 'B6', C.dist, pick), balloon(X(g.xf + (g.W - g.xf) / 2) + 15, Y(B68) - 30, 'B8', C.dist, pick));
if (r.shear.rows.length) parts.push(balloon(X(r.shear.rows[0].x) + 22, Y(g.H / 2), 'B9', C.shear, pick));
parts.push(balloon(X(g.W) + 22, Y(r.side.levels[1] ?? g.H / 2), 'B10', C.side, pick));
parts.push(text(X(0), by - 30, '← 배면 (뒷굽)', 'start', MUTED, 10), text(X(g.W), by - 30, '전면 (앞굽) →', 'end', MUTED, 10));
const W = X(g.W) + 50;
const H = Y(0) + 30;
return `<svg viewBox="0 0 ${W.toFixed(0)} ${H.toFixed(0)}" width="100%" role="img" aria-label="교대축 직각 단면: 폭 ${g.W}, 두께 ${g.H}, 하면 ${r.dist.bottomText}">${parts.join('')}</svg>`;
}
/** 교대축 단면 (길이 L, 가로 축척 0.4배): B5–B8 선(이음 위치), B1–B4 점, B10-2 점. 왼쪽 IP3 쪽. */
export function abutFootingSectionLSvg(g: AbutFootingGeom, r: AbutFootingRebar, pick: AbutRebarPick = NO_PICK): string {
const kx = 0.045;
const kz = 0.12;
const x0 = 50;
const y0 = 30;
const X = (s: number) => x0 + s * kx;
const Y = (z: number) => y0 + (g.H - z) * kz;
const C = ABUT_FOOTING_COLORS;
const parts: string[] = [];
parts.push(`<rect x="${X(0)}" y="${Y(g.H)}" width="${g.L * kx}" height="${g.H * kz}" fill="${CONC}" stroke="${MUTED}"/>`);
const e = r.settings.cSide / g.angle.sin;
const line = (pts: [number, number][], color: string, marks: string[], w = 1.8, dash = '') =>
`<polyline points="${pts.map(([s, z]) => `${X(s).toFixed(1)},${Y(z).toFixed(1)}`).join(' ')}" fill="none" stroke="${color}" stroke-width="${w}"${dash ? ` stroke-dasharray="${dash}"` : ''} opacity="${opacity(pick, marks)}"/>`;
const { B13, B24, B57, B68 } = r.z;
parts.push(line([[e + 4, B68], [e + 4, B57], [g.L - e - 4, B57], [g.L - e - 4, B68]], C.dist, ['B5']));
parts.push(line([[e, B57], [e, B68], [g.L - e, B68], [g.L - e, B57]], C.dist, ['B6']));
parts.push(line([[e, B57 - 14], [g.L - e, B57 - 14]], C.dist, ['B7'], 1.4, '6 3'));
parts.push(line([[e, B68 + 14], [g.L - e, B68 + 14]], C.dist, ['B8'], 1.4, '6 3'));
// 이음 (B5 · B6): the two splice centres (± l_s around the middle) as ticks.
const lap = r.bars.find((b) => b.mark === 'B5')?.splice?.lap;
if (lap) {
for (const sc of [g.L / 2 - lap, g.L / 2 + lap]) {
parts.push(`<line x1="${X(sc)}" y1="${Y(B57) - 6}" x2="${X(sc)}" y2="${Y(B68) + 6}" stroke="${C.dist}" stroke-dasharray="2 2" opacity="${opacity(pick, ['B5', 'B6'])}"/>`);
}
parts.push(text(X(g.L / 2), Y(B57) - 10, `B5·B6 이음 l_s ${lap} (± l_s 엇갈림)`, 'middle', C.dist, 10));
}
const dot = (s: number, z: number, color: string, mark: string) =>
`<circle cx="${X(s).toFixed(1)}" cy="${Y(z).toFixed(1)}" r="1.8" fill="${color}" opacity="${opacity(pick, [mark])}"/>`;
r.main.positions.forEach((s, i) => {
const one = i % 2 === 0;
parts.push(dot(one ? s + r.sideShift.B1 : s, B13, C.main, one ? 'B1' : 'B3'));
parts.push(dot(s, B24, C.main, one ? 'B2' : 'B4'));
});
const d2 = r.side.depth2 / g.angle.sin;
for (const z of r.side.levels) parts.push(dot(d2, z, C.side, 'B10-2'), dot(g.L - d2, z, C.side, 'B10-2'));
parts.push(text(X(g.L / 2), Y(0) + 18, r.main.text, 'middle', INK, 10));
parts.push(balloon(X(g.L * 0.12), Y(B13) - 16, 'B1', C.main, pick), balloon(X(g.L * 0.12) + 28, Y(B13) - 16, 'B3', C.main, pick));
parts.push(balloon(X(g.L * 0.88), Y(B57) + 30, 'B5', C.dist, pick), balloon(X(g.L * 0.88) + 28, Y(B57) + 30, 'B7', C.dist, pick));
parts.push(balloon(X(g.L * 0.3), Y(B68) - 30, 'B6', C.dist, pick), balloon(X(g.L * 0.3) + 28, Y(B68) - 30, 'B8', C.dist, pick));
parts.push(balloon(X(g.L * 0.12), Y(B24) + 20, 'B2', C.main, pick), balloon(X(g.L * 0.12) + 28, Y(B24) + 20, 'B4', C.main, pick));
parts.push(balloon(X(g.L) + 22, Y(g.H / 2), 'B10', C.side, pick));
const W = X(g.L) + 50;
const H = Y(0) + 30;
return `<svg viewBox="0 0 ${W.toFixed(0)} ${H.toFixed(0)}" width="100%" role="img" aria-label="교대축 단면: 길이 ${Math.round(g.L)}, ${r.main.text}">${parts.join('')}</svg>`;
}
/**
* 상면 평면: skew 평행사변형, 상면 B1·B3 (옵션에 따라 skew 선과 나란히 / 교대축 직각, 옵션 ② B1-S), B5·B7, B9, 벽체 밑 띠.
* `skewK` = 날개벽 방향의 s / t 비 (장면의 abutSkewK): s(x) = s + skewK · x. 위 = 전면, 왼쪽 = IP3 끝 (s = L).
*/
export function abutFootingRebarPlanSvg(g: AbutFootingGeom, r: AbutFootingRebar, skewK: number, pick: AbutRebarPick = NO_PICK): string {
const k = 0.045;
const shiftMax = Math.abs(skewK) * g.W;
const sMin = Math.min(0, skewK * g.W);
const x0 = 30;
const y0 = 24;
const X = (s: number) => x0 + (g.L + shiftMax + sMin - s) * k; // s = L (IP3) on the left
const Y = (x: number) => y0 + (g.W - x) * k; // 전면 (x = W) on top
const P = (s: number, x: number) => `${X(s + skewK * x).toFixed(1)},${Y(x).toFixed(1)}`;
// Option ② positions start after the end face that leans out (core: start = c + W·|cot θ|); with the faces leaning the
// other way (skewK < 0) that side is at x = W, so the square bars sit W·|cot θ| further toward s = 0 in this frame.
const SQ = (s: number) => (skewK >= 0 ? s : s - shiftMax);
const C = ABUT_FOOTING_COLORS;
const c = r.settings.cSide;
const parts: string[] = [];
parts.push(`<polygon points="${[P(0, 0), P(g.L, 0), P(g.L, g.W), P(0, g.W)].join(' ')}" fill="${CONC}" stroke="${MUTED}"/>`);
parts.push(`<polygon points="${[P(0, g.xb), P(g.L, g.xb), P(g.L, g.xf), P(0, g.xf)].join(' ')}" fill="${WALL}" opacity="0.7"/>`);
const seg = (a: string, b: string, color: string, mark: string, w = 1, dash = '') =>
`<line x1="${a.split(',')[0]}" y1="${a.split(',')[1]}" x2="${b.split(',')[0]}" y2="${b.split(',')[1]}" stroke="${color}" stroke-width="${w}"${dash ? ` stroke-dasharray="${dash}"` : ''} opacity="${opacity(pick, [mark])}"/>`;
// B5 · B7: along the axis at the kept top positions, cut square at the ends (끝 피복 c / sin θ).
const e = c / g.angle.sin;
r.dist.positions.forEach((x, i) => {
if (!r.dist.topKept[i]) return;
parts.push(seg(P(e, x), P(g.L - e, x), C.dist, i % 2 === 0 ? 'B5' : 'B7', 0.8));
});
// B1 · B3: option ① along the skew line from the end-face grid; option ② square to the axis.
r.main.positions.forEach((s, i) => {
const one = i % 2 === 0;
const xEnd = one ? g.W - c : g.xf - c;
if (r.dir === 'skew') parts.push(seg(P(s, c), P(s, xEnd), C.main, one ? 'B1' : 'B3', 0.8, one ? '' : '4 2'));
else parts.push(seg(`${X(SQ(s)).toFixed(1)},${Y(c).toFixed(1)}`, `${X(SQ(s)).toFixed(1)},${Y(xEnd).toFixed(1)}`, C.main, one ? 'B1' : 'B3', 0.8, one ? '' : '4 2'));
});
if (r.skewEnd) {
for (const off of r.skewEnd.offsets) {
parts.push(seg(P(c + off, c), P(c + off, g.W - c), C.main, 'B1-S', 1.4));
parts.push(seg(P(g.L - c - off, c), P(g.L - c - off, g.W - c), C.main, 'B1-S', 1.4));
}
}
// B9 dots (rows).
for (const row of r.shear.rows) {
for (const s of row.s) {
const at = r.dir === 'skew' ? P(s, row.x) : `${X(SQ(s)).toFixed(1)},${Y(row.x).toFixed(1)}`;
const [cx, cy] = at.split(',');
parts.push(`<circle cx="${cx}" cy="${cy}" r="1.2" fill="${C.shear}" opacity="${opacity(pick, ['B9'])}"/>`);
}
}
parts.push(text(X(g.L / 2 + skewK * (g.xb + g.xf) / 2), Y((g.xb + g.xf) / 2) + 4, '벽체 밑 (상면 B5·B7 없음, B9 없음)', 'middle', MUTED, 10));
parts.push(text(X(g.L / 2 + skewK * g.W), Y(g.W) - 6, '전면 (앞굽 끝)', 'middle', MUTED, 10));
parts.push(text(X(g.L / 2), Y(0) + 14, '배면 (뒷굽 끝)', 'middle', MUTED, 10));
parts.push(text(X(g.L) - 4, Y(g.W / 2), 'IP3', 'end', MUTED, 10), text(X(0) + 4, Y(g.W / 2), 'IP1', 'start', MUTED, 10));
parts.push(balloon(X(g.L * 0.75 + skewK * g.W * 0.7), Y(g.W * 0.7), 'B1', C.main, pick), balloon(X(g.L * 0.75 + skewK * g.W * 0.7) + 28, Y(g.W * 0.7), 'B3', C.main, pick));
parts.push(balloon(X(g.L * 0.4 + skewK * g.W * 0.9), Y(g.W * 0.92), 'B5', C.dist, pick), balloon(X(g.L * 0.4 + skewK * g.W * 0.9) + 28, Y(g.W * 0.92), 'B7', C.dist, pick));
parts.push(balloon(X(g.L * 0.25 + skewK * g.W * 0.1), Y(g.W * 0.1), 'B9', C.shear, pick));
if (r.skewEnd) parts.push(balloon(X(c + skewK * g.W * 0.5) - 26, Y(g.W * 0.5), 'B1-S', C.main, pick));
const W = x0 * 2 + (g.L + shiftMax) * k;
const H = Y(0) + 26;
return `<svg viewBox="0 0 ${W.toFixed(0)} ${H.toFixed(0)}" width="100%" role="img" aria-label="상면 평면: 배근 방향 ${r.dir === 'skew' ? '① skew' : '② 교대축 직각'}">${parts.join('')}</svg>`;
}
+59
View File
@@ -0,0 +1,59 @@
/**
* 교대 확대기초 · 말뚝 평면 삽도 (2026-11-04, 시안 ② 확대기초·말뚝 카드). 순수 문자열 함수.
* 생성본 값으로 그린다: 저판(skew 평행사변형, 굵은 선), 벽체 윤곽(실선), 교대축(점선), 말뚝 3열(원), 열 위치, 폭 치수.
* 가로(s)와 세로(t) 축척이 같다. 위가 전면, 아래가 배면이다.
*/
export interface AbutFootingDrawing {
/** 교대축 길이 L (m). */
lengthM: number;
/** s per m of t along the wing (skew): s = s_axis + k·t. */
skewK: number;
/** t (m): 저판 앞굽 끝, 뒷굽 끝, 벽체 전면, 벽체 배면. */
footingFrontM: number;
footingBackM: number;
wallFrontM: number;
wallBackM: number;
/** 말뚝 열: t (m), s (m, 교대축 기준, skew 전), 열 이름 (1열 · 전면 …). */
rows: { t: number; s: number[]; name: string }[];
pileDiameterM: number;
/** 열 위치 치수 (mm, 배면부터): 배면 연단, 간격, 간격, 전면 연단. */
rowDimsMm: [number, number, number, number];
footingWidthMm: number;
}
const INK = '#18212b';
const MUTED = '#55606e';
const label = (x: number, y: number, s: string, anchor = 'start', fill = INK) =>
`<text x="${x.toFixed(1)}" y="${y.toFixed(1)}" text-anchor="${anchor}" font-size="11" fill="${fill}" font-family="JetBrains Mono, monospace">${s}</text>`;
export function abutFootingPlanSvg(d: AbutFootingDrawing): string {
const k = 36; // px per m, both directions
const sMin = Math.min(d.skewK * d.footingFrontM, d.skewK * d.footingBackM) - 0.4;
const sMax = d.lengthM + Math.max(d.skewK * d.footingFrontM, d.skewK * d.footingBackM) + 0.4;
const tTop = d.footingFrontM + 0.4;
const X = (s: number, t: number) => 20 + (s + d.skewK * t - sMin) * k;
const Y = (t: number) => 14 + (tTop - t) * k;
const W = 20 + (sMax - sMin) * k + 170;
const H = Y(d.footingBackM - 0.4) + 34;
const quad = (t0: number, t1: number, style: string) =>
`<polygon points="${[[0, t0], [d.lengthM, t0], [d.lengthM, t1], [0, t1]].map(([s, t]) => `${X(s, t).toFixed(1)},${Y(t).toFixed(1)}`).join(' ')}" ${style}/>`;
const parts: string[] = [];
parts.push(quad(d.footingBackM, d.footingFrontM, `fill="#e6ebf0" stroke="${INK}" stroke-width="2"`));
parts.push(quad(d.wallBackM, d.wallFrontM, `fill="none" stroke="${MUTED}"`));
parts.push(`<line x1="${X(0, 0).toFixed(1)}" y1="${Y(0).toFixed(1)}" x2="${X(d.lengthM, 0).toFixed(1)}" y2="${Y(0).toFixed(1)}" stroke="#c0392b" stroke-dasharray="6 3"/>`);
parts.push(label(X(d.lengthM, 0) + 6, Y(0) + 4, '교대축', 'start', '#c0392b'));
const r = Math.max(2, (d.pileDiameterM / 2) * k);
for (const row of d.rows) {
for (const s of row.s) parts.push(`<circle cx="${X(s, row.t).toFixed(1)}" cy="${Y(row.t).toFixed(1)}" r="${r.toFixed(1)}" fill="#ffffff" stroke="${INK}"/>`);
const tLabel = `${row.t >= 0 ? '+' : '−'}${Math.round(Math.abs(row.t) * 1000)}`;
parts.push(label(X(d.lengthM, row.t) + r + 8, Y(row.t) + 4, `${row.name} · t ${tLabel} · ${row.s.length}본`));
}
const [e1, g1, g2, e2] = d.rowDimsMm;
parts.push(label(20, H - 10, `${d.footingWidthMm} = 뒷굽쪽 ${e1} · ${g1} · ${g2} · ${e2} 전면 · 위 = 전면`, 'start', MUTED));
return [
`<svg viewBox="0 0 ${W.toFixed(0)} ${H.toFixed(0)}" width="100%" role="img" aria-label="확대기초 평면: skew 평행사변형, 폭 ${d.footingWidthMm}, 말뚝 ${d.rows.map((r2) => r2.s.length).join(' + ')}본">`,
...parts,
'</svg>',
].join('');
}
+130
View File
@@ -0,0 +1,130 @@
/**
* 교대 흉벽 · 브라켓 배근 삽도 (2026-11-04, 시안 ③ 흉벽·브라켓 카드, 교대흉벽철근.md §10): 교대축 직각 단면 (대표 단면),
* 교대축 단면 (전면, 상단 경사), P10 다웰 평면. 순수 문자열 함수, 철근은 배근 규칙의 경로(parapet.ts paths)에서 그린다.
* 색: 연직 #0F5C66, 수평 #B4531F, 간격재 #6B3FA0, 측면 · 다웰 #2E7D32.
*/
import type { AbutParapetGeom, AbutParapetRebar, ParapetBarPath, ParapetMark } from '../core/abut/rebar/parapet';
import { balloon, CONC, MUTED, NO_PICK, opacity, text, WALL, type AbutRebarPick } from './abut-footing-rebar-svg';
export const ABUT_PARAPET_COLORS = { vertical: '#0F5C66', horizontal: '#B4531F', tie: '#6B3FA0', side: '#2E7D32' } as const;
const C = ABUT_PARAPET_COLORS;
const COLOR: Record<ParapetMark, string> = {
P1: C.vertical, P2: C.vertical, P3: C.vertical, P11: C.vertical,
P4: C.horizontal, P5: C.horizontal,
P6: C.tie, P7: C.tie,
P8: C.side, P9: C.side, P10: C.side,
};
const DASH: Partial<Record<ParapetMark, string>> = { P6: '3 3', P7: '3 3', P8: '2 2', P9: '2 2' };
const topAt = (g: AbutParapetGeom, u: number) => g.top[0] + ((g.top[1] - g.top[0]) * u) / g.wall.L;
const seatAt = (g: AbutParapetGeom, u: number) => {
const sg = g.wall.segments.find((s) => u < s.to) ?? g.wall.segments[g.wall.segments.length - 1];
return g.wall.HF + sg.H;
};
const nearest = (paths: ParapetBarPath[], mark: ParapetMark, u: number, at = 1) =>
paths.filter((p) => p.mark === mark).reduce<ParapetBarPath | undefined>((a, b) => (!a || Math.abs(b.pts[at][0] - u) < Math.abs(a.pts[at][0] - u) ? b : a), undefined);
/** 교대축 직각 단면 at the 대표 단면 (받침 ① 위치): t 가로 (왼쪽 브라켓 배면 → 오른쪽 흉벽 전면), z 세로. */
export function abutParapetSectionSvg(g: AbutParapetGeom, r: AbutParapetRebar, pick: AbutRebarPick = NO_PICK): string {
const u = r.uRef;
const seat = seatAt(g, u);
const top = topAt(g, u);
const br = top - g.bracketH;
const zMin = seat - r.ld.H16 - 150;
const k = 0.16;
const tL = g.wall.xb - 150;
const tR = 450;
const X = (t: number) => 60 + (t - tL) * k;
const Y = (z: number) => 40 + (top + 120 - z) * k;
const parts: string[] = [];
const xb = g.wall.xb;
// 벽체 (교좌면 아래) · 교좌면 · 브라켓 · 흉벽.
parts.push(`<rect x="${X(xb)}" y="${Y(seat)}" width="${(tR - xb) * k}" height="${(seat - zMin) * k}" fill="${WALL}" stroke="${MUTED}" stroke-dasharray="4 3"/>`);
parts.push(`<path d="M${X(xb)},${Y(seat)} L${X(xb)},${Y(br)} L${X(g.parapetBack)},${Y(br)} L${X(g.parapetBack)},${Y(top)} L${X(0)},${Y(top)} L${X(0)},${Y(seat)} Z" fill="${CONC}" stroke="${MUTED}"/>`);
parts.push(text(X(tR) - 4, Y(seat) - 6, '교좌면', 'end', MUTED, 10), text((X(xb) + X(g.parapetBack)) / 2, Y(br) - 6, '브라켓 상면', 'middle', MUTED, 10));
const line = (p: ParapetBarPath, w = 1.6) =>
`<polyline points="${p.pts.map(([, t, z]) => `${X(t).toFixed(1)},${Y(z).toFixed(1)}`).join(' ')}" fill="none" stroke="${COLOR[p.mark]}" stroke-width="${w}"${DASH[p.mark] ? ` stroke-dasharray="${DASH[p.mark]}"` : ''} opacity="${opacity(pick, [p.mark])}"/>`;
// 간격재: every level at the 교대축 position nearest the section.
for (const mark of ['P6', 'P7'] as const) {
const ref = nearest(r.paths, mark, u);
// That column and its staggered levels (반 칸 뒤), not the previous column's.
if (ref) for (const p of r.paths.filter((q) => q.mark === mark && q.pts[1][0] - ref.pts[1][0] > -1 && q.pts[1][0] - ref.pts[1][0] < 600)) parts.push(line(p, 1.1));
}
for (const mark of ['P3', 'P2', 'P1', 'P10'] as const) {
const p = nearest(r.paths, mark, u, mark === 'P10' ? 0 : 1);
if (p) parts.push(line(p, mark === 'P10' ? 3 : 1.8));
}
// 수평 P4 · P5: dots where they pass the section.
for (const p of r.paths.filter((q) => (q.mark === 'P4' || q.mark === 'P5') && q.pts[0][0] <= u && q.pts[1][0] >= u)) {
const f = (u - p.pts[0][0]) / (p.pts[1][0] - p.pts[0][0]);
const z = p.pts[0][2] + f * (p.pts[1][2] - p.pts[0][2]);
parts.push(`<circle cx="${X(p.pts[0][1]).toFixed(1)}" cy="${Y(z).toFixed(1)}" r="2.4" fill="${COLOR[p.mark]}" opacity="${opacity(pick, [p.mark])}"/>`);
}
// 치수: 흉벽 두께 (c · 사이 · c), 단 수.
const c = r.settings.c;
parts.push(text((X(g.parapetBack) + X(0)) / 2, Y(top) - 8, `${c} · ${Math.round(-g.parapetBack - 2 * c)} · ${c}`, 'middle', MUTED, 10));
parts.push(text((X(xb) + X(g.parapetBack)) / 2, Y(br) + 14, `브라켓 ${Math.round(g.parapetBack - xb)}`, 'middle', MUTED, 10));
parts.push(text(X(tR) - 4, Y((top + seat) / 2), `P4 ${r.levels.P4}단 · P5 ${r.levels.P5up} + ${r.levels.P5low}단`, 'end', MUTED, 10));
// 번호 풍선.
const ym = Y((top + seat) / 2);
parts.push(balloon(X(0) + 30, Y(top) + 30, 'P1', C.vertical, pick), balloon(X(0) + 30, ym + 30, 'P4', C.horizontal, pick));
parts.push(balloon(X(g.parapetBack) + 30, Y(top) - 26, 'P2', C.vertical, pick), balloon(X(xb) - 26, Y(br) + 40, 'P3', C.vertical, pick), balloon(X(xb) - 26, Y(br) + 80, 'P5', C.horizontal, pick));
parts.push(balloon((X(g.parapetBack) + X(0)) / 2, Y(br) - 40, 'P7', C.tie, pick), balloon((X(xb) + X(0)) / 2, Y(seat) - 30, 'P6', C.tie, pick), balloon(X(r.x.P10) - 26, Y(br) - 30, 'P10', C.side, pick));
const W = X(tR) + 20;
const H = Y(zMin) + 16;
return `<svg viewBox="0 0 ${W.toFixed(0)} ${H.toFixed(0)}" width="100%" style="max-width:${Math.round(W * 1.4)}px" role="img" aria-label="흉벽 · 브라켓 교대축 직각 단면 (대표 단면)">${parts.join('')}</svg>`;
}
/** 교대축 단면 (전면): u 가로 (왼쪽 IP3 → 오른쪽 IP1), z 세로. 상단 경사와 나란한 P4, P1, 끝면 P11 · P9 · P8. */
export function abutParapetElevationSvg(g: AbutParapetGeom, r: AbutParapetRebar, pick: AbutRebarPick = NO_PICK): string {
const L = g.wall.L;
const k = 0.045;
const kz = 0.12;
const zMax = Math.max(g.top[0], g.top[1]) + 100;
const zMin = Math.min(...g.wall.segments.map((s) => g.wall.HF + s.H)) - r.ld.H16 - 100;
const X = (u: number) => 60 + (L - u) * k;
const Y = (z: number) => 50 + (zMax - z) * kz;
const parts: string[] = [];
let outline = `M${X(0).toFixed(1)},${Y(g.top[0]).toFixed(1)} L${X(L).toFixed(1)},${Y(g.top[1]).toFixed(1)}`;
for (const s of [...g.wall.segments].reverse()) outline += ` L${X(s.to).toFixed(1)},${Y(g.wall.HF + s.H).toFixed(1)} L${X(s.from).toFixed(1)},${Y(g.wall.HF + s.H).toFixed(1)}`;
parts.push(`<path d="${outline} Z" fill="${CONC}" stroke="${MUTED}"/>`);
parts.push(`<line x1="${X(0)}" y1="${Y(g.top[0] - g.bracketH)}" x2="${X(L)}" y2="${Y(g.top[1] - g.bracketH)}" stroke="${MUTED}" stroke-dasharray="5 4"/>`);
parts.push(text(X(L / 2), Y(g.top[0] - g.bracketH + ((g.top[1] - g.top[0]) / 2)) - 4, '브라켓 상면 (배면)', 'middle', MUTED, 10));
const seg = (mark: ParapetMark, a: [number, number], b: [number, number], w: number) =>
`<line x1="${X(a[0]).toFixed(1)}" y1="${Y(a[1]).toFixed(1)}" x2="${X(b[0]).toFixed(1)}" y2="${Y(b[1]).toFixed(1)}" stroke="${COLOR[mark]}" stroke-width="${w}"${DASH[mark] ? ` stroke-dasharray="${DASH[mark]}"` : ''} opacity="${opacity(pick, [mark])}"/>`;
for (const p of r.paths) {
const a = p.pts[0];
const b = p.pts[p.pts.length - 1];
if (p.mark === 'P4') parts.push(seg('P4', [a[0], a[2]], [b[0], b[2]], 0.8));
else if (p.mark === 'P1') parts.push(seg('P1', [a[0], a[2]], [b[0], b[2]], 0.6));
else if (p.mark === 'P11' && Math.abs(a[1] - r.x.P4) < 1e-6) parts.push(seg('P11', [a[0], a[2]], [b[0], b[2]], 2));
else if (p.mark === 'P8' || p.mark === 'P9') parts.push(`<circle cx="${X(p.pts[1][0]).toFixed(1)}" cy="${Y(a[2]).toFixed(1)}" r="1.8" fill="${COLOR[p.mark]}" opacity="${opacity(pick, [p.mark])}"/>`);
}
parts.push(text(X(L), Y(zMin) + 14, 'IP3 (구간 1)', 'start', MUTED, 10), text(X(0), Y(zMin) + 14, 'IP1', 'end', MUTED, 10));
parts.push(balloon(X(L * 0.7), 22, 'P4', C.horizontal, pick), balloon(X(L * 0.4), 22, 'P1', C.vertical, pick));
parts.push(balloon(X(0) + 30, Y(g.top[0] - 300), 'P9', C.side, pick), balloon(X(0) + 30, Y(g.top[0] - 1300), 'P8', C.side, pick), balloon(X(0) + 30, Y(g.top[0] - 2300), 'P11', C.vertical, pick));
const W = X(0) + 60;
const H = Y(zMin) + 24;
return `<svg viewBox="0 0 ${W.toFixed(0)} ${H.toFixed(0)}" width="100%" style="max-width:${Math.round(W * 1.2)}px" role="img" aria-label="흉벽 교대축 단면 (전면)">${parts.join('')}</svg>`;
}
/** P10 다웰 배치 평면: u 가로 (왼쪽 IP3 → 오른쪽 IP1), 다웰 n개, 슬래브 측면선, P3 수평 다리를 비켜 옮긴 다웰. */
export function abutParapetDowelSvg(g: AbutParapetGeom, r: AbutParapetRebar, pick: AbutRebarPick = NO_PICK): string {
const L = g.wall.L;
const k = 0.07;
const X = (u: number) => 40 + (L - u) * k;
const y0 = 70;
const parts: string[] = [];
parts.push(`<rect x="${X(L)}" y="${y0 - 12}" width="${L * k}" height="24" fill="${CONC}" stroke="${MUTED}"/>`);
parts.push(text(X(L / 2), y0 - 18, `브라켓 상면 · 다웰 선 x ${r.x.P10}`, 'middle', MUTED, 10));
if (g.slab) {
for (const u of [g.slab.a, g.slab.b]) parts.push(`<line x1="${X(u)}" y1="${y0 - 30}" x2="${X(u)}" y2="${y0 + 30}" stroke="${MUTED}" stroke-dasharray="4 3"/>`);
parts.push(text(X((g.slab.a + g.slab.b) / 2), y0 + 44, `접속슬래브 폭 ${Math.round(g.slab.B)} · n = ${r.dowel.n} · 양끝 여유 c + e = ${Math.round((r.settings.c + r.dowel.e) * 10) / 10}${r.dowel.moved ? ` · ${r.dowel.moved}개 P3 비켜 옮김` : ''}`, 'middle', MUTED, 10));
}
for (const p of r.paths.filter((q) => q.mark === 'P3')) parts.push(`<line x1="${X(p.pts[0][0]).toFixed(1)}" y1="${y0 - 8}" x2="${X(p.pts[0][0]).toFixed(1)}" y2="${y0 + 8}" stroke="${C.vertical}" stroke-width="0.5" opacity="${opacity(pick, ['P3'])}"/>`);
for (const u of r.dowel.positions) parts.push(`<circle cx="${X(u).toFixed(1)}" cy="${y0}" r="3" fill="${C.side}" opacity="${opacity(pick, ['P10'])}"/>`);
parts.push(text(X(L), y0 + 26, 'IP3', 'start', MUTED, 10), text(X(0), y0 + 26, 'IP1', 'end', MUTED, 10));
parts.push(balloon(X(L * 0.5), 20, 'P10', C.side, pick), balloon(X(L * 0.5) + 30, 20, 'P3', C.vertical, pick));
const W = X(0) + 40;
return `<svg viewBox="0 0 ${W.toFixed(0)} ${y0 + 60}" width="100%" style="max-width:${Math.round(W * 1.2)}px" role="img" aria-label="P10 다웰 배치 평면">${parts.join('')}</svg>`;
}
+150
View File
@@ -0,0 +1,150 @@
/**
* 교대 3D 철근 (③, 2026-11-04): 저판 B1–B10, 벽체 W1–W11, 흉벽 · 브라켓 P1–P11, 날개벽 WR1–WR20 (좌 · 우), 접속슬래브 R1–R8. Positions come from `abutFootingRebar` ·
* `abutWallRebar` · `abutParapetRebar` (no new rules here; the 벽체 · 흉벽 bars are their paths). The 저판 shapes follow the
* 배근 삽도 (abut-footing-rebar-svg.ts): B1 ⊓ / B2 ⊔ / B3 · B4 직선, B5 ⊓ / B6 ⊔ / B7 · B8 직선, B9 연직, B10 측면, B1-S · B2-S.
* Hooks and the bend radii are not drawn. Each bar is named by its 철근표 number, so the table, the 삽도 and the model
* share one selection. Local frame = the abutment group's (x = s along the axis, y = EL, z = t toward the front).
*/
import * as THREE from 'three';
import type { AbutFootingGeom, AbutFootingRebar } from '../core/abut/rebar/footing';
import { NOMINAL_DIAMETER } from '../core/rebar/common';
import { memberOfBar } from '../core/rebar/color';
import { ABUT_WALL_SIZES, type AbutWallRebar } from '../core/abut/rebar/wall';
import { ABUT_PARAPET_SIZES, type AbutParapetRebar } from '../core/abut/rebar/parapet';
import { ABUT_PARAPET_COLORS } from './abut-parapet-rebar-svg';
import { ABUT_WING_SIZES, type AbutWingRebar } from '../core/abut/rebar/wing';
import { ABUT_WING_COLORS } from './abut-wing-rebar-svg';
import { ABUT_SLAB_SIZES, type AbutSlabRebar } from '../core/abut/rebar/slab';
import { ABUT_BLOCK_SIZES, type AbutBlockRebar } from '../core/abut/rebar/block';
import { ABUT_BLOCK_COLORS } from './abut-block-rebar-svg';
import { ABUT_SLAB_COLORS } from './abut-slab-rebar-svg';
import { ABUT_FOOTING_COLORS } from './abut-footing-rebar-svg';
import { ABUT_WALL_COLORS } from './abut-wall-rebar-svg';
import { rebarColor, rebarControls, type RebarScene } from './rebar3d';
type P3 = [number, number, number];
/**
* @param skewK plan skew (s per m of t, `abutSkewK`), @param backM 저판 배면 t (m), @param bottomEl 기초 하단 EL (m),
* @param wall 벽체 배근, @param parapet 흉벽 배근, @param wings 날개벽 배근 (측마다, 경로는 교대 s · t · z) — 없으면 그 부재는 그리지 않음.
*/
export function buildAbutRebar3D(
g: AbutFootingGeom,
r: AbutFootingRebar,
skewK: number,
backM: number,
bottomEl: number,
wall?: AbutWallRebar,
parapet?: AbutParapetRebar,
wings: AbutWingRebar[] = [],
slab?: AbutSlabRebar,
blocks?: AbutBlockRebar,
): RebarScene {
const group = new THREE.Group();
group.name = 'abut-rebar';
const byName = new Map<string, THREE.Mesh[]>();
const C = Object.fromEntries(Object.entries(ABUT_FOOTING_COLORS).map(([k, v]) => [k, parseInt(v.slice(1), 16)])) as Record<keyof typeof ABUT_FOOTING_COLORS, number>;
const sizeOf = new Map<string, keyof typeof NOMINAL_DIAMETER>([...r.bars.map((b) => [b.mark, b.size] as const), ...Object.entries(ABUT_WALL_SIZES), ...Object.entries(ABUT_PARAPET_SIZES), ...Object.entries(ABUT_WING_SIZES), ...Object.entries(ABUT_SLAB_SIZES), ...Object.entries(ABUT_BLOCK_SIZES)]);
// (s, x, z) in mm: s along the skew grid (끝면과 나란히), x from the 배면, z from the 저판 하면.
const P = (s: number, x: number, z: number): P3 => [s / 1000 + skewK * (backM + x / 1000), bottomEl + z / 1000, backM + x / 1000];
// Option ②: square to the axis, s in the 삽도's frame (abutFootingRebarPlanSvg SQ), the same for every x.
const shiftMax = Math.abs(skewK) * g.W;
const Q = (s: number, x: number, z: number): P3 => {
const u = (skewK >= 0 ? s : s - shiftMax) / 1000 + skewK * backM;
return [u, bottomEl + z / 1000, backM + x / 1000];
};
const main = r.dir === 'skew' ? P : Q;
const add = (name: string, pts: P3[], color: number) => {
const size = sizeOf.get(name);
if (!size) return;
const dMm = NOMINAL_DIAMETER[size];
for (let i = 1; i < pts.length; i++) {
const a = new THREE.Vector3(...pts[i - 1]);
const b = new THREE.Vector3(...pts[i]);
const dir = b.clone().sub(a);
const len = dir.length();
if (len < 1e-6) continue;
const geo = new THREE.CylinderGeometry(dMm / 2000, dMm / 2000, len, 6);
geo.applyQuaternion(new THREE.Quaternion().setFromUnitVectors(new THREE.Vector3(0, 1, 0), dir.normalize()));
geo.translate((a.x + b.x) / 2, (a.y + b.y) / 2, (a.z + b.z) / 2);
const mesh = new THREE.Mesh(geo, new THREE.MeshStandardMaterial({ color: rebarColor(color), roughness: 0.45, metalness: 0.1 }));
mesh.userData = { name, roleColor: color, dMm, member: memberOfBar(name) };
group.add(mesh);
const list = byName.get(name) ?? [];
list.push(mesh);
byName.set(name, list);
}
};
const c = r.settings.cSide;
const { B13, B24, B57, B68 } = r.z;
// B1–B4: across the width. B1 sits beside B2 along the axis (옆붙임).
r.main.positions.forEach((s, i) => {
if (i % 2 === 0) {
const s1 = s + r.sideShift.B1;
add('B1', [main(s1, c, B24), main(s1, c, B13), main(s1, g.W - c, B13), main(s1, g.W - c, B24)], C.main);
add('B2', [main(s, c, B13), main(s, c, B24), main(s, g.W - c, B24), main(s, g.W - c, B13)], C.main);
} else {
add('B3', [main(s, c, B13), main(s, g.xf - c, B13)], C.main);
add('B4', [main(s, c, B24), main(s, g.W - c, B24)], C.main);
}
});
for (const off of r.skewEnd?.offsets ?? []) {
for (const s of [c + off, g.L - c - off]) {
add('B1-S', [P(s, c, B13), P(s, g.W - c, B13)], C.main);
add('B2-S', [P(s, c, B24), P(s, g.W - c, B24)], C.main);
}
}
// B5–B8: along the axis, cut at the end cover (c / sin θ). Top bars only where the 삽도 keeps them.
const e = c / g.angle.sin;
r.dist.positions.forEach((x, i) => {
const one = i % 2 === 0;
if (r.dist.topKept[i]) {
if (one) {
const x5 = x + r.sideShift.B5;
add('B5', [P(e, x5, B68), P(e, x5, B57), P(g.L - e, x5, B57), P(g.L - e, x5, B68)], C.dist);
} else add('B7', [P(e, x, B57), P(g.L - e, x, B57)], C.dist);
}
if (one) add('B6', [P(e, x, B57), P(e, x, B68), P(g.L - e, x, B68), P(g.L - e, x, B57)], C.dist);
else add('B8', [P(e, x, B68), P(g.L - e, x, B68)], C.dist);
});
// B9: vertical, B2 → B1 level.
for (const row of r.shear.rows) for (const s of row.s) add('B9', [main(s, row.x, B24), main(s, row.x, B13)], C.shear);
// B10-1 on the front and back faces (one bar above the level, as in the 단면), B10-2 on the two skew end faces.
const { depth1, depth2, levels } = r.side;
const e2 = depth2 / g.angle.sin;
for (const z of levels) {
const z1 = z + NOMINAL_DIAMETER[sizeOf.get('B10-1') ?? 'H19'];
for (const x of [depth1, g.W - depth1]) add('B10-1', [P(e2, x, z1), P(g.L - e2, x, z1)], C.side);
for (const s of [e2, g.L - e2]) add('B10-2', [P(s, depth1, z), P(s, g.W - depth1, z)], C.side);
}
// 벽체 · 흉벽: the rule paths in (u, t, z) mm — u on the skew grid, t from the axis, z from the 저판 하면.
const toScene = ([u, t, z]: [number, number, number]): P3 => [u / 1000 + (skewK * t) / 1000, bottomEl + z / 1000, t / 1000];
if (wall) {
const W = Object.fromEntries(Object.entries(ABUT_WALL_COLORS).map(([k, v]) => [k, parseInt(v.slice(1), 16)])) as Record<keyof typeof ABUT_WALL_COLORS, number>;
const colorOf = (mark: string) =>
['W1', 'W3', 'W4', 'W11'].includes(mark) ? W.vertical : mark === 'W7' ? W.tie : mark === 'W9' || mark === 'W10' ? W.step : W.horizontal;
for (const p of wall.paths) add(p.mark, p.pts.map(toScene), colorOf(p.mark));
}
if (parapet) {
const P = Object.fromEntries(Object.entries(ABUT_PARAPET_COLORS).map(([k, v]) => [k, parseInt(v.slice(1), 16)])) as Record<keyof typeof ABUT_PARAPET_COLORS, number>;
const colorOf = (mark: string) =>
['P1', 'P2', 'P3', 'P11'].includes(mark) ? P.vertical : mark === 'P6' || mark === 'P7' ? P.tie : ['P8', 'P9', 'P10'].includes(mark) ? P.side : P.horizontal;
for (const p of parapet.paths) add(p.mark, p.pts.map(toScene), colorOf(p.mark));
}
const G = Object.fromEntries(Object.entries(ABUT_WING_COLORS).map(([k, v]) => [k, parseInt(v.slice(1), 16)])) as Record<keyof typeof ABUT_WING_COLORS, number>;
const wingColor = (mark: string) =>
['WR1', 'WR2', 'WR11', 'WR12'].includes(mark) ? G.vertical : /^WR(3|4|5|13|14|15)$/.test(mark) ? G.horizontal : mark === 'WR6' || mark === 'WR20' ? G.other : G.haunch;
for (const w of wings) for (const p of w.paths) add(p.mark, p.pts.map(([s, t, z]): P3 => [s / 1000, bottomEl + z / 1000, t / 1000]), wingColor(p.mark));
if (slab) {
const S = Object.fromEntries(Object.entries(ABUT_SLAB_COLORS).map(([k, v]) => [k, parseInt(v.slice(1), 16)])) as Record<keyof typeof ABUT_SLAB_COLORS, number>;
const slabColor = (mark: string) => (mark === 'R1' || mark === 'R3' ? S.main : mark === 'R7' ? S.corner : mark === 'R8' ? S.shear : S.dist);
for (const p of slab.paths) add(p.mark, p.pts.map(([s, t, z]): P3 => [s / 1000, bottomEl + z / 1000, t / 1000]), slabColor(p.mark));
}
if (blocks) {
const B = Object.fromEntries(Object.entries(ABUT_BLOCK_COLORS).map(([k, v]) => [k, parseInt(v.slice(1), 16)])) as Record<'s1' | 's2', number>;
for (const p of blocks.paths) add(p.mark, p.pts.map(([s, t, z]): P3 => [s / 1000, bottomEl + z / 1000, t / 1000]), p.mark === 'S1' ? B.s1 : B.s2);
}
return { group, byName, records: [], ...rebarControls(byName) };
}
+524
View File
@@ -0,0 +1,524 @@
/**
* 교대 3D 장면 (2026-11-03): 벽체, 흉벽, 저판, 말뚝, 날개벽, 헌치를 Three.js 그룹으로 만든다. 순수 함수 (렌더러 없음).
*
* 좌표 (장면 기준): x = 교대축 방향 s (IP1 → IP3), y = EL (위 +), z = 교대축 직각 t (전면 +).
* 날개벽 평면(E, N)은 축 기준 (s, t)로 바꿔 쓴다: s = (P − IP1)·u, t = (P − IP1)·(전면 법선).
* 흉벽·벽체 단면은 모식도다. 흉벽은 저판 상면에서 상단까지 그린다(교좌면 아래 세부는 배근 단계에서).
*/
import type { BearingSole } from '../core/sole-plate';
import { jointPositions } from '../core/abut/joints';
import * as THREE from 'three';
import type { AbutModel, AbutPoint } from '../core/abut/geometry';
import { buildMembers, type AbutMembers } from '../core/abut/members';
import { buildWings, type WingSolid } from '../core/abut/wings';
import { resolveSlab, slabGradePct, slabLevelAt, slabSection, type AbutSlab } from '../core/abut/slab';
import type { TerrainGrid } from '../core/ground-model';
import { terrainGeometry } from './terrain';
export const ABUT_GROUP_NAMES = ['wall', 'parapet', 'footing', 'blinding', 'piles', 'wings', 'haunches', 'ground', 'blocks', 'bearings', 'solplates', 'slab', 'joints'] as const;
/** 말뚝 두부 매입 (m): the drawn pile head goes this far into the 저판 (drawing only). */
const PILE_HEAD_EMBED_M = 0.1;
/** Keeps the part of a convex plan polygon with tLo ≤ t ≤ tHi (two half-plane cuts). */
function clipT(poly: [number, number][], tHi: number, tLo: number): [number, number][] {
const cut = (pts: [number, number][], keep: (t: number) => boolean, edge: number) => {
const out: [number, number][] = [];
for (let i = 0; i < pts.length; i++) {
const a = pts[i];
const b = pts[(i + 1) % pts.length];
if (keep(a[1])) out.push(a);
if (keep(a[1]) !== keep(b[1])) {
const k = (edge - a[1]) / (b[1] - a[1]);
out.push([a[0] + k * (b[0] - a[0]), edge]);
}
}
return out;
};
return cut(cut(poly, (t) => t <= tHi + 1e-9, tHi), (t) => t >= tLo - 1e-9, tLo);
}
/**
* 접속슬래브 solid (2026-11-04): the plan polygon (s, t), cut into strips at the section stations (t, descending). Inside a
* strip the top and the bottom are flat, so each strip is fanned; the sides follow the plan edges, split at the stations.
* Double-sided material: the winding is not critical.
*/
function slabGeometry(plan: [number, number][], stationsT: number[], level: (s: number, t: number) => { top: number; bottom: number }): THREE.BufferGeometry {
const positions: number[] = [];
const push = (s: number, y: number, t: number) => {
positions.push(s, y, t);
return positions.length / 3 - 1;
};
const indices: number[] = [];
for (let i = 0; i + 1 < stationsT.length; i++) {
const strip = clipT(plan, stationsT[i], stationsT[i + 1]);
if (strip.length < 3) continue;
for (const face of ['top', 'bottom'] as const) {
const ids = strip.map(([s, t]) => push(s, level(s, t)[face], t));
for (let j = 1; j + 1 < ids.length; j++) indices.push(ids[0], ids[j], ids[j + 1]);
}
}
for (let i = 0; i < plan.length; i++) {
const a = plan[i];
const b = plan[(i + 1) % plan.length];
const cuts = stationsT.filter((t) => (t - a[1]) * (t - b[1]) < 0).map((t) => (t - a[1]) / (b[1] - a[1]));
const ks = [0, ...cuts.sort((x, y) => x - y), 1];
for (let j = 0; j + 1 < ks.length; j++) {
const p = [a[0] + ks[j] * (b[0] - a[0]), a[1] + ks[j] * (b[1] - a[1])];
const q = [a[0] + ks[j + 1] * (b[0] - a[0]), a[1] + ks[j + 1] * (b[1] - a[1])];
const lp = level(p[0], p[1]);
const lq = level(q[0], q[1]);
const v = [push(p[0], lp.bottom, p[1]), push(q[0], lq.bottom, q[1]), push(q[0], lq.top, q[1]), push(p[0], lp.top, p[1])];
indices.push(v[0], v[1], v[2], v[0], v[2], v[3]);
}
}
const geom = new THREE.BufferGeometry();
geom.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3));
geom.setIndex(indices);
geom.computeVertexNormals();
return geom;
}
/** 지반 pad margin (m) around the abutment plan, like the pier's GROUND_PAD. */
export const ABUT_GROUND_MARGIN_M = 2;
/** The third axis point is IP3; the model keeps IP1 only, so it is taken from the axis length and direction. */
function input3(model: AbutModel): AbutPoint {
return { e: model.ip1.e + model.u.e * model.length, n: model.ip1.n + model.u.n * model.length };
}
/** True when every point (with margin m) lies inside the terrain grid's E–N extent. */
export function covers(grid: TerrainGrid, points: AbutPoint[], margin: number): boolean {
const eMin = grid.origin[1];
const eMax = grid.origin[1] + (grid.ny - 1) * grid.step;
const nMin = grid.origin[0];
const nMax = grid.origin[0] + (grid.nx - 1) * grid.step;
return points.every((p) => p.e - margin >= eMin && p.e + margin <= eMax && p.n - margin >= nMin && p.n + margin <= nMax);
}
/** Extrudes a closed profile given in its own (x, y) plane, placed by the basis vectors ax, ay and the extrusion az. */
function prism(profile: [number, number][], depth: number, origin: THREE.Vector3, ax: THREE.Vector3, ay: THREE.Vector3, az: THREE.Vector3): THREE.BufferGeometry {
const shape = new THREE.Shape(profile.map(([x, y]) => new THREE.Vector2(x, y)));
const geom = new THREE.ExtrudeGeometry(shape, { depth, bevelEnabled: false, steps: 1 });
geom.applyMatrix4(new THREE.Matrix4().makeBasis(ax, ay, az).setPosition(origin));
return geom;
}
/** Solplate wedge in the block frame: W along x, L along z, bottom at y0; thickness T1 at z = side·L/2, T2 at the other end. */
function solplateWedge(L: number, W: number, T1: number, T2: number, side: 1 | -1, y0: number): THREE.BufferGeometry {
const g = new THREE.BoxGeometry(W, 1, L);
const pos = g.getAttribute('position') as THREE.BufferAttribute;
for (let i = 0; i < pos.count; i++) {
const z = pos.getZ(i), top = pos.getY(i) > 0;
const thick = T1 + ((T2 - T1) * (L / 2 - side * z)) / L;
pos.setY(i, y0 + (top ? thick : 0));
}
pos.needsUpdate = true;
g.computeVertexNormals();
return g;
}
function material(color: number): THREE.MeshStandardMaterial {
return new THREE.MeshStandardMaterial({ color, side: THREE.DoubleSide, roughness: 0.85 });
}
/** Local (s, t) of a plan point, relative to the IP1 end of the axis. */
function toAxis(model: AbutModel, p: AbutPoint): { s: number; t: number } {
const d = { e: p.e - model.ip1.e, n: p.n - model.ip1.n };
const front = { e: model.nLeft.e * model.frontSide, n: model.nLeft.n * model.frontSide };
return { s: d.e * model.u.e + d.n * model.u.n, t: d.e * front.e + d.n * front.n };
}
/** Keeps the part of a 2-D profile where g(x, y) ≥ 0 (Sutherland–Hodgman; g is linear, so the cut is a straight line). */
function clipByLine(poly: [number, number][], g: (x: number, y: number) => number): [number, number][] {
const out: [number, number][] = [];
for (let i = 0; i < poly.length; i++) {
const p = poly[i];
const q = poly[(i + 1) % poly.length];
const gp = g(p[0], p[1]);
const gq = g(q[0], q[1]);
if (gp >= 0) out.push(p);
if (gp * gq < 0) {
const f = gp / (gp - gq);
out.push([p[0] + (q[0] - p[0]) * f, p[1] + (q[1] - p[1]) * f]);
}
}
return out;
}
/** Keeps the part of a 2-D profile with x ≥ x0 (Sutherland–Hodgman against one line). */
function clipBelowX(poly: [number, number][], x0: number): [number, number][] {
const out: [number, number][] = [];
for (let i = 0; i < poly.length; i++) {
const p = poly[i];
const q = poly[(i + 1) % poly.length];
if (p[0] >= x0) out.push(p);
if ((p[0] - x0) * (q[0] - x0) < 0) {
const y = p[1] + ((q[1] - p[1]) * (x0 - p[0])) / (q[0] - p[0]);
out.push([x0, y]);
}
}
return out;
}
function wingDirectionAxis(model: AbutModel): { ws: number; wt: number } {
const rad = ((model.wing?.lineDeg ?? 0) * Math.PI) / 180;
const w = { e: Math.cos(rad), n: Math.sin(rad) };
const front = { e: model.nLeft.e * model.frontSide, n: model.nLeft.n * model.frontSide };
return { ws: w.e * model.u.e + w.n * model.u.n, wt: w.e * front.e + w.n * front.n };
}
/**
* Plan (s, t) outlines of the abutment: the 벽체 (skew), each wing, and the 접속슬래브 between the wings (spec §4.8).
* The slab starts 흉벽과 간격 behind the 흉벽 back face, runs 길이 back, keeps 날개벽과 간격 from each wing's inner face, and
* is chamfered at the 흉벽 corners by that wing's 헌치 (2026-11-04: 모따기 = 날개벽 헌치). No wing: the 교좌면 끝선, no chamfer.
*/
export interface AbutPlanOutline {
wall: [number, number][];
wings: { side: 'left' | 'right'; sEnd: number; poly: [number, number][] }[];
slab: [number, number][];
}
export function abutPlanOutline(model: AbutModel, members: AbutMembers, wings: WingSolid[], slab: AbutSlab = resolveSlab()): AbutPlanOutline {
const L = model.length;
const k = abutSkewK(model);
const tb = members.shape.wallBackM;
const tf = members.shape.wallFrontM;
const near = members.shape.parapetBackM - slab.parapetGapMm / 1000;
const far = near - slab.lengthMm / 1000;
const sideK = (w: WingSolid) => {
const p0 = toAxis(model, w.corners[0]);
const p1 = toAxis(model, w.corners[1]);
return (p1.s - p0.s) / (p1.t - p0.t);
};
const innerS = (sEnd: number, t: number): number => {
const w = wings.find((x) => x.sEnd === sEnd);
if (!w) return sEnd + k * t; // no wing on this side: the slab ends at the 교좌면 끝선 (skew)
const a = w.sEnd === 0 ? 1 : -1;
return sEnd + a * (w.thicknessM + slab.wingGapMm / 1000) + sideK(w) * t;
};
// Near corner of one side: [point on the side line, point on the near edge] (chamfer), or the corner alone.
const nearCorner = (sEnd: number): [number, number][] => {
const w = wings.find((x) => x.sEnd === sEnd);
const corner: [number, number] = [innerS(sEnd, near), near];
if (!w || !(w.haunchM > 0)) return [corner];
const c = w.haunchM;
const a = sEnd === 0 ? 1 : -1;
const tSide = near - c / Math.hypot(1, sideK(w));
return [[innerS(sEnd, tSide), tSide], [corner[0] + a * c, near]];
};
const axis = (c: AbutPoint): [number, number] => {
const p = toAxis(model, c);
return [p.s, p.t];
};
return {
wall: [[k * tb, tb], [L + k * tb, tb], [L + k * tf, tf], [k * tf, tf]],
wings: wings.map((w) => ({ side: w.side, sEnd: w.sEnd, poly: w.corners.map(axis) })),
slab: [...nearCorner(0), ...nearCorner(L).reverse(), [innerS(L, far), far], [innerS(0, far), far]],
};
}
/** Skew of the plan (s per m of t along the wing), the same one the 저판 · 벽체 · 말뚝 열 use. */
export function abutSkewK(model: AbutModel): number {
const d = wingDirectionAxis(model);
return d.ws / d.wt;
}
/**
* Placement of the abutment in the pier scene (2026-11-03). The pier scene maps (E, N, EL) to
* (E − pivot.E, EL, −(N − pivot.N)); the abutment's local (s, t, EL) → (E, N) with s along u and t along the front
* normal, then the same mapping. So the abutment and any pier in the same workbook share one frame.
*/
export function abutPlacement(model: AbutModel, pivot: { e: number; n: number }): THREE.Matrix4 {
const f = { e: model.nLeft.e * model.frontSide, n: model.nLeft.n * model.frontSide };
const xs = new THREE.Vector3(model.u.e, 0, -model.u.n);
const y = new THREE.Vector3(0, 1, 0);
const zt = new THREE.Vector3(f.e, 0, -f.n);
const origin = new THREE.Vector3(model.ip1.e - pivot.e, 0, -(model.ip1.n - pivot.n));
return new THREE.Matrix4().makeBasis(xs, y, zt).setPosition(origin);
}
export function buildAbutGroup(
model: AbutModel,
members: AbutMembers = buildMembers(model),
wings: WingSolid[] = buildWings(model, members),
pivot?: { e: number; n: number },
groundEl?: number,
terrain?: TerrainGrid,
slabInput?: Partial<AbutSlab>,
/** Solplate of each bearing (abutSoles, 2026-11-06): the bearing body and the solplate wedge, and the block-top drop. */
soles?: BearingSole[],
/** 수축줄눈 위치 s (m, core/abut/joints.ts). Absent = the default count between the bearings. */
jointS?: number[],
): THREE.Group {
const root = new THREE.Group();
root.name = model.name;
if (pivot) {
// applyMatrix4 keeps auto-update on, so the world matrix is refreshed by the scene and by bounding-box calls.
root.applyMatrix4(abutPlacement(model, pivot));
}
const L = model.length;
const ex = new THREE.Vector3(1, 0, 0);
const ey = new THREE.Vector3(0, 1, 0);
const ez = new THREE.Vector3(0, 0, 1);
const add = (name: (typeof ABUT_GROUP_NAMES)[number], geom: THREE.BufferGeometry, color: number) => {
const mesh = new THREE.Mesh(geom, material(color));
mesh.name = name;
let group = root.getObjectByName(name) as THREE.Group | undefined;
if (!group) {
group = new THREE.Group();
group.name = name;
root.add(group);
}
group.add(mesh);
};
const footingTop = members.footingTopEl;
// Skew (2026-11-03): the 벽체·흉벽 plan follows the wing lines, s = s_axis + k·t, with k = ws/wt of the wing direction.
const skewAxis = wingDirectionAxis(model);
const skewK = skewAxis.ws / skewAxis.wt;
// A prism whose profile (s, EL) is given at the face t0 and which is extruded along t with the skew (λ·(k, 0, 1)).
const skewPrism = (profile: [number, number][], depth: number, t0: number) =>
prism(
profile.map(([s, y]) => [s + skewK * t0, y] as [number, number]),
depth,
new THREE.Vector3(0, 0, t0),
ex,
ey,
new THREE.Vector3(skewK, 0, 1),
);
const footingBottom = members.footingBottomEl;
// 벽체 (spec §4.3, 삽도): the full 2400 block from the footing top up to the 교좌면 step (t −0.8 … 1.6).
const parapetTopAt = (s: number) => members.parapetTopStartEl + ((members.parapetTopEndEl - members.parapetTopStartEl) * s) / L;
const bracketAt = (s: number) => parapetTopAt(s) - members.shape.bracketHeightM;
for (const seg of members.wallSegments) {
const block: [number, number][] = [
[seg.sFrom, footingTop],
[seg.sTo, footingTop],
[seg.sTo, seg.seatEl],
[seg.sFrom, seg.seatEl],
];
add('wall', skewPrism(block, members.shape.wallFrontM - members.shape.wallBackM, members.shape.wallBackM), 0xb7c7d3);
}
// 흉벽 (삽도): sits on the 벽체 top, above the 교좌면 step. The back strip (t −0.8 … −0.5) rises to the 브라켓 상면 (흉벽 상단 − 0.85),
// where the 접속슬래브 sits; the 흉벽 (t −0.5 … 0) rises to the parapet top line. Both start at the 교좌면 step, so nothing overlaps the 벽체.
for (const seg of members.wallSegments) {
const backStrip: [number, number][] = [
[seg.sFrom, seg.seatEl],
[seg.sTo, seg.seatEl],
[seg.sTo, bracketAt(seg.sTo)],
[seg.sFrom, bracketAt(seg.sFrom)],
];
add('parapet', skewPrism(backStrip, members.shape.parapetBackM - members.shape.wallBackM, members.shape.wallBackM), 0x8898aa);
const parapet: [number, number][] = [
[seg.sFrom, seg.seatEl],
[seg.sTo, seg.seatEl],
[seg.sTo, parapetTopAt(seg.sTo)],
[seg.sFrom, parapetTopAt(seg.sFrom)],
];
add('parapet', skewPrism(parapet, -members.shape.parapetBackM, members.shape.parapetBackM), 0x8898aa);
}
// 수축줄눈 (spec_abut §4.9, 2026-11-06): lines only, on the 벽체 · 흉벽 faces (the 저판 is continuous). At each joint s:
// 벽체 전면 (t = 교좌면 끝) 저판 상면 → 교좌면, 흉벽 전면 (t = 0) 교좌면 → 흉벽 상단, 배면 (t = 벽체 배면) 저판 상면 → 브라켓 상면,
// 흉벽 배면 브라켓 상면 → 흉벽 상단. A joint between two steps takes the lower 교좌면. Lifted 3 mm off the faces.
{
const joints = jointS ?? jointPositions(L, model.bearings.map((b) => b.s));
const seatAt = (s: number) => Math.min(...members.wallSegments.filter((g) => s >= g.sFrom - 1e-6 && s <= g.sTo + 1e-6).map((g) => g.seatEl));
const pts: number[] = [];
const line = (s: number, t: number, y0: number, y1: number) => { if (y1 > y0) pts.push(s + skewK * t, y0, t, s + skewK * t, y1, t); };
const lift = 0.003, sh = members.shape;
for (const s of joints) {
const seat = seatAt(s);
if (!Number.isFinite(seat)) continue;
line(s, sh.wallFrontM + lift, footingTop, seat);
line(s, 0 + lift, seat, parapetTopAt(s));
line(s, sh.wallBackM - lift, footingTop, bracketAt(s));
line(s, sh.parapetBackM - lift, bracketAt(s), parapetTopAt(s));
}
if (pts.length) {
const g = new THREE.BufferGeometry();
g.setAttribute('position', new THREE.Float32BufferAttribute(pts, 3));
const lines = new THREE.LineSegments(g, new THREE.LineBasicMaterial({ color: 0x1b2733 }));
lines.name = 'joints';
const group = new THREE.Group();
group.name = 'joints';
group.add(lines);
root.add(group);
}
}
if (footingBottom !== undefined) {
// 저판: s 0 … L, t −1.8 … +2.8, from the 기초 하단 to the 저판 상면.
// Skew (2026-11-03): the 저판 ends follow the wing outer lines (s = sEnd + (ws/wt)·t), so the plan is a parallelogram.
const footProfile: [number, number][] = [
[skewK * members.shape.footingBackM, members.shape.footingBackM],
[L + skewK * members.shape.footingBackM, members.shape.footingBackM],
[L + skewK * members.shape.footingFrontM, members.shape.footingFrontM],
[skewK * members.shape.footingFrontM, members.shape.footingFrontM],
];
add('footing', prism(footProfile, members.shape.footingThicknessM, new THREE.Vector3(0, footingBottom, 0), ex, ez, ey), 0x5a6a78);
// 버림 (2026-11-04): under the 저판, wider by the 여유 on every side, the same skew.
const m = members.shape.blindingMarginM;
const th = members.shape.blindingThicknessM;
if (th > 0) {
const tb = members.shape.footingBackM - m;
const tf = members.shape.footingFrontM + m;
const blindProfile: [number, number][] = [
[skewK * tb - m, tb],
[L + skewK * tb + m, tb],
[L + skewK * tf + m, tf],
[skewK * tf - m, tf],
];
add('blinding', prism(blindProfile, th, new THREE.Vector3(0, footingBottom - th, 0), ex, ez, ey), 0x9aa29a);
}
}
// 받침블럭 (spec §4.2, §6.6): concrete 1200 × 1200 from the 교좌면 to 50 below z, and the 몰탈패드 1000 × 1000 × 50 up to z.
// The plan follows the 교대축 (받침 방위각 = 축 방향). t is the 전면 +, so the bearing's lateral offset is taken with the front side.
// The block turns by the bearing azimuth (rotDeg, from the 방위각 input) about its own centre.
// 교량받침 본체 · 솔플레이트 (2026-11-06, 교각과 같은 개념): the body is the solplate plan less 100 mm each side, height h3,
// on the pad; the solplate tapers along the girder (L) with T1 on the 오르막 쪽. 거더 하면 고정: the block top and the pad
// drop by T − T_ref (as the 받침 ELEVATION TABLE).
const soleOf = new Map((soles ?? []).map((s) => [s.no, s]));
for (const b of model.bearings) {
const t = model.frontSide * b.lateral;
const sole = soleOf.get(b.no);
const padTop = b.z - (sole?.dropMm ?? 0) / 1000;
const blockTop = padTop - 0.05;
const turn = (b.rotDeg * Math.PI) / 180;
const placed = (g: THREE.BufferGeometry) => g.rotateY(turn).translate(b.s, 0, t);
add('blocks', placed(prism([[-0.6, b.seatEl], [0.6, b.seatEl], [0.6, blockTop], [-0.6, blockTop]], 1.2, new THREE.Vector3(0, 0, -0.6), ex, ey, ez)), 0xa9b4bf);
add('blocks', placed(prism([[-0.5, blockTop], [0.5, blockTop], [0.5, padTop], [-0.5, padTop]], 1.0, new THREE.Vector3(0, 0, -0.5), ex, ey, ez)), 0x6e7a86);
if (!sole) continue;
const L = sole.L / 1000, W = sole.W / 1000, h3 = sole.h3 / 1000;
// Local block frame after rotateY(turn): +z (the girder, L) is (sin turn)·u + (cos turn)·front in (E, N).
const front = { e: model.nLeft.e * model.frontSide, n: model.nLeft.n * model.frontSide };
const zDir = { e: Math.sin(turn) * model.u.e + Math.cos(turn) * front.e, n: Math.sin(turn) * model.u.n + Math.cos(turn) * front.n };
const t1Side = sole.uphill && zDir.e * sole.uphill.e + zDir.n * sole.uphill.n > 0 ? 1 : -1;
const body = new THREE.BoxGeometry(W - 0.2, h3, L - 0.2);
body.translate(0, padTop + h3 / 2, 0);
add('bearings', placed(body), 0x3d4a57);
add('solplates', placed(solplateWedge(L, W, sole.T1 / 1000, sole.T2 / 1000, t1Side, padTop + h3)), 0xc08a3e);
}
// 접속슬래브 (spec §4.8, 2026-11-04 매립형 / 노출형): the section (core/abut/slab.ts) is measured from the 흉벽 상단 at each s.
// Plan: between the wing inner faces with the gaps and chamfers, from abutPlanOutline (shared with the ② 날개벽 plan).
const slab = resolveSlab(slabInput);
const slabPlan = abutPlanOutline(model, members, wings, slab).slab;
const section = slabSection(slab, slabGradePct(slab, model.wing?.slopePct ?? 0), members.shape);
const slabMesh = new THREE.Mesh(
slabGeometry(
slabPlan,
section.map((p) => p.t),
(s, t) => {
const lv = slabLevelAt(section, t);
return { top: parapetTopAt(s) + lv.top, bottom: parapetTopAt(s) + lv.bottom };
},
),
new THREE.MeshStandardMaterial({ color: 0xd8c8a0, roughness: 0.9, side: THREE.DoubleSide }),
);
slabMesh.name = 'slab';
const slabGroup = new THREE.Group();
slabGroup.name = 'slab';
slabGroup.add(slabMesh);
root.add(slabGroup);
// 말뚝: 원기둥 (지름 입력, 기본 D600) at each row, from the 기초 하단 down to the 선단.
if (members.pileBottomEl !== undefined && footingBottom !== undefined) {
// The pile head runs PILE_HEAD_EMBED_M into the 저판, so its top cap is hidden inside the slab and does not z-fight
// with the 저판 bottom (the striped discs seen from below, 2026-11-04). The 선단 EL is unchanged.
const height = footingBottom + PILE_HEAD_EMBED_M - members.pileBottomEl;
for (const row of members.piles) {
for (const s of row.s) {
// 말뚝 지름은 입력값 (QA-5). Each row moves along s with the skew, like the 저판 (시안: 열마다 skew만큼 밀림).
const r = members.shape.pileDiameterM / 2;
const geom = new THREE.CylinderGeometry(r, r, height, 16);
geom.translate(s + skewK * row.t, members.pileBottomEl + height / 2, row.t);
add('piles', geom, 0x7d8b99);
}
}
}
// 날개벽: the side profile (d along the wing, EL) extruded across the 0.45 m thickness toward the inside of the axis.
const dir = wingDirectionAxis(model);
for (const wing of wings) {
const sEnd = wing.sEnd;
const a = sEnd === 0 ? 1 : -1;
const profile: [number, number][] = [
...wing.topLine.map((p) => [p.dM, p.el] as [number, number]),
...[...wing.bottomLine].reverse().map((p) => [p.dM, p.el] as [number, number]),
];
const ax = new THREE.Vector3(dir.ws, 0, dir.wt);
// 삽도 (A-A, 평면): above the 브라켓 상면 the wing meets the 흉벽 back face (t = −0.5); below it, the wing meets the 벽체 back face (t = −0.8).
// The part in front of those faces lies inside the 벽체 or 흉벽, so it is cut off. The bracket falls along s, so the cut follows it.
// The wing is 0.45 thick along s, so its footprint spans two s values at each dM; the cut uses the end where the 브라켓 is highest.
const slopeS = (members.parapetTopEndEl - members.parapetTopStartEl) / L;
const highEnd = slopeS < 0 ? Math.min(0, a * wing.thicknessM) : Math.max(0, a * wing.thicknessM);
const bracketAtDm = (dM: number) => members.parapetTopStartEl + slopeS * (sEnd + dir.ws * dM + highEnd) - members.shape.bracketHeightM;
const above = clipByLine(profile, (x, y) => y - bracketAtDm(x));
const below = clipByLine(profile, (x, y) => bracketAtDm(x) - y);
const upper = prism(clipBelowX(above, members.shape.parapetBackM / dir.wt), wing.thicknessM, new THREE.Vector3(sEnd, 0, 0), ax, ey, new THREE.Vector3(a, 0, 0));
const lower = prism(clipBelowX(below, members.shape.wallBackM / dir.wt), wing.thicknessM, new THREE.Vector3(sEnd, 0, 0), ax, ey, new THREE.Vector3(a, 0, 0));
add('wings', upper, 0xc9a77c);
add('wings', lower, 0xc9a77c);
// 저판 상면 헌치 (수평, 2026-11-03): a right triangle in the plane EL–s on the 저판 상면, next to the 날개벽 내측면,
// extruded along the wing from the 벽체 배면 to the 뒷굽.
if (wing.footingHaunch) {
const f = wing.footingHaunch;
const leg = wing.haunchM;
const triangle: [number, number][] = [[0, 0], [leg, 0], [0, leg]];
const origin = new THREE.Vector3(sEnd + a * wing.thicknessM + dir.ws * f.dMFrom, f.el, dir.wt * f.dMFrom);
add('haunches', prism(triangle, f.dMTo - f.dMFrom, origin, new THREE.Vector3(a, 0, 0), ey, new THREE.Vector3(dir.ws, 0, dir.wt)), 0xc9a77c);
}
for (const h of wing.haunches) {
const shape: [number, number][] = h.corners.map((c) => {
const p = toAxis(model, c);
return [p.s, p.t];
});
const zBottom = h.zBottomEl;
const height = h.zTopEl - h.zBottomEl;
if (height > 0) add('haunches', prism(shape, height, new THREE.Vector3(0, zBottom, 0), ex, ez, ey), 0xc9a77c);
}
}
// 지형: when the ground model's terrain grid covers the abutment footprint, the terrain is drawn (the same frame as the
// piers); otherwise a flat pad at 지반고 covers the footing and the wings.
const footprint = [model.ip1, input3(model), ...wings.flatMap((w) => w.corners)];
const grid = terrain && pivot && covers(terrain, footprint, ABUT_GROUND_MARGIN_M) ? terrain : undefined;
if (grid && pivot) {
const mesh = new THREE.Mesh(terrainGeometry(grid, pivot), new THREE.MeshStandardMaterial({ color: 0x6b8f4e, roughness: 0.9, transparent: true, opacity: 0.35, depthWrite: false, side: THREE.DoubleSide }));
const group = new THREE.Group();
group.name = 'ground';
group.add(mesh);
root.add(group);
} else if (groundEl !== undefined) {
const ts = [members.shape.footingBackM, members.shape.footingFrontM, ...wings.flatMap((w) => w.corners.map((c) => toAxis(model, c).t))];
const tMin = Math.min(...ts) - ABUT_GROUND_MARGIN_M;
const tMax = Math.max(...ts) + ABUT_GROUND_MARGIN_M;
const sMin = -ABUT_GROUND_MARGIN_M;
const sMax = L + ABUT_GROUND_MARGIN_M;
const pad = new THREE.BoxGeometry(sMax - sMin, 0.05, tMax - tMin);
pad.translate((sMin + sMax) / 2, groundEl, (tMin + tMax) / 2);
const mesh = new THREE.Mesh(pad, new THREE.MeshStandardMaterial({ color: 0x6b8f4e, roughness: 0.9, transparent: true, opacity: 0.35, depthWrite: false, side: THREE.DoubleSide }));
mesh.name = 'ground';
const group = new THREE.Group();
group.name = 'ground';
group.add(mesh);
root.add(group);
}
return root;
}
/**
* Camera for looking at one abutment (2026-11-03): the target is the centre of its bounding box, and the camera stands
* back along a diagonal from the bridge side so both the wall and the piles are in view.
*/
export function cameraForBox(box: THREE.Box3): { position: THREE.Vector3; target: THREE.Vector3 } {
const target = box.getCenter(new THREE.Vector3());
const size = box.getSize(new THREE.Vector3());
const span = Math.max(size.x, size.y, size.z, 1);
const dist = span * 1.6;
const position = new THREE.Vector3(target.x + dist * 0.6, target.y + dist * 0.5, target.z + dist * 0.8);
return { position, target };
}
+124
View File
@@ -0,0 +1,124 @@
/**
* 교대 접속슬래브 배근 삽도 (2026-11-05, 시안 ③ 접속슬래브 카드, 교대접속슬래브철근.md §9): 평면 (왼쪽 절반 하면 · 오른쪽 절반 상면,
* 삽도와 같음), 단면 (길이 방향, 폭 가운데). 순수 문자열 함수, 철근은 배근 규칙의 경로(slab.ts paths, 교대 s · t · z)에서 그린다.
* 색: 길이 방향 주철근 #0F5C66, 교대축 방향 #B4531F, R7 #C0501A, R8 #6B3FA0.
*/
import type { AbutSlabGeom, AbutSlabRebar, SlabBarPath, SlabMark } from '../core/abut/rebar/slab';
import { balloon, CONC, MUTED, NO_PICK, opacity, text, type AbutRebarPick } from './abut-footing-rebar-svg';
export const ABUT_SLAB_COLORS = { main: '#0F5C66', dist: '#B4531F', corner: '#C0501A', shear: '#6B3FA0' } as const;
const C = ABUT_SLAB_COLORS;
const COLOR: Record<SlabMark, string> = { R1: C.main, R3: C.main, R2: C.dist, R4: C.dist, R7: C.corner, R8: C.shear };
/** u (0 측면에서 교대축을 따라) and t' (흉벽 쪽 끝에서) of a plan point. */
const frame = (g: AbutSlabGeom) => ({
u: (s: number, t: number) => s - (g.side0.s + g.side0.k * (t - g.near)),
tp: (t: number) => g.near - t,
});
/** 평면: 가로 u (교대축), 세로 t' (위 = 흉벽 쪽 끝). 왼쪽 절반은 하면 (R3 · R4 · R8 · 하면 R7), 오른쪽 절반은 상면 (R1 · R2 · 상면 R7). */
export function abutSlabPlanSvg(g: AbutSlabGeom, r: AbutSlabRebar, pick: AbutRebarPick = NO_PICK): string {
const f = frame(g);
const k = 0.055;
const X = (u: number) => 50 + u * k;
const Y = (tp: number) => 60 + tp * k;
const half = g.B / 2;
const parts: string[] = [];
const P = (u: number, tp: number) => `${X(u).toFixed(1)},${Y(tp).toFixed(1)}`;
const outline = [P(g.chamfer0, 0), P(g.B - g.chamferL, 0), P(g.B, g.chamferL), P(g.B, g.Ls), P(0, g.Ls), P(0, g.chamfer0)];
parts.push(`<polygon points="${outline.join(' ')}" fill="${CONC}" stroke="${MUTED}"/>`);
parts.push(`<line x1="${X(half)}" y1="${Y(0) - 10}" x2="${X(half)}" y2="${Y(g.Ls) + 10}" stroke="${MUTED}" stroke-dasharray="6 4"/>`);
parts.push(text(X(half / 2), Y(g.Ls) + 24, '하면 (R3 · R4 · R8)', 'middle', MUTED, 11), text(X(half * 1.5), Y(g.Ls) + 24, '상면 (R1 · R2)', 'middle', MUTED, 11));
parts.push(text(X(half), Y(0) - 14, `흉벽 쪽 끝 · ${r.across.text}`, 'middle', MUTED, 10));
const seg = (mark: SlabMark, a: [number, number], b: [number, number], w: number) =>
`<line x1="${X(a[0]).toFixed(1)}" y1="${Y(a[1]).toFixed(1)}" x2="${X(b[0]).toFixed(1)}" y2="${Y(b[1]).toFixed(1)}" stroke="${COLOR[mark]}" stroke-width="${w}" opacity="${opacity(pick, [mark])}"/>`;
const uvOf = (p: SlabBarPath) => p.pts.map(([s, t]) => [f.u(s, t), f.tp(t)] as [number, number]);
for (const p of r.paths) {
const uv = uvOf(p);
const bottom = p.mark === 'R3' || p.mark === 'R4' || p.mark === 'R8';
if (p.mark === 'R8') {
const [u, tp] = uv[1];
if (u < half) parts.push(`<circle cx="${X(u).toFixed(1)}" cy="${Y(tp).toFixed(1)}" r="2.2" fill="${C.shear}" opacity="${opacity(pick, ['R8'])}"/>`);
continue;
}
if (p.mark === 'R7') {
// 상 · 하 R7은 평면에서 같은 자리라 그대로 그린다 (모서리마다 한 모양).
for (let i = 1; i < uv.length; i++) parts.push(seg('R7', uv[i - 1], uv[i], 1.6));
continue;
}
// 길이 방향 (R1 · R3)은 절반 안의 것만, 교대축 방향 (R2 · R4)은 절반까지 자름.
if (p.mark === 'R1' || p.mark === 'R3') {
const u = uv[0][0];
if (bottom ? u < half : u >= half) for (let i = 1; i < uv.length; i++) parts.push(seg(p.mark, uv[i - 1], uv[i], 0.7));
} else {
const [a, b] = [uv[0], uv[uv.length - 1]];
const lo = a[0] < b[0] ? a : b;
const hi = a[0] < b[0] ? b : a;
if (bottom) parts.push(seg(p.mark, lo, [Math.min(hi[0], half), hi[1]], 0.7));
else parts.push(seg(p.mark, [Math.max(lo[0], half), lo[1]], hi, 0.7));
}
}
parts.push(text(X(g.B) + 8, Y(g.Ls / 2), r.along.text, 'start', MUTED, 10));
parts.push(balloon(X(half * 0.25), Y(g.Ls) + 50, 'R3', C.main, pick), balloon(X(half * 0.5), Y(g.Ls) + 50, 'R4', C.dist, pick), balloon(X(half * 0.75), Y(g.Ls) + 50, 'R8', C.shear, pick));
parts.push(balloon(X(half * 1.4), Y(g.Ls) + 50, 'R1', C.main, pick), balloon(X(half * 1.7), Y(g.Ls) + 50, 'R2', C.dist, pick), balloon(X(g.B * 0.06), Y(0) + 40, 'R7', C.corner, pick));
const W = X(g.B) + 260;
const H = Y(g.Ls) + 80;
return `<svg viewBox="0 0 ${W.toFixed(0)} ${H.toFixed(0)}" width="100%" style="max-width:${Math.round(W * 1.3)}px" role="img" aria-label="접속슬래브 배근 평면 (왼쪽 하면 · 오른쪽 상면)">${parts.join('')}</svg>`;
}
/** 단면 (길이 방향, 폭 가운데): 가로 t' (왼쪽 흉벽 쪽 끝), 세로 높이. 경사, 층 순서 (R1 · R2 위, R4 · R3 아래), R8. */
export function abutSlabSectionSvg(g: AbutSlabGeom, r: AbutSlabRebar, pick: AbutRebarPick = NO_PICK): string {
const f = frame(g);
const uMid = g.B / 2;
const kx = 0.11;
const kz = 0.5;
const sMid = (t: number) => g.side0.s + g.side0.k * (t - g.near) + uMid;
const parapetAt = (sv: number) => g.parapetTop[0] + ((g.parapetTop[1] - g.parapetTop[0]) * sv) / g.L;
const zs = g.stations.flatMap((st) => [parapetAt(sMid(st.t)) + st.top, parapetAt(sMid(st.t)) + st.bottom]);
const zMax = Math.max(...zs) + 60;
const zMin = Math.min(...zs) - 60;
const X = (tp: number) => 40 + tp * kx;
const Y = (z: number) => 50 + (zMax - z) * kz;
const parts: string[] = [];
const topPts = g.stations.map((st) => `${X(f.tp(st.t)).toFixed(1)},${Y(parapetAt(sMid(st.t)) + st.top).toFixed(1)}`);
const botPts = [...g.stations].reverse().map((st) => `${X(f.tp(st.t)).toFixed(1)},${Y(parapetAt(sMid(st.t)) + st.bottom).toFixed(1)}`);
parts.push(`<polygon points="${[...topPts, ...botPts].join(' ')}" fill="${CONC}" stroke="${MUTED}"/>`);
// 폭 가운데에 가장 가까운 길이 방향 철근 (R1 · R3), 교대축 방향 철근 (R2 · R4) 점, R8 한 줄.
const nearest = (mark: SlabMark) =>
r.paths.filter((p) => p.mark === mark).reduce<SlabBarPath | undefined>((a, b) => (!a || Math.abs(f.u(b.pts[0][0], b.pts[0][1]) - uMid) < Math.abs(f.u(a.pts[0][0], a.pts[0][1]) - uMid) ? b : a), undefined);
for (const mark of ['R1', 'R3'] as const) {
const p = nearest(mark);
if (p) parts.push(`<polyline points="${p.pts.map(([, t, z]) => `${X(f.tp(t)).toFixed(1)},${Y(z).toFixed(1)}`).join(' ')}" fill="none" stroke="${C.main}" stroke-width="1.6" opacity="${opacity(pick, [mark])}"/>`);
}
for (const p of r.paths.filter((q) => q.mark === 'R2' || q.mark === 'R4')) {
const t = p.pts[0][1];
const a = p.pts[0];
const b = p.pts[p.pts.length - 1];
const fz = (uMid - f.u(a[0], t)) / (f.u(b[0], t) - f.u(a[0], t));
const z = a[2] + fz * (b[2] - a[2]);
parts.push(`<circle cx="${X(f.tp(t)).toFixed(1)}" cy="${Y(z).toFixed(1)}" r="1.8" fill="${C.dist}" opacity="${opacity(pick, [p.mark])}"/>`);
}
// R8: 줄마다 하나, 그린 R1 · R3 선 사이 (슬래브 상면이 교대축을 따라 기울어 실제 R8마다 높이가 조금씩 다름).
const zOn = (p: SlabBarPath | undefined, t: number) => {
if (!p) return NaN;
for (let i = 1; i < p.pts.length; i++) {
const [a0, a1] = [p.pts[i - 1], p.pts[i]];
if ((t - a0[1]) * (t - a1[1]) <= 0) return a0[2] + ((t - a0[1]) / (a1[1] - a0[1] || 1)) * (a1[2] - a0[2]);
}
return p.pts[p.pts.length - 1][2];
};
const r1 = nearest('R1');
const r3 = nearest('R3');
const rowTs = [...new Set(r.paths.filter((q) => q.mark === 'R8').map((q) => Math.round(q.pts[1][1])))];
for (const t of rowTs) {
const zt = zOn(r1, t) + 11.1;
const zb = zOn(r3, t) - 11.1;
parts.push(`<line x1="${X(f.tp(t)).toFixed(1)}" y1="${Y(zb).toFixed(1)}" x2="${X(f.tp(t)).toFixed(1)}" y2="${Y(zt).toFixed(1)}" stroke="${C.shear}" stroke-width="0.9" opacity="${opacity(pick, ['R8'])}"/>`);
}
parts.push(text(X(0), Y(zMin) + 16, '흉벽 쪽 끝 (브라켓 위)', 'start', MUTED, 10), text(X(g.Ls), Y(zMin) + 16, `먼 끝 · 경사 ${g.gradePct.toFixed(1)} %`, 'end', MUTED, 10));
parts.push(text(X(g.Ls / 2), 18, `${g.form === 'buried' ? '매립형' : '노출형'} · 상면 R1 → R2, 하면 R3 → R4 (피복 ${r.settings.cv}) · R8 @${Math.round(r.settings.k8 * (r.settings.sDist ?? r.settings.sL))}`, 'middle', MUTED, 10));
parts.push(balloon(X(g.Ls * 0.3), 40, 'R1', C.main, pick), balloon(X(g.Ls * 0.4), 40, 'R2', C.dist, pick), balloon(X(g.Ls * 0.5), 40, 'R8', C.shear, pick), balloon(X(g.Ls * 0.6), 40, 'R4', C.dist, pick), balloon(X(g.Ls * 0.7), 40, 'R3', C.main, pick));
const W = X(g.Ls) + 40;
const H = Y(zMin) + 30;
return `<svg viewBox="0 0 ${W.toFixed(0)} ${H.toFixed(0)}" width="100%" style="max-width:${Math.round(W * 1.2)}px" role="img" aria-label="접속슬래브 배근 단면 (길이 방향)">${parts.join('')}</svg>`;
}
+184
View File
@@ -0,0 +1,184 @@
/**
* 교대 접속슬래브 삽도 (2026-11-04, 시안 ② 접속슬래브 카드): 평면(날개벽 사이)과 단면 + 접합 상세(매립형 / 노출형, 중간 생략).
* 순수 문자열 함수. 생성본 값으로 그리고 가로·세로 축척이 같다(단면은 파단선으로 중간을 생략). 접합 부속재는 표시용이다.
*/
import type { AbutPlanOutline } from './abut-scene';
import { slabLevelAt, type SlabForm, type SlabStation } from '../core/abut/slab';
import { dimLine } from './abut-wing-svg';
const INK = '#18212b';
const MUTED = '#55606e';
const WING = '#d6bb8c';
const WALL = '#b7c7d3';
const SLAB = '#f3eee4';
const SLAB_EDGE = '#b49a6b';
const text = (x: number, y: number, s: string, anchor = 'start', fill = INK) =>
`<text x="${x.toFixed(1)}" y="${y.toFixed(1)}" text-anchor="${anchor}" font-size="11" fill="${fill}" font-family="JetBrains Mono, monospace">${s}</text>`;
const poly = (pts: [number, number][], style: string) => `<polygon points="${pts.map(([x, y]) => `${x.toFixed(1)},${y.toFixed(1)}`).join(' ')}" ${style}/>`;
const line = (x1: number, y1: number, x2: number, y2: number, style = `stroke="${MUTED}"`) =>
`<line x1="${x1.toFixed(1)}" y1="${y1.toFixed(1)}" x2="${x2.toFixed(1)}" y2="${y2.toFixed(1)}" ${style}/>`;
const el = (v: number) => `EL.${v.toFixed(3)}`;
export interface AbutSlabPlanDrawing {
outline: AbutPlanOutline;
/** 좌 = ① 쪽 끝 (sEnd); 날개벽 평면과 같은 좌우. */
leftSEnd: number;
widthM: number;
lengthMm: number;
/** 측별 모따기 (mm, 0 = 없음). */
chamferMm: { left: number; right: number };
}
/** 평면: 벽체, 날개벽, 접속슬래브(모따기), 폭 · 길이 치수. 왼쪽이 ① 쪽(좌), 위가 전면. */
export function abutSlabPlanSvg(d: AbutSlabPlanDrawing): string {
const pts = [...d.outline.wall, ...d.outline.slab, ...d.outline.wings.flatMap((w) => w.poly)];
const flip = d.leftSEnd !== 0;
const xs = pts.map(([s]) => (flip ? -s : s));
const ts = pts.map(([, t]) => t);
const k = 30;
const xMin = Math.min(...xs) - 1.6;
const tMax = Math.max(...ts) + 0.4;
const X = (s: number) => 20 + ((flip ? -s : s) - xMin) * k;
const Y = (t: number) => 18 + (tMax - t) * k;
const W = 40 + (Math.max(...xs) - xMin + 0.6) * k;
const tMin = Math.min(...d.outline.slab.map(([, t]) => t));
const H = Y(tMin) + 46;
const map = (p: [number, number][]) => p.map(([s, t]) => [X(s), Y(t)] as [number, number]);
const slab = d.outline.slab;
const parts = [
poly(map(d.outline.wall), `fill="${WALL}" stroke="${MUTED}"`),
...d.outline.wings.map((w) => poly(map(w.poly), `fill="${WING}" stroke="#9c8763"`)),
poly(map(slab), `fill="${SLAB}" stroke="${SLAB_EDGE}" stroke-width="1.4"`),
];
// 폭: the far edge (the last two corners), below it. 길이: the left side, from the near edge to the far edge.
const farA = slab[slab.length - 1];
const farB = slab[slab.length - 2];
const [fl, fr] = X(farA[0]) < X(farB[0]) ? [farA, farB] : [farB, farA];
parts.push(dimLine([X(fl[0]), Y(fl[1])], [X(fr[0]), Y(fr[1])], Y(tMin) + 16, 'h', `폭 ${d.widthM.toFixed(3)} m`, true));
const tNear = Math.max(...slab.map(([, t]) => t));
const xLeft = Math.min(...slab.map(([s]) => X(s)));
const nearLeft = slab.filter(([, t]) => Math.abs(t - tNear) < 1e-9).map(([s]) => X(s)).sort((a, b) => a - b)[0];
parts.push(dimLine([nearLeft, Y(tNear)], [X(fl[0]), Y(fl[1])], xLeft - 12, 'v', `${d.lengthMm}`));
const c = slab.reduce((a, [s, t]) => [a[0] + s / slab.length, a[1] + t / slab.length], [0, 0]);
parts.push(text(X(c[0]), Y(c[1]), '접속슬래브', 'middle'));
for (const w of d.outline.wings) {
const mm = w.side === 'left' ? d.chamferMm.left : d.chamferMm.right;
if (!(mm > 0)) continue;
const x = X(w.sEnd) + (X(c[0]) > X(w.sEnd) ? 1 : -1) * 34;
parts.push(text(x, Y(tNear) + 26, `모따기 ${mm} × ${mm}`, X(c[0]) > X(w.sEnd) ? 'start' : 'end', MUTED));
}
parts.push(text((X(0) + X(Math.max(...d.outline.wall.map(([s]) => s)))) / 2, 12, '흉벽 · 벽체 (위 = 전면)', 'middle', MUTED));
return [
`<svg viewBox="0 0 ${W.toFixed(0)} ${H.toFixed(0)}" width="100%" role="img" aria-label="접속슬래브 평면: 폭 ${d.widthM.toFixed(3)} m, 길이 ${d.lengthMm}">`,
...parts,
'</svg>',
].join('');
}
export interface AbutSlabSectionDrawing {
form: SlabForm;
/** "하향 4 %" / "종단경사 +1.91 %". */
slopeLabel: string;
/** 단면 (t, 흉벽 상단에서 잰 상면 · 하면), core/abut/slab.ts slabSection. */
section: SlabStation[];
/** 이 단면 위치의 흉벽 상단 EL (m). */
parapetTopEl: number;
bracketHeightM: number;
parapetBackM: number;
wallBackM: number;
lengthMm: number;
thicknessMm: number;
parapetGapMm: number;
haunchLengthMm: number;
}
/**
* 단면 + 접합 상세 (2026-11-04, 그림 합침): 교대축 직각, 왼쪽이 배면. 흉벽 쪽(헌치 끝 너머까지)과 먼 끝 0.8 m만 같은 축척으로
* 그리고 그 사이는 파단선으로 생략한다. 간격(5)과 고무판 두께는 보이도록 키워 그리고, 부속재(충진재 · 스티로폼 · 고무판 ·
* 타르페이퍼 · 다웰 · 강관)는 표시용이다. 치수: 7000 (중간 생략), 450, 흉벽 높이(브라켓 상면 ~ 흉벽 상단), 헌치, 브라켓 얹힘 길이.
*/
export function abutSlabSectionSvg(d: AbutSlabSectionDrawing): string {
const k = 150; // px per m in both zones
const st = d.section;
const t0 = st[0].t;
const t1 = st[st.length - 1].t;
const bH = d.bracketHeightM;
const wb = d.wallBackM;
const pb = d.parapetBackM;
const he = wb - d.haunchLengthMm / 1000;
const farLen = 0.8;
const tz = Math.max(d.form === 'exposed' ? he - 0.5 : wb - 1.5, t1 + farLen); // left end of the near zone
const breakW = 34;
const xL = 70;
const xNear0 = xL + farLen * k + breakW;
const X = (t: number) => (t <= t1 + farLen + 1e-9 ? xL + (t - t1) * k : xNear0 + (t - tz) * k);
const gapPx = 4; // 간격 drawn 4 px wide whatever its size
const Xs = (t: number) => X(t) - (t >= t0 - 1e-9 ? gapPx - (d.parapetGapMm / 1000) * k : 0);
const low = -bH - 0.75;
const hi = Math.max(0, ...st.map((p) => p.top)) + 0.15;
const Y = (y: number) => 26 + (hi - y) * k;
const lv = (t: number) => slabLevelAt(st, t);
// Slab pieces: far (t1 … t1 + 0.8) and near (tz … t0), each closed by a zigzag at the cut.
const zig = (x: number, top: number, bottom: number, dir: 1 | -1): [number, number][] => {
const h = bottom - top;
return [[x, top - 4], [x, top + h * 0.3], [x + dir * 6, top + h * 0.45], [x - dir * 6, top + h * 0.6], [x, top + h * 0.72], [x, bottom + 4]];
};
const piece = (from: number, to: number, cutLeft: boolean) => {
const ts = [from, ...st.map((p) => p.t).filter((t) => t > from + 1e-9 && t < to - 1e-9), to].sort((a, b) => a - b);
const top = ts.map((t) => [Xs(t), Y(lv(t).top)] as [number, number]);
const bottom = ts.map((t) => [Xs(t), Y(lv(t).bottom)] as [number, number]).reverse();
const cutT = cutLeft ? from : to;
const cut = zig(Xs(cutT), Y(lv(cutT).top), Y(lv(cutT).bottom), cutLeft ? 1 : -1);
return cutLeft ? [...top, ...bottom, ...cut.reverse()] : [...top, ...cut, ...bottom];
};
const slabStyle = `fill="${SLAB}" stroke="${SLAB_EDGE}" stroke-width="1.4"`;
const parts = [
poly([[X(wb), Y(low)], [X(0), Y(low)], [X(0), Y(0)], [X(pb), Y(0)], [X(pb), Y(-bH)], [X(wb), Y(-bH)]], `fill="${WALL}" stroke="${MUTED}"`),
poly(piece(t1, t1 + farLen, false), slabStyle),
poly(piece(tz, t0, true), slabStyle),
];
// 간격: 아스팔트 충진재 (위 30 %), 스티로폼 (아래).
const gTop = Y(st[0].top);
const gBot = Y(-bH);
const gMid = gTop + (gBot - gTop) * 0.3;
const gx = Xs(t0);
parts.push(`<rect x="${gx.toFixed(1)}" y="${gTop.toFixed(1)}" width="${(X(pb) - gx).toFixed(1)}" height="${(gMid - gTop).toFixed(1)}" fill="#3b3f45"/>`);
parts.push(`<rect x="${gx.toFixed(1)}" y="${gMid.toFixed(1)}" width="${(X(pb) - gx).toFixed(1)}" height="${(gBot - gMid).toFixed(1)}" fill="#ffffff" stroke="${MUTED}" stroke-width="0.5"/>`);
// 타르페이퍼 (브라켓 상면), 고무판 150 (가운데), 다웰 D25 in 강관 Φ60 L300.
const xc = (X(wb) + Xs(t0)) / 2;
parts.push(line(X(wb), Y(-bH), X(pb), Y(-bH), 'stroke="#8a5a2b" stroke-width="2.4"'));
parts.push(`<rect x="${(xc - 0.075 * k).toFixed(1)}" y="${(Y(-bH) - 3).toFixed(1)}" width="${(0.15 * k).toFixed(1)}" height="3" fill="#2b2b2b"/>`);
parts.push(`<rect x="${(xc - 0.03 * k).toFixed(1)}" y="${(Y(-bH) - 3 - 0.3 * k).toFixed(1)}" width="${(0.06 * k).toFixed(1)}" height="${(0.3 * k).toFixed(1)}" fill="none" stroke="${INK}" stroke-width="0.9"/>`);
parts.push(line(xc, Y(-bH) - 0.34 * k, xc, Y(-bH) + 0.3 * k, `stroke="${INK}" stroke-width="1.8"`));
// 치수.
const yRow = Y(-bH) + 18;
if (d.form === 'exposed') parts.push(dimLine([X(he), Y(lv(he).bottom)], [X(wb), Y(-bH)], yRow, 'h', `헌치 ${d.haunchLengthMm}`, true));
// 브라켓 얹힘 길이 = 브라켓 폭 − 흉벽과 간격 (예: 300 − 5 = 295).
parts.push(dimLine([X(wb), Y(-bH)], [Xs(t0), Y(-bH)], yRow, 'h', `${Math.round((t0 - wb) * 1000)}`, true));
const yLen = Y(low) + 22;
parts.push(dimLine([X(t1), Y(lv(t1).bottom)], [Xs(t0), Y(st[0].bottom)], yLen, 'h', `${d.lengthMm} (중간 생략)`, true));
parts.push(dimLine([X(t1), Y(lv(t1).top)], [X(t1), Y(lv(t1).bottom)], X(t1) - 12, 'v', `${d.thicknessMm}`));
parts.push(dimLine([X(0), Y(0)], [X(0), Y(-bH)], X(0) + 16, 'v', `${Math.round(bH * 1000)}`, true));
// 글자: 경사, EL, 부속재 (오른쪽 · 왼쪽 아래로 끌어냄).
const xMidNear = (X(tz) + Xs(t0)) / 2;
parts.push(text(xMidNear, Y(lv(tz).top) - 12, `경사 ${d.slopeLabel}`, 'middle'));
const xr = X(0) + 58;
const lead = (x1: number, y1: number, x2: number, y2: number, s: string, anchor: 'start' | 'end') =>
line(x1, y1, x2, y2, `stroke="${MUTED}" stroke-width="0.7"`) + text(x2 + (anchor === 'start' ? 3 : -3), y2 + 4, s, anchor);
parts.push(text(xr, Y(0) + 4, `흉벽 상단 ${el(d.parapetTopEl)}`, 'start'));
parts.push(lead(gx + 2, gTop + 4, xr - 4, Y(0) + 26, `간격 ${d.parapetGapMm} · 아스팔트 충진재`, 'start'));
parts.push(lead(gx + 2, gMid + 8, xr - 4, Y(0) + 48, '스티로폼', 'start'));
parts.push(text(xr, Y(-bH) + 4, `브라켓 상면 ${el(d.parapetTopEl - bH)}`, 'start'));
const xl = X(wb) - 24;
parts.push(lead(xc - 6, Y(-bH) - 1, xl, Y(-bH) + 46, '고무판 150 × 150 × 15', 'end'));
parts.push(lead(X(wb) + 4, Y(-bH) + 1, xl, Y(-bH) + 64, '타르페이퍼 T = 15', 'end'));
parts.push(lead(xc, Y(-bH) + 0.25 * k, xl, Y(-bH) + 82, '다웰 D25 @400 · 강관 Φ60 L300', 'end'));
parts.push(text(X(0) + 6, Y(low) - 4, '흉벽 · 벽체', 'start', MUTED));
const W = xr + 200;
const H = yLen + 24;
return [
`<svg viewBox="0 0 ${W.toFixed(0)} ${H.toFixed(0)}" width="100%" role="img" aria-label="접속슬래브 단면 · 접합 상세 · ${d.form === 'exposed' ? '노출형' : '매립형'} · 경사 ${d.slopeLabel} · 길이 ${d.lengthMm} (중간 생략) · 두께 ${d.thicknessMm} · 흉벽 높이 ${Math.round(bH * 1000)}">`,
...parts,
'</svg>',
].join('');
}
+127
View File
@@ -0,0 +1,127 @@
/**
* 교대 벽체 배근 삽도 (2026-11-04, 시안 ③ 벽체 카드, 교대벽체철근.md §9): 교대축 직각 단면 (구간 1), 교대축 단면 (계단).
* 순수 문자열 함수. 철근은 배근 규칙의 경로(wall.ts paths)에서 그린다. 번호 풍선(data-pick)과 선택은 저판 삽도와 같다.
* 색: 연직 #0F5C66, 수평 #B4531F, 간격재 #6B3FA0, 계단 상면 W9 · W10 #C0501A (W10 파선).
*/
import type { AbutWallGeom, AbutWallRebar, WallBarPath, WallMark } from '../core/abut/rebar/wall';
import { balloon, CONC, MUTED, NO_PICK, opacity, text, WALL, type AbutRebarPick } from './abut-footing-rebar-svg';
export const ABUT_WALL_COLORS = { vertical: '#0F5C66', horizontal: '#B4531F', tie: '#6B3FA0', step: '#C0501A' } as const;
const C = ABUT_WALL_COLORS;
const COLOR: Record<WallMark, string> = {
W1: C.vertical, W3: C.vertical, W4: C.vertical, W11: C.vertical,
W2: C.horizontal, W5: C.horizontal, W6: C.horizontal, W8: C.horizontal,
W7: C.tie, W9: C.step, W10: C.step,
};
const DASH: Partial<Record<WallMark, string>> = { W4: '6 3', W7: '3 3', W10: '6 3' };
/** 교대축 직각 단면 at 구간 1 (IP3 끝): t 가로 (왼쪽 배면 → 오른쪽 전면), z 세로. */
export function abutWallSectionSvg(g: AbutWallGeom, r: AbutWallRebar, pick: AbutRebarPick = NO_PICK): string {
const seg = g.segments.find((s) => s.no === 1) ?? g.segments[0];
const uMid = (seg.from + seg.to) / 2;
const k = 0.1;
const x0 = 70;
const zTop = g.HF + seg.H;
const zMax = Math.max(zTop + r.ld.H19, g.bracketTop[0], g.bracketTop[1]) + 150;
const X = (t: number) => x0 + (t - g.xb) * k;
const Y = (z: number) => 30 + (zMax - z) * k;
const parts: string[] = [];
const pad = 600;
// 저판 (위 부분) · 벽체 · 교좌면 뒤 흉벽 · 브라켓 (점선).
parts.push(`<rect x="${X(g.xb - pad)}" y="${Y(g.HF)}" width="${(g.xf - g.xb + 2 * pad) * k}" height="${g.HF * k}" fill="${CONC}" stroke="${MUTED}"/>`);
parts.push(`<rect x="${X(g.xb)}" y="${Y(zTop)}" width="${(g.xf - g.xb) * k}" height="${seg.H * k}" fill="${CONC}" stroke="${MUTED}"/>`);
const bt = Math.min(g.bracketTop[0], g.bracketTop[1]);
parts.push(`<rect x="${X(g.xb)}" y="${Y(bt)}" width="${-g.xb * k}" height="${(bt - zTop) * k}" fill="${WALL}" stroke="${MUTED}" stroke-dasharray="4 3"/>`);
parts.push(text((X(g.xb) + X(0)) / 2, Y(bt) - 6, '흉벽 · 브라켓', 'middle', MUTED, 10));
parts.push(text((X(0) + X(g.xf)) / 2, Y(zTop) - 6, `교좌면 (구간 1)`, 'middle', MUTED, 10));
const line = (p: WallBarPath, w = 1.6) =>
`<polyline points="${p.pts.map(([, t, z]) => `${X(t).toFixed(1)},${Y(z).toFixed(1)}`).join(' ')}" fill="none" stroke="${COLOR[p.mark]}" stroke-width="${w}"${DASH[p.mark] ? ` stroke-dasharray="${DASH[p.mark]}"` : ''} opacity="${opacity(pick, [p.mark])}"/>`;
const dot = (p: WallBarPath) => {
const [, t, z] = p.pts[0];
return `<circle cx="${X(t).toFixed(1)}" cy="${Y(z).toFixed(1)}" r="2.4" fill="${COLOR[p.mark]}" opacity="${opacity(pick, [p.mark])}"/>`;
};
// Bars across the section (in the t-z plane): the first of each mark in 구간 1.
const first = (mark: WallMark) => r.paths.find((p) => p.mark === mark && p.pts[1][0] >= seg.from && p.pts[1][0] < seg.to);
for (const mark of ['W7', 'W1', 'W3', 'W4', 'W2'] as WallMark[]) {
if (mark === 'W7') {
for (const p of r.paths.filter((q) => q.mark === 'W7' && Math.abs(q.pts[1][0] - (first('W7')?.pts[1][0] ?? NaN)) < 1e-6)) parts.push(line(p, 1.1));
} else {
const p = first(mark);
if (p) parts.push(line(p, mark === 'W4' ? 1.3 : 1.8));
}
}
// Bars along the axis that pass the middle of 구간 1: dots.
const along = r.paths.filter((p) => ['W5', 'W6', 'W9', 'W10'].includes(p.mark) && Math.min(p.pts[0][0], p.pts[1][0]) <= uMid && Math.max(p.pts[0][0], p.pts[1][0]) >= uMid);
for (const p of along) parts.push(dot(p));
// 치수: 전면 수평 단 (구간 1), 상면 W9 간격.
const lv = r.levels.find((l) => l.no === seg.no);
if (lv) {
const xd = X(g.xf) + 14;
parts.push(`<line x1="${xd}" y1="${Y(g.HF)}" x2="${xd}" y2="${Y(zTop)}" stroke="${MUTED}"/>`);
parts.push(`<text x="${xd + 6}" y="${(Y(g.HF) + Y(zTop)) / 2}" font-size="10.5" fill="${MUTED}" font-family="JetBrains Mono, monospace" transform="rotate(-90 ${xd + 6} ${(Y(g.HF) + Y(zTop)) / 2})" text-anchor="middle">${r.settings.c} · ${lv.text}</text>`);
}
if (r.x.W9.length > 1) parts.push(text((X(r.x.W9[0]) + X(r.x.W9[r.x.W9.length - 1])) / 2, Y(zTop) + 22, `W9 · W10 ${r.x.W9.length - 1}@${Math.round(r.x.W9[0] - r.x.W9[1])}`, 'middle', C.step, 10));
// 번호 풍선.
const yMid = Y(g.HF + seg.H / 2);
parts.push(balloon(X(g.xf) + 60, yMid - 40, 'W1', C.vertical, pick), balloon(X(g.xf) + 60, yMid + 10, 'W5', C.horizontal, pick));
parts.push(balloon(X(g.xb) - 40, yMid - 60, 'W3', C.vertical, pick), balloon(X(g.xb) - 40, yMid - 10, 'W4', C.vertical, pick), balloon(X(g.xb) - 40, yMid + 40, 'W6', C.horizontal, pick));
parts.push(balloon((X(0) + X(g.xf)) / 2 - 20, Y(zTop) - 34, 'W2', C.horizontal, pick), balloon((X(0) + X(g.xf)) / 2 + 20, Y(zTop) - 34, 'W9', C.step, pick), balloon((X(0) + X(g.xf)) / 2 + 60, Y(zTop) - 34, 'W10', C.step, pick));
parts.push(balloon((X(g.xb) + X(g.xf)) / 2, Y(g.HF + 300), 'W7', C.tie, pick));
const W = X(g.xf + pad) + 40;
const H = Y(0) + 10;
return `<svg viewBox="0 0 ${W.toFixed(0)} ${H.toFixed(0)}" width="100%" style="max-width:${Math.round(W * 1.4)}px" role="img" aria-label="벽체 교대축 직각 단면 (구간 1)">${parts.join('')}</svg>`;
}
/** 교대축 단면 (계단): u 가로 (왼쪽 IP3 = 구간 1 → 오른쪽 IP1, 시안과 같은 좌우), z 세로. 전면 철근 (W1 · W5) · 계단 상면 · 끝면. */
export function abutWallElevationSvg(g: AbutWallGeom, r: AbutWallRebar, pick: AbutRebarPick = NO_PICK): string {
const k = 0.045;
const kz = 0.08;
const x0 = 70;
const zMax = g.HF + Math.max(...g.segments.map((s) => s.H)) + 300;
const X = (u: number) => x0 + (g.L - u) * k;
const Y = (z: number) => 56 + (zMax - z) * kz;
const parts: string[] = [];
parts.push(`<rect x="${X(g.L)}" y="${Y(g.HF)}" width="${g.L * k}" height="${g.HF * kz}" fill="${CONC}" stroke="${MUTED}"/>`);
let outline = `M${X(0).toFixed(1)},${Y(g.HF).toFixed(1)}`;
for (const s of g.segments) outline += ` L${X(s.from).toFixed(1)},${Y(g.HF + s.H).toFixed(1)} L${X(s.to).toFixed(1)},${Y(g.HF + s.H).toFixed(1)}`;
outline += ` L${X(g.L).toFixed(1)},${Y(g.HF).toFixed(1)} Z`;
parts.push(`<path d="${outline}" fill="${CONC}" stroke="${MUTED}"/>`);
for (const s of g.segments) parts.push(text((X(s.from) + X(s.to)) / 2, Y(g.HF + s.H) - 8, `구간 ${s.no} · ${Math.round(s.H)}`, 'middle', MUTED, 10));
const seg = (mark: WallMark, a: [number, number], b: [number, number], w: number) =>
`<line x1="${X(a[0]).toFixed(1)}" y1="${Y(a[1]).toFixed(1)}" x2="${X(b[0]).toFixed(1)}" y2="${Y(b[1]).toFixed(1)}" stroke="${COLOR[mark]}" stroke-width="${w}"${DASH[mark] ? ` stroke-dasharray="${DASH[mark]}"` : ''} opacity="${opacity(pick, [mark])}"/>`;
const front = r.x.W1;
const w11Front = Math.max(...r.paths.filter((p) => p.mark === 'W11').map((p) => p.pts[1][1]));
for (const p of r.paths) {
const [u0, t0, z0] = p.pts[0];
const [u1, , z1] = p.pts[p.pts.length - 1];
if (p.mark === 'W1') parts.push(seg('W1', [p.pts[1][0], p.pts[1][2]], [u1, z1], 0.7));
else if (p.mark === 'W5') parts.push(seg('W5', [u0, z0], [u1, z1], 0.8));
else if ((p.mark === 'W9' || p.mark === 'W10') && Math.abs(t0 - r.x.W9[0]) < 1e-6) {
for (let i = 1; i < p.pts.length; i++) parts.push(seg(p.mark, [p.pts[i - 1][0], p.pts[i - 1][2]], [p.pts[i][0], p.pts[i][2]], 2));
}
else if (p.mark === 'W11' && Math.abs(t0 - w11Front) < 1e-6) {
// ㄷ형: 연직 + 위 갈고리 (아래 갈고리는 저판 안).
parts.push(seg('W11', [p.pts[1][0], p.pts[1][2]], [p.pts[2][0], p.pts[2][2]], 2), seg('W11', [p.pts[2][0], p.pts[2][2]], [u1, z1], 2));
}
else if (p.mark === 'W8') parts.push(`<circle cx="${X(p.pts[1][0]).toFixed(1)}" cy="${Y(z0).toFixed(1)}" r="1.6" fill="${COLOR.W8}" opacity="${opacity(pick, ['W8'])}"/>`);
}
// 단 치수 (양끝).
const low = r.levels.find((l) => l.no === 1);
const high = r.levels.find((l) => l.no === g.segments.length);
const lvText = [low, high && g.segments.length > 1 ? high : undefined].filter((l) => l).map((l) => `구간 ${l!.no} 수평 ${l!.text}`).join(' ');
parts.push(text((X(0) + X(g.L)) / 2, Y(0) + 30, lvText, 'middle', MUTED, 10));
parts.push(text((X(0) + X(g.L)) / 2, Y(g.HF / 2), `저판 (연직철근 갈고리는 B6 · B8 위) · 전면 W1 x ${Math.round(front * 10) / 10}`, 'middle', MUTED, 10));
parts.push(text(X(g.L), Y(0) + 14, 'IP3 (구간 1)', 'start', MUTED, 10), text(X(0), Y(0) + 14, 'IP1', 'end', MUTED, 10));
const top = 20;
// W9 · W10 balloons over their own bars (the mark follows the shape: ㄷ / ㄴ).
const midOf = (mark: WallMark, fallback: number) => {
const p = r.paths.find((q) => q.mark === mark);
return p ? (p.pts[0][0] + p.pts[p.pts.length - 1][0]) / 2 : fallback;
};
parts.push(balloon(X(midOf('W9', g.L * 0.85)), top, 'W9', C.step, pick), balloon(X(midOf('W10', g.L * 0.5)), top, 'W10', C.step, pick));
parts.push(balloon(X(g.L * 0.3), top, 'W1', C.vertical, pick), balloon(X(g.L * 0.65), top, 'W5', C.horizontal, pick));
parts.push(balloon(X(0) + 34, Y(g.HF + 1200), 'W11', C.vertical, pick), balloon(X(0) + 34, Y(g.HF + 600), 'W8', C.horizontal, pick));
const W = X(0) + 60;
const H = Y(0) + 40;
return `<svg viewBox="0 0 ${W.toFixed(0)} ${H.toFixed(0)}" width="100%" style="max-width:${Math.round(W * 1.2)}px" role="img" aria-label="벽체 교대축 단면 (계단)">${parts.join('')}</svg>`;
}
+148
View File
@@ -0,0 +1,148 @@
/**
* 교대 벽체·흉벽 삽도 (2026-11-03, 시안 ② 벽체·흉벽 카드): 교대축 직각 단면(① 구간)과 정면·배면도. 순수 문자열 함수.
* 생성본(교대 모델·부재)의 값으로 그린다. 두 그림 모두 가로·세로 축척이 같다(시안 "축척 같음").
*/
export interface AbutWallDrawing {
/** 교대축 길이 L (m), IP1 → IP3. */
lengthM: number;
/** 교좌면 계단 구간 (s, m는 IP1에서), 구간마다 교좌면 EL. */
segments: { sFrom: number; sTo: number; seatEl: number }[];
/** 받침 (s, m), 받침 번호, 패드 상면 z (m), 교좌면 EL (m). */
bearings: { no: number; s: number; z: number; seatEl: number }[];
footingTopEl: number;
footingBottomEl: number;
/** 흉벽 상단 EL at s = 0 (IP1 끝) and s = L (IP3 끝). */
parapetTopStartEl: number;
parapetTopEndEl: number;
/** t (m, 전면 +): 벽체 전면, 흉벽 배면, 벽체 배면, 저판 앞굽 끝·뒷굽 끝. */
wallFrontM: number;
parapetBackM: number;
wallBackM: number;
footingFrontM: number;
footingBackM: number;
bracketHeightM: number;
/** 수축줄눈 위치 s (m from IP1), from core/abut/joints.ts (받침 사이 중간). */
shrinkJointsS: number[];
/** 시안 치수 줄 "브라켓 폭 + 흉벽 두께 + 교좌면 폭 = 벽체 두께" (mm). */
bracketWidthMm: number;
parapetThicknessMm: number;
seatWidthMm: number;
}
const INK = '#18212b';
const MUTED = '#55606e';
const CONC = '#d9c9a8';
const CONC_DARK = '#c7b48d';
const FOOT = '#b9c3ce';
const t3 = (v: number) => v.toFixed(3);
/** EL 표기 (2026-11-04): "EL.41.995". */
const el = (v: number) => `EL.${v.toFixed(3)}`;
const label = (x: number, y: number, s: string, anchor = 'middle', fill = INK) =>
`<text x="${x.toFixed(1)}" y="${y.toFixed(1)}" text-anchor="${anchor}" font-size="11" fill="${fill}" font-family="JetBrains Mono, monospace">${s}</text>`;
const poly = (pts: [number, number][], style: string) => `<polygon points="${pts.map(([x, y]) => `${x.toFixed(1)},${y.toFixed(1)}`).join(' ')}" ${style}/>`;
/** 흉벽 상단 EL at s (linear between the two ends). */
function parapetTopAt(d: AbutWallDrawing, s: number): number {
return d.parapetTopStartEl + ((d.parapetTopEndEl - d.parapetTopStartEl) * s) / d.lengthM;
}
/**
* 교대축 직각 단면 (① 구간 = 받침 1번이 놓인 구간): 저판, 벽체(기초 상면 ~ 교좌면), 흉벽 배면 단차부(브라켓 상면까지),
* 흉벽(흉벽 상단까지), 받침 블럭. 왼쪽이 배면, 오른쪽이 전면.
*/
export function abutWallSectionSvg(d: AbutWallDrawing): string {
const b1 = d.bearings.find((b) => b.no === 1) ?? d.bearings[0];
const seg = d.segments.find((g) => b1.s >= g.sFrom && b1.s <= g.sTo) ?? d.segments[0];
const sAt = b1.s;
const top = parapetTopAt(d, sAt);
const bracket = top - d.bracketHeightM;
const k = 46; // px per m, both directions
const tMin = d.footingBackM - 0.6;
const elMax = top + 0.7;
const X = (t: number) => (t - tMin) * k;
const Y = (el: number) => (elMax - el) * k + 10;
const W = (d.footingFrontM + 1.4 - tMin) * k;
const H = Y(d.footingBottomEl) + 40;
const rect = (t0: number, t1: number, e0: number, e1: number, style: string) =>
poly([[X(t0), Y(e0)], [X(t1), Y(e0)], [X(t1), Y(e1)], [X(t0), Y(e1)]], style);
const halfBlock = 0.6;
const blockT = (d.wallFrontM + 0) / 2; // drawn at the middle of the 교좌면 width
return [
`<svg viewBox="0 0 ${W.toFixed(0)} ${H.toFixed(0)}" width="100%" role="img" aria-label="교대축 직각 단면 (① 구간): 흉벽 상단 ${t3(top)}, 브라켓 상면 ${t3(bracket)}, 교좌면 ${t3(seg.seatEl)}, 저판 상면 ${t3(d.footingTopEl)}, 기초 하단 ${t3(d.footingBottomEl)}">`,
rect(d.footingBackM, d.footingFrontM, d.footingBottomEl, d.footingTopEl, `fill="${FOOT}" stroke="${MUTED}"`),
rect(d.wallBackM, d.wallFrontM, d.footingTopEl, seg.seatEl, `fill="${CONC}" stroke="${MUTED}"`),
rect(d.wallBackM, d.parapetBackM, seg.seatEl, bracket, `fill="${CONC_DARK}" stroke="${MUTED}"`),
rect(d.parapetBackM, 0, seg.seatEl, top, `fill="${CONC_DARK}" stroke="${MUTED}"`),
rect(blockT - halfBlock, blockT + halfBlock, seg.seatEl, b1.z, `fill="#9aa7b5" stroke="${INK}"`),
`<line x1="${X(0).toFixed(1)}" y1="4" x2="${X(0).toFixed(1)}" y2="${(H - 30).toFixed(1)}" stroke="#c0392b" stroke-dasharray="6 3"/>`,
label(X(0), 14, '교대축', 'middle', '#c0392b'),
label((X(d.wallBackM) + X(d.wallFrontM)) / 2, Y(top) - 18, `${d.bracketWidthMm} + ${d.parapetThicknessMm} + ${d.seatWidthMm} = ${d.bracketWidthMm + d.parapetThicknessMm + d.seatWidthMm}`),
label(X(0) + 6, Y(top) - 4, `흉벽 상단 ${el(top)}`, 'start'),
label(X(d.wallBackM) - 6, Y(bracket) + 4, `브라켓 상면 ${el(bracket)}`, 'end'),
label(X(d.wallFrontM) + 6, Y(seg.seatEl) + 4, `교좌면 ${el(seg.seatEl)}`, 'start'),
label(X(d.wallFrontM) + 6, Y((seg.seatEl + d.footingTopEl) / 2) + 4, `벽체 ${(seg.seatEl - d.footingTopEl).toFixed(3)}`, 'start'),
label(X(d.footingFrontM) + 6, Y(d.footingTopEl) + 4, `상면 ${el(d.footingTopEl)}`, 'start'),
label(X(d.footingFrontM) + 6, Y(d.footingBottomEl) + 4, `기초 하단 ${el(d.footingBottomEl)}`, 'start'),
label(X(d.footingBackM), H - 8, '← 배면', 'start', MUTED),
label(X(d.footingFrontM), H - 8, '전면 →', 'end', MUTED),
'</svg>',
].join('');
}
/**
* 정면·배면도: 교좌면 계단(구간별 교좌면 EL), 흉벽 상단 경사, 받침 블럭, 수축줄눈, 저판, 받침 ① 기준선과 벽체 높이 H_w. 왼쪽 IP3(① 쪽), 오른쪽 IP1.
*/
export function abutWallElevationSvg(d: AbutWallDrawing): string {
const k = 46;
const top = Math.max(d.parapetTopStartEl, d.parapetTopEndEl);
const elMax = top + 0.6;
const pad = 40;
const X = (s: number) => pad + (d.lengthM - s) * k; // IP3 (s = L) on the left
const Y = (el: number) => (elMax - el) * k + 10;
const W = d.lengthM * k + 2 * pad;
const H = Y(d.footingBottomEl) + 40;
const parts: string[] = [];
parts.push(poly([[X(d.lengthM), Y(d.footingTopEl)], [X(0), Y(d.footingTopEl)], [X(0), Y(d.footingBottomEl)], [X(d.lengthM), Y(d.footingBottomEl)]], `fill="${FOOT}" stroke="${MUTED}"`));
// 흉벽 (behind) from the 교좌면 steps up to the sloped top line.
parts.push(poly([[X(d.lengthM), Y(d.footingTopEl)], [X(0), Y(d.footingTopEl)], [X(0), Y(d.parapetTopStartEl)], [X(d.lengthM), Y(d.parapetTopEndEl)]], `fill="${CONC_DARK}" stroke="${MUTED}"`));
// 벽체 front: one block per 교좌면 step.
for (const g of d.segments) {
parts.push(poly([[X(g.sTo), Y(d.footingTopEl)], [X(g.sFrom), Y(d.footingTopEl)], [X(g.sFrom), Y(g.seatEl)], [X(g.sTo), Y(g.seatEl)]], `fill="${CONC}" stroke="${MUTED}"`));
}
for (const b of d.bearings) {
parts.push(poly([[X(b.s + 0.6), Y(b.seatEl)], [X(b.s - 0.6), Y(b.seatEl)], [X(b.s - 0.6), Y(b.z)], [X(b.s + 0.6), Y(b.z)]], `fill="#9aa7b5" stroke="${INK}"`));
parts.push(label(X(b.s), Y(b.z) - 6, ['①', '②', '③', '④', '⑤', '⑥', '⑦', '⑧'][b.no - 1] ?? String(b.no)));
}
d.shrinkJointsS.forEach((s, i) => {
const j = i + 1;
parts.push(`<line x1="${X(s).toFixed(1)}" y1="${Y(parapetTopAt(d, s)).toFixed(1)}" x2="${X(s).toFixed(1)}" y2="${Y(d.footingTopEl).toFixed(1)}" stroke="${INK}" stroke-dasharray="5 3"/>`);
parts.push(label(X(s) + 4, Y(d.footingTopEl) - 8, `수축줄눈 ${j}`, 'start'));
});
// 벽체 높이 H_w (사용자 도면 2026-11-04): 받침 ① 연직 기준선, 받침 ① 교좌면 ~ 흉벽 상단.
const b1 = d.bearings.find((b) => b.no === 1) ?? d.bearings[0];
if (b1) {
const topB1 = parapetTopAt(d, b1.s);
const xb = X(b1.s);
parts.push(`<line x1="${xb.toFixed(1)}" y1="${(Y(topB1) - 14).toFixed(1)}" x2="${xb.toFixed(1)}" y2="${(Y(d.footingTopEl) + 6).toFixed(1)}" stroke="#c0392b" stroke-dasharray="10 3 2 3"/>`);
const xd = xb - 22;
parts.push(`<line x1="${xd.toFixed(1)}" y1="${Y(topB1).toFixed(1)}" x2="${xd.toFixed(1)}" y2="${Y(b1.seatEl).toFixed(1)}" stroke="#c0392b" marker-start="url(#hw-a)" marker-end="url(#hw-a)"/>`);
parts.push(`<text x="${(xd - 4).toFixed(1)}" y="${((Y(topB1) + Y(b1.seatEl)) / 2).toFixed(1)}" font-size="11" fill="#c0392b" text-anchor="end">H_w ${Math.round((topB1 - b1.seatEl) * 1000)}</text>`);
}
const first = d.segments.find((g) => d.lengthM >= g.sFrom && d.lengthM <= g.sTo) ?? d.segments[d.segments.length - 1];
const last = d.segments.find((g) => g.sFrom <= 0 && g.sTo >= 0) ?? d.segments[0];
parts.push(label(X(d.lengthM), Y(d.parapetTopEndEl) - 6, el(d.parapetTopEndEl), 'start'));
parts.push(label(X(0), Y(d.parapetTopStartEl) - 6, el(d.parapetTopStartEl), 'end'));
parts.push(label(X(d.lengthM) - 4, Y(first.seatEl) + 4, el(first.seatEl), 'end'));
parts.push(label(X(0) + 4, Y(last.seatEl) + 4, el(last.seatEl), 'start'));
parts.push(label(X(d.lengthM) - 4, Y(d.footingBottomEl) + 4, el(d.footingBottomEl), 'end'));
parts.push(label(X(d.lengthM), H - 8, 'IP3', 'middle', MUTED));
parts.push(label(X(0), H - 8, 'IP1', 'middle', MUTED));
parts.push(label((X(0) + X(d.lengthM)) / 2, H - 8, `${d.lengthM.toFixed(3)} m`));
return [
`<svg viewBox="0 0 ${W.toFixed(0)} ${H.toFixed(0)}" width="100%" role="img" aria-label="정면·배면도: 흉벽 상단 ${t3(d.parapetTopEndEl)} (IP3) → ${t3(d.parapetTopStartEl)} (IP1), 교좌면 계단 ${d.segments.length}구간, 수축줄눈 ${d.shrinkJointsS.length}">`,
'<defs><marker id="hw-a" viewBox="0 0 10 10" refX="5" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse"><path d="M0,0 L10,5 L0,10 z" fill="#c0392b"/></marker></defs>',
...parts,
'</svg>',
].join('');
}
+122
View File
@@ -0,0 +1,122 @@
/**
* 교대 날개벽 배근 삽도 (2026-11-04, 시안 ③ 날개벽 카드, 교대날개벽철근.md §10): 전면도 · 배면도 (날개벽 방향 거리 d × 높이),
* 헌치 · 모서리 평면. 순수 문자열 함수, 철근은 배근 규칙의 경로(wing.ts paths, 교대 s · t · z)에서 그린다.
* 색: 연직 #0F5C66, 수평 #B4531F (등단면 실선 · 변화단면1 파선 · 변화단면2 점선), 헌치 · 모서리 #C0501A, WR6 · WR20 #6B3FA0.
*/
import type { AbutWingGeom, AbutWingRebar, WingBarPath, WingMark } from '../core/abut/rebar/wing';
import { balloon, CONC, MUTED, NO_PICK, opacity, text, WALL, type AbutRebarPick } from './abut-footing-rebar-svg';
export const ABUT_WING_COLORS = { vertical: '#0F5C66', horizontal: '#B4531F', haunch: '#C0501A', other: '#6B3FA0' } as const;
const C = ABUT_WING_COLORS;
const COLOR = (m: WingMark): string =>
['WR1', 'WR2', 'WR11', 'WR12'].includes(m) ? C.vertical : ['WR3', 'WR4', 'WR5', 'WR13', 'WR14', 'WR15'].includes(m) ? C.horizontal : m === 'WR6' || m === 'WR20' ? C.other : C.haunch;
const DASH: Partial<Record<WingMark, string>> = { WR4: '6 3', WR14: '6 3', WR5: '2 2', WR15: '2 2', WR10: '6 3', WR20: '2 2' };
/** Wing frame of a plan point: d along the wing from the axis line, depth from the front face toward the back. */
function frame(g: AbutWingGeom) {
const [ws, wt] = g.w;
const iw = g.inward[0] * ws + g.inward[1] * wt;
const nv = [g.inward[0] - iw * ws, g.inward[1] - iw * wt];
const T = Math.hypot(nv[0], nv[1]);
const n = [nv[0] / T, nv[1] / T];
return {
T,
d: (s: number, t: number) => (s - g.p0[0]) * ws + (t - g.p0[1]) * wt,
depth: (s: number, t: number) => (s - g.p0[0]) * n[0] + (t - g.p0[1]) * n[1],
};
}
/** 전면도 (face 'front', 바깥에서 봄) 또는 배면도 ('back', 흙 쪽에서 봄): 가로 d (왼쪽 교대 → 오른쪽 끝), 세로 높이. */
export function abutWingFaceSvg(g: AbutWingGeom, r: AbutWingRebar, face: 'front' | 'back', pick: AbutRebarPick = NO_PICK): string {
const f = frame(g);
const k = 0.075;
// 왼쪽 끝 = 벽체 전면 (변화단면2 수평철근이 벽체를 지나 전면 − c까지 감).
const dMin = -(g.wallFrontT + 150) / Math.abs(g.w[1]);
const zTopMax = g.topStart + g.slope * g.L;
const X = (d: number) => 60 + (d - dMin) * k;
const Y = (z: number) => 50 + (zTopMax + 100 - z) * k;
const parts: string[] = [];
const general = g.form === 'general' && g.heelD < g.L;
const outline = [
[g.wallBackD, g.footTop], [general ? g.heelD : g.L, g.footTop], [g.L, general ? g.bottomEnd : g.footTop], [g.L, zTopMax], [g.wallBackD, g.topStart + g.slope * g.wallBackD],
];
// 교대 단면 (벽체 · 흉벽, 날개벽 시작 쪽) · 저판.
parts.push(`<rect x="${X(dMin)}" y="${Y(g.topStart)}" width="${(g.wallBackD - dMin) * k}" height="${(g.topStart - g.footTop) * k}" fill="${WALL}" stroke="${MUTED}" stroke-dasharray="4 3"/>`);
parts.push(`<rect x="${X(dMin)}" y="${Y(g.footTop)}" width="${(g.heelD - dMin) * k}" height="${(g.footTop - g.zB68 + 200) * k}" fill="${CONC}" stroke="${MUTED}"/>`);
parts.push(`<polygon points="${outline.map(([d, z]) => `${X(d).toFixed(1)},${Y(z).toFixed(1)}`).join(' ')}" fill="${CONC}" stroke="${MUTED}"/>`);
parts.push(text(X((dMin + g.wallBackD) / 2), Y(g.topStart) - 6, '교대 단면', 'middle', MUTED, 10));
parts.push(`<line x1="${X(g.heelD)}" y1="${Y(g.footTop) + 4}" x2="${X(g.heelD)}" y2="${Y(g.footTop) + 40}" stroke="${MUTED}" stroke-dasharray="3 3"/>`, text(X(g.heelD), Y(g.footTop) + 52, '뒷굽 끝', 'middle', MUTED, 10));
const onFace = (p: WingBarPath) => {
const dep = p.pts.map(([s, t]) => f.depth(s, t));
const avg = dep.reduce((a, b) => a + b, 0) / dep.length;
return face === 'front' ? avg < f.T / 2 : avg >= f.T / 2;
};
const show: WingMark[] = face === 'front' ? ['WR11', 'WR12', 'WR13', 'WR14', 'WR15', 'WR6'] : ['WR1', 'WR2', 'WR3', 'WR4', 'WR5', 'WR6', 'WR9'];
for (const p of r.paths) {
if (!show.includes(p.mark) || !onFace(p)) continue;
const pts = p.pts.map(([s, t, z]) => `${X(f.d(s, t)).toFixed(1)},${Y(z).toFixed(1)}`).join(' ');
const w = ['WR1', 'WR2', 'WR11', 'WR12'].includes(p.mark) ? 0.8 : 1;
parts.push(`<polyline points="${pts}" fill="none" stroke="${COLOR(p.mark)}" stroke-width="${w}"${DASH[p.mark] ? ` stroke-dasharray="${DASH[p.mark]}"` : ''} opacity="${opacity(pick, [p.mark])}"/>`);
}
const v = r.vertical[face];
parts.push(text(X(g.L / 2), Y(g.footTop) + 70, `${face === 'front' ? '전면' : '배면'} 연직 (끝 → 교대) ${v.text}`, 'middle', MUTED, 10));
parts.push(text(X(g.L) + 6, Y(zTopMax - 400), `${r.levels[face].end}단`, 'start', MUTED, 10), text(X(g.wallBackD) + 6, Y(g.topStart - 400), `${r.levels[face].start}단`, 'start', MUTED, 10));
const vm = face === 'front' ? ['WR12', 'WR11'] : ['WR2', 'WR1'];
const hm = face === 'front' ? ['WR13', 'WR14', 'WR15'] : ['WR3', 'WR4', 'WR5'];
const top = 22;
parts.push(balloon(X(g.heelD - 300), top, vm[0], C.vertical, pick), balloon(X(g.heelD + 1500), top, vm[1], C.vertical, pick));
parts.push(balloon(X(g.L) + 30, Y(zTopMax - 200), hm[0], C.horizontal, pick), balloon(X(g.L) + 30, Y(g.seat + 400), hm[1], C.horizontal, pick), balloon(X(g.L) + 30, Y(g.footTop + 600), hm[2], C.horizontal, pick));
if (g.form === 'general') parts.push(balloon(X(g.L - 800), Y(g.bottomEnd) + 40, 'WR6', C.other, pick));
if (face === 'back' && g.haunch > 0) parts.push(balloon(X(g.wallBackD) - 4, top, 'WR9', C.haunch, pick));
const W = X(g.L) + 70;
const H = Y(g.zB68) + 80;
return `<svg viewBox="0 0 ${W.toFixed(0)} ${H.toFixed(0)}" width="100%" style="max-width:${Math.round(W * 1.2)}px" role="img" aria-label="날개벽 ${face === 'front' ? '전면도' : '배면도'}">${parts.join('')}</svg>`;
}
/** 헌치 · 모서리 평면 (교대 s · t, 날개벽이 교대와 만나는 모서리 둘레): 헌치 WR7-1 · 7-2 · 7-3과 WR10의 띠별 한 단, WR9 점. */
export function abutWingCornerSvg(g: AbutWingGeom, r: AbutWingRebar, pick: AbutRebarPick = NO_PICK): string {
const f = frame(g);
// 보는 범위: 모서리 둘레 ± 3 m.
const cornerT = g.wallBackT;
const sC = g.p0[0] + g.inward[0];
const span = 3200;
const sMin = sC - span;
const sMax = sC + span;
const tMin = cornerT - span;
const tMax = Math.min(g.wallFrontT, cornerT + span);
const k = 0.12;
const flip = g.inward[0] < 0 ? -1 : 1; // 날개벽이 왼쪽에 오도록
const X = (s: number) => 40 + (flip > 0 ? s - sMin : sMax - s) * k;
const Y = (t: number) => 30 + (tMax - t) * k;
const parts: string[] = [];
// 교대 (벽체 · 흉벽 배면 위쪽이 교대) · 날개벽 띠.
parts.push(`<rect x="${Math.min(X(sMin), X(sMax))}" y="${Y(tMax)}" width="${(sMax - sMin) * k}" height="${(tMax - g.wallBackT) * k}" fill="${WALL}" stroke="${MUTED}"/>`);
parts.push(`<line x1="${Math.min(X(sMin), X(sMax))}" y1="${Y(g.parapetBackT)}" x2="${Math.max(X(sMin), X(sMax))}" y2="${Y(g.parapetBackT)}" stroke="${MUTED}" stroke-dasharray="4 3"/>`);
const P = (d: number, dep: number) => {
const [ws, wt] = g.w;
const iw = g.inward[0] * ws + g.inward[1] * wt;
const n = [(g.inward[0] - iw * ws) / f.T, (g.inward[1] - iw * wt) / f.T];
return [g.p0[0] + d * ws + dep * n[0], g.p0[1] + d * wt + dep * n[1]];
};
const dEnd = Math.min(g.L, (cornerT - tMin) / Math.abs(g.w[1]));
const wing = [P(0, 0), P(dEnd, 0), P(dEnd, f.T), P(0, f.T)];
parts.push(`<polygon points="${wing.map(([s, t]) => `${X(s).toFixed(1)},${Y(t).toFixed(1)}`).join(' ')}" fill="${CONC}" stroke="${MUTED}"/>`);
parts.push(text(X(P(dEnd * 0.7, f.T / 2)[0]), Y(P(dEnd * 0.7, f.T / 2)[1]), '날개벽', 'middle', MUTED, 10));
parts.push(text(Math.min(X(sMin), X(sMax)) + 6, Y(g.wallBackT) - 6, '벽체 배면 (흉벽 배면 점선)', 'start', MUTED, 10));
// 한 단씩: 각 띠의 첫 단, WR10 첫 단 · 흉벽 구간 첫 단.
const firstOf = (mark: WingMark) => r.paths.find((p) => p.mark === mark);
const wr10Up = r.paths.find((p) => p.mark === 'WR10' && p.pts[0][2] > g.bracketTop);
for (const p of [firstOf('WR7-1'), firstOf('WR7-2'), firstOf('WR7-3'), firstOf('WR10'), wr10Up, firstOf('WR3'), firstOf('WR13')]) {
if (!p) continue;
const pts = p.pts.map(([s, t]) => `${X(s).toFixed(1)},${Y(t).toFixed(1)}`).join(' ');
parts.push(`<polyline points="${pts}" fill="none" stroke="${COLOR(p.mark)}" stroke-width="1.6"${DASH[p.mark] ? ` stroke-dasharray="${DASH[p.mark]}"` : ''} opacity="${opacity(pick, [p.mark])}"/>`);
}
for (const p of r.paths.filter((q) => q.mark === 'WR9')) parts.push(`<circle cx="${X(p.pts[1][0]).toFixed(1)}" cy="${Y(p.pts[1][1]).toFixed(1)}" r="3" fill="${C.haunch}" opacity="${opacity(pick, ['WR9'])}"/>`);
const wr7 = firstOf('WR7-3');
const at = wr7 ? [X(wr7.pts[2][0]), Y(wr7.pts[2][1])] : [X(sC), Y(cornerT)];
parts.push(balloon(at[0] + 40, at[1] + 40, 'WR7', C.haunch, pick), balloon(at[0] + 80, at[1] + 40, 'WR10', C.haunch, pick), balloon(at[0] + 120, at[1] + 40, 'WR9', C.haunch, pick));
parts.push(text(40, Y(tMin) + 18, `헌치 ${Math.round(g.haunch)} · WR7 띠: 위 ${r.haunchLevels['WR7-1']} · 가운데 ${r.haunchLevels['WR7-2']} · 아래 ${r.haunchLevels['WR7-3']}단 · 날개벽 두께 ${Math.round(f.T)}, 수평철근 사이 WR20`, 'start', MUTED, 10));
const W = (sMax - sMin) * k + 80;
const H = Y(tMin) + 30;
return `<svg viewBox="0 0 ${W.toFixed(0)} ${H.toFixed(0)}" width="100%" style="max-width:${Math.round(W * 1.3)}px" role="img" aria-label="날개벽 헌치 · 모서리 평면">${parts.join('')}</svg>`;
}
+217
View File
@@ -0,0 +1,217 @@
/**
* 교대 날개벽 삽도 (2026-11-04, 시안 ② 날개벽 카드): 평면(좌우 날개벽과 접속슬래브), 측면(날개벽 축 방향), 날개벽–흉벽 모서리 평면.
* 순수 문자열 함수. 평면·측면은 생성본 값으로 그리고 가로·세로 축척이 같다. 모서리 상세는 치수를 나타내는 모식도다.
*/
import type { AbutPlanOutline } from './abut-scene';
const INK = '#18212b';
const MUTED = '#55606e';
const WING = '#d6bb8c';
const WALL = '#b7c7d3';
const SLAB = '#f3eee4';
const text = (x: number, y: number, s: string, anchor = 'start', fill = INK) =>
`<text x="${x.toFixed(1)}" y="${y.toFixed(1)}" text-anchor="${anchor}" font-size="11" fill="${fill}" font-family="JetBrains Mono, monospace">${s}</text>`;
const poly = (pts: [number, number][], style: string) => `<polygon points="${pts.map(([x, y]) => `${x.toFixed(1)},${y.toFixed(1)}`).join(' ')}" ${style}/>`;
export interface AbutWingPlanDrawing {
lengthM: number;
outline: AbutPlanOutline;
/** 좌 = ① 쪽 끝 (sEnd), 우 = 반대 끝. 이름에 형식을 붙여 표시한다. */
leftLabel: string;
rightLabel: string;
leftSEnd: number;
slabWidthM: number;
}
/** 평면: 벽체(skew), 좌우 날개벽, 그 사이 접속슬래브. 왼쪽이 ① 쪽(좌), 위가 전면. */
export function abutWingPlanSvg(d: AbutWingPlanDrawing): string {
const pts = [...d.outline.wall, ...d.outline.slab, ...d.outline.wings.flatMap((w) => w.poly)];
// ① 쪽(좌)을 왼쪽에 둔다: 좌가 s = L이면 s를 뒤집는다.
const flip = d.leftSEnd !== 0;
const xs = pts.map(([s]) => (flip ? -s : s));
const ts = pts.map(([, t]) => t);
const k = 30;
const xMin = Math.min(...xs) - 0.6;
const tMax = Math.max(...ts) + 0.4;
const X = (s: number) => 20 + ((flip ? -s : s) - xMin) * k;
const Y = (t: number) => 18 + (tMax - t) * k;
const W = 40 + (Math.max(...xs) - xMin + 0.6) * k;
const H = Y(Math.min(...ts) - 0.4) + 30;
const map = (p: [number, number][]) => p.map(([s, t]) => [X(s), Y(t)] as [number, number]);
const parts = [
poly(map(d.outline.slab), `fill="${SLAB}" stroke="#b49a6b" stroke-dasharray="5 3"`),
poly(map(d.outline.wall), `fill="${WALL}" stroke="${MUTED}"`),
...d.outline.wings.map((w) => poly(map(w.poly), `fill="${WING}" stroke="#9c8763"`)),
];
const slabC = d.outline.slab.reduce((a, [s, t]) => [a[0] + s / d.outline.slab.length, a[1] + t / d.outline.slab.length], [0, 0]);
parts.push(text(X(slabC[0]), Y(slabC[1]), `접속슬래브 폭 ${d.slabWidthM.toFixed(3)} m`, 'middle'));
for (const w of d.outline.wings) {
const far = w.poly[1];
parts.push(text(X(far[0]), Y(far[1]) + 16, w.side === 'left' ? d.leftLabel : d.rightLabel, 'middle'));
}
parts.push(text(X(d.lengthM / 2), 12, '흉벽 · 벽체 (위 = 전면)', 'middle', MUTED));
return [
`<svg viewBox="0 0 ${W.toFixed(0)} ${H.toFixed(0)}" width="100%" role="img" aria-label="평면: ${d.leftLabel}, ${d.rightLabel}, 접속슬래브 폭 ${d.slabWidthM.toFixed(3)} m">`,
...parts,
'</svg>',
].join('');
}
export interface AbutWingSideDrawing {
name: string;
form: 'general' | 'short';
lengthM: number;
endHeightM: number;
heelDistanceM: number;
topLine: { dM: number; el: number }[];
bottomLine: { dM: number; el: number }[];
bands: { name: string; zTopEl: number; zBottomEl: number }[];
seatEl: number;
footingTopEl: number;
/** 흉벽 상단 EL at this wing's end, and the 브라켓 높이 (m): 브라켓 상면 = 흉벽 상단 − 브라켓 높이. */
parapetTopEl: number;
bracketHeightM: number;
/** 단면 위치 t (m, 전면 +): 벽체 배면 · 흉벽 배면 · 벽체 전면, 저판 앞굽 끝 · 뒷굽 끝, 저판 두께. */
wallBackM: number;
parapetBackM: number;
wallFrontM: number;
footingFrontM: number;
footingBackM: number;
footingThicknessM: number;
}
/** EL 표기 (2026-11-04): "EL.41.995". */
const el = (v: number) => `EL.${v.toFixed(3)}`;
/**
* 측면 (날개벽 축 방향으로 잘라 본 모습): 벽체 쪽이 오른쪽, 날개벽 끝이 왼쪽.
* 벽체 · 흉벽 · 브라켓 단차와 저판을 단면으로 함께 그린다. 가로 위치는 t를 날개벽 거리로 바꾼 값(dM = −t / k, k = 뒷굽 / 뒷굽 거리).
*/
/** 치수선 (2026-11-04): 점 a, b에서 치수선 위치 at(가로면 y, 세로면 x)까지 보조선, 치수선, 양 끝 사선 틱, 치수. after = 치수를 선의 아래 · 오른쪽에. */
export function dimLine(a: [number, number], b: [number, number], at: number, dir: 'h' | 'v', label: string, after = false): string {
const line = (x1: number, y1: number, x2: number, y2: number) =>
`<line x1="${x1.toFixed(1)}" y1="${y1.toFixed(1)}" x2="${x2.toFixed(1)}" y2="${y2.toFixed(1)}" stroke="${INK}" stroke-width="0.8"/>`;
const tick = (x: number, y: number) => line(x - 3, y + 3, x + 3, y - 3);
if (dir === 'h') {
const ext = (x: number, y: number) => line(x, y + Math.sign(at - y) * 3, x, at + Math.sign(at - y) * 3);
const lab = text((a[0] + b[0]) / 2, after ? at + 13 : at - 4, label, 'middle');
return [ext(a[0], a[1]), ext(b[0], b[1]), line(a[0], at, b[0], at), tick(a[0], at), tick(b[0], at), lab].join('');
}
const ext = (x: number, y: number) => line(x + Math.sign(at - x) * 3, y, at + Math.sign(at - x) * 3, y);
const lab = after ? text(at + 4, (a[1] + b[1]) / 2 + 4, label, 'start') : text(at - 4, (a[1] + b[1]) / 2 + 4, label, 'end');
return [ext(a[0], a[1]), ext(b[0], b[1]), line(at, a[1], at, b[1]), tick(at, a[1]), tick(at, b[1]), lab].join('');
}
export function abutWingSideSvg(d: AbutWingSideDrawing): string {
const k = 40;
const kt = Math.abs(d.footingBackM) / d.heelDistanceM; // |t| per m along the wing
const dmOf = (tM: number) => -tM / kt;
const bracketTop = d.parapetTopEl - d.bracketHeightM;
const footingBottom = d.footingTopEl - d.footingThicknessM;
const els = [...d.topLine.map((p) => p.el), ...d.bottomLine.map((p) => p.el), d.parapetTopEl, footingBottom];
const elMax = Math.max(...els) + 0.8;
const elMin = Math.min(...els) - 0.5;
const dmRight = dmOf(d.footingFrontM); // 저판 앞굽 끝 (rightmost)
const X = (dM: number) => 40 + (d.lengthM - dM) * k; // wall (dM 0) toward the right
const Y = (y: number) => 14 + (elMax - y) * k;
const W = X(dmRight) + 110;
const H = Y(elMin) + 34;
const rect = (dm0: number, dm1: number, e0: number, e1: number, style: string) =>
poly([[X(dm0), Y(e0)], [X(dm1), Y(e0)], [X(dm1), Y(e1)], [X(dm0), Y(e1)]], style);
const outline: [number, number][] = [
...d.topLine.map((p) => [X(p.dM), Y(p.el)] as [number, number]),
...[...d.bottomLine].reverse().map((p) => [X(p.dM), Y(p.el)] as [number, number]),
];
const wb = dmOf(d.wallBackM);
const pb = dmOf(d.parapetBackM);
const wf = dmOf(d.wallFrontM);
const parts = [
poly(outline, `fill="${WING}" stroke="#9c8763"`),
// 저판 (뒷굽 끝 ~ 앞굽 끝), 벽체 (기초 상면 ~ 교좌면), 브라켓 단차 (벽체 배면 ~ 흉벽 배면, 브라켓 상면까지), 흉벽 (흉벽 상단까지).
rect(dmOf(d.footingBackM), dmRight, footingBottom, d.footingTopEl, `fill="#c9d1da" stroke="${MUTED}"`),
rect(wb, wf, d.footingTopEl, d.seatEl, `fill="${WALL}" stroke="${MUTED}"`),
rect(wb, pb, d.seatEl, bracketTop, `fill="#9fb1c0" stroke="${MUTED}"`),
rect(pb, 0, d.seatEl, d.parapetTopEl, `fill="#9fb1c0" stroke="${MUTED}"`),
];
// 수평 단 경계 (등단면 / 변화단면) as dashed lines across the wing.
for (const b of d.bands.slice(0, -1)) {
parts.push(`<line x1="${X(d.lengthM).toFixed(1)}" y1="${Y(b.zBottomEl).toFixed(1)}" x2="${X(wb).toFixed(1)}" y2="${Y(b.zBottomEl).toFixed(1)}" stroke="${MUTED}" stroke-dasharray="4 3"/>`);
}
for (const b of d.bands) {
const mid = (b.zTopEl + b.zBottomEl) / 2;
parts.push(text(X(d.lengthM * 0.55), Y(mid) + 4, b.name, 'middle', MUTED));
}
// 치수선: 길이 (날개벽 끝 ~ 교좌면 끝), 끝단 높이 (일반형), 뒷굽 끝 (교좌면 끝 ~ 뒷굽 끝).
const endTop = d.topLine[d.topLine.length - 1].el;
const endBottom = d.bottomLine[d.bottomLine.length - 1].el;
parts.push(dimLine([X(d.lengthM), Y(endTop)], [X(0), Y(d.parapetTopEl)], Y(elMax) + 12, 'h', `${Math.round(d.lengthM * 1000)} (교좌면 끝에서, 날개벽 축 방향)`));
if (d.form === 'general') parts.push(dimLine([X(d.lengthM), Y(endTop)], [X(d.lengthM), Y(endBottom)], X(d.lengthM) - 12, 'v', `${Math.round(d.endHeightM * 1000)}`));
parts.push(text(X(0) + 4, Y(d.parapetTopEl) + 4, `흉벽 상단 ${el(d.parapetTopEl)}`, 'start'));
parts.push(text(X(pb) + 4, Y(bracketTop) - 4, `브라켓 상면 ${el(bracketTop)}`, 'start'));
parts.push(text(X(wf) + 4, Y(d.seatEl) + 4, `교좌면 ${el(d.seatEl)}`, 'start'));
parts.push(text(X(dmRight) + 4, Y(d.footingTopEl) + 4, el(d.footingTopEl), 'start'));
parts.push(text(X(dmRight) + 4, Y(footingBottom) + 4, `기초 하단 ${el(footingBottom)}`, 'start'));
parts.push(dimLine([X(0), Y(footingBottom)], [X(d.heelDistanceM), Y(footingBottom)], Y(footingBottom) + 18, 'h', ''));
parts.push(text((X(0) + X(d.heelDistanceM)) / 2, Y(footingBottom) + 31, `뒷굽 끝 ${d.heelDistanceM.toFixed(3)} m`, 'middle'));
return [
`<svg viewBox="0 0 ${W.toFixed(0)} ${H.toFixed(0)}" width="100%" role="img" aria-label="${d.name} 측면: 길이 ${Math.round(d.lengthM * 1000)}, 흉벽 상단 ${el(d.parapetTopEl)}, 브라켓 상면 ${el(bracketTop)}, 뒷굽 끝 ${d.heelDistanceM.toFixed(3)} m">`,
...parts,
'</svg>',
].join('');
}
export interface AbutWingCornerDrawing {
parapetMm: number;
bracketMm: number;
wingThicknessMm: number;
haunchMm: number;
/** 접속슬래브 간격: 날개벽과 20, 흉벽과 5. 모따기 300 × 300 (spec §4.8). */
slabGapWingMm: number;
slabGapParapetMm: number;
chamferMm: number;
}
/** 좌 날개벽–흉벽 모서리 평면 (모식도): 흉벽, 브라켓(슬래브 아래), 날개벽, 헌치, 접속슬래브(모따기, 간격). */
export function abutWingCornerSvg(d: AbutWingCornerDrawing): string {
const k = 0.14; // px per mm
const x0 = 70; // 날개벽 내측면
const y0 = 60; // 흉벽 배면
const X = (mm: number) => x0 + mm * k;
const Y = (mm: number) => y0 + mm * k;
const span = 2200; // drawn extent from the corner (mm)
const h = d.haunchMm;
const c = d.chamferMm;
const sx = d.slabGapWingMm;
const sy = d.slabGapParapetMm;
const parts = [
// 흉벽 (above the 배면 line) and the 교대축 at its front face.
poly([[X(-d.wingThicknessMm), Y(-d.parapetMm)], [X(span), Y(-d.parapetMm)], [X(span), Y(0)], [X(-d.wingThicknessMm), Y(0)]], `fill="${WALL}" stroke="${MUTED}"`),
`<line x1="${X(-d.wingThicknessMm - 200).toFixed(1)}" y1="${Y(-d.parapetMm).toFixed(1)}" x2="${X(span).toFixed(1)}" y2="${Y(-d.parapetMm).toFixed(1)}" stroke="#c0392b" stroke-dasharray="8 3 2 3"/>`,
// 브라켓 (under the slab, dashed).
`<rect x="${X(0).toFixed(1)}" y="${Y(0).toFixed(1)}" width="${(span * k).toFixed(1)}" height="${(d.bracketMm * k).toFixed(1)}" fill="none" stroke="${MUTED}" stroke-dasharray="4 3"/>`,
// 날개벽 (left, running back = down).
poly([[X(-d.wingThicknessMm), Y(0)], [X(0), Y(0)], [X(0), Y(span)], [X(-d.wingThicknessMm), Y(span)]], `fill="${WING}" stroke="#9c8763"`),
];
if (h > 0) parts.push(poly([[X(0), Y(0)], [X(h), Y(0)], [X(0), Y(h)]], `fill="${WING}" stroke="#9c8763"`));
// 접속슬래브 with the corner cut (모따기) and the gaps.
parts.push(
poly(
[[X(sx + c), Y(sy)], [X(span), Y(sy)], [X(span), Y(span)], [X(sx), Y(span)], [X(sx), Y(sy + c)]],
`fill="${SLAB}" stroke="#b49a6b"`,
),
);
parts.push(text(X(-d.wingThicknessMm / 2), Y(span) - 6, `날개벽 ${d.wingThicknessMm}`, 'middle'));
parts.push(text(X(span / 2), Y(-d.parapetMm / 2) + 4, `흉벽 ${d.parapetMm}`, 'middle'));
parts.push(text(X(span) - 4, Y(-d.parapetMm) - 6, '교대축 (흉벽 전면)', 'end', '#9b2c1f'));
parts.push(text(X(span) - 4, Y(d.bracketMm) - 4, `브라켓 ${d.bracketMm} (슬래브 아래)`, 'end', MUTED));
if (h > 0) parts.push(text(X(h) + 6, Y(h / 2) + 4, `헌치 ${h} × ${h}`, 'start'));
parts.push(text(X(sx + c) + 8, Y(sy + c) + 14, `슬래브 모따기 ${c} × ${c}`, 'start'));
parts.push(text(X(span / 2), Y(span) - 6, `접속슬래브 · 날개벽과 ${sx} · 흉벽과 ${sy}`, 'middle'));
const W = X(span) + 20;
const H = Y(span) + 16;
return [
`<svg viewBox="0 0 ${W.toFixed(0)} ${H.toFixed(0)}" width="100%" role="img" aria-label="날개벽–흉벽 모서리: 헌치 ${h} × ${h}, 브라켓 ${d.bracketMm}, 슬래브 모따기 ${c} × ${c}, 간격 ${sx} / ${sy}">`,
...parts,
'</svg>',
].join('');
}
+8 -21
View File
@@ -1,19 +1,13 @@
import type { PierModel } from '../core/geometry';
import { bearingEdgeDistances, pairEccentricities } from '../core/bearing-layout';
/** Drawing values (A, B of the solplate, mm) of the front and back bearings, to overlay on the model (승인 2026-10-26). */
export interface BearingPlanOverlay {
front: { A: number; B: number };
back: { A: number; B: number };
}
import { BLOCK_DEPTH_MM } from '../core/bearing-table';
/** Number of pairs the 상세 보기 chips can show (①–⑩ in the mockup). */
export const PAIR_CHIP_LABELS = ['①', '②', '③', '④', '⑤', '⑥', '⑦', '⑧', '⑨', '⑩'];
/**
* 교량받침 상세 · 평면 for one pair (승인 2026-10-26): the bearings keep their actual 교각축 positions (the offset
* between the two bearings is shown as it is), and the blocks and solplates follow them. The model's solplates are solid, the drawing values
* dashed. 일체 draws one 2500 × 1200 outline; 분리 draws two 1200 × 1200 outlines. Dimensions: C1 (bearing to block edge),
* between the two bearings is shown as it is), and the blocks and solplates follow them. 일체 draws one 2500 × 1200 outline; 분리 draws two 1200 × 1200 outlines. Dimensions: C1 (bearing to block edge),
* C2 (between the bearings), C3 (block span). C4 and C5 are the outer margins along the 교축.
*/
/**
@@ -24,7 +18,7 @@ function rect(cx: number, cy: number, w: number, h: number, skewDeg: number, att
return `<rect x="${cx - w / 2}" y="${cy - h / 2}" width="${w}" height="${h}" transform="rotate(${-skewDeg} ${cx} ${cy})" ${attrs}></rect>`;
}
export function bearingPlanSvg(m: PierModel, pairIndex: number, overlay?: BearingPlanOverlay): string {
export function bearingPlanSvg(m: PierModel, pairIndex: number): string {
const pair = pairEccentricities(m.blocks)[pairIndex];
if (!pair) return '';
const f = m.blocks.find((b) => b.no === pair.frontNo && b.row === '전열')!;
@@ -55,18 +49,9 @@ export function bearingPlanSvg(m: PierModel, pairIndex: number, overlay?: Bearin
}
}
// Solplates: the model's plan (A across the 교축, B along the 교각축), centred on each bearing.
// Solplates: the pier's plan (L across the 교축, W along the 교각축), centred on each bearing.
for (const b of [f, k]) {
if (b.plan) {
const { A, B } = b.plan;
parts.push(rect(X(b.n), Y(b.s), A, B, b.rowSkewDeg, `fill="#EEF3F7" stroke="#2F6DB5" stroke-width="10"`));
}
}
// Drawing values overlaid (dashed) on the same bearings (#1 겹쳐 보이게).
if (overlay) {
for (const [b, v] of [[f, overlay.front], [k, overlay.back]] as const) {
parts.push(rect(X(b.n), Y(b.s), v.A, v.B, b.rowSkewDeg, `fill="none" stroke="#B4531F" stroke-width="10" stroke-dasharray="40 25"`));
}
parts.push(rect(X(b.n), Y(b.s), b.plan.L, b.plan.W, b.rowSkewDeg, `fill="#EEF3F7" stroke="#2F6DB5" stroke-width="10"`));
}
// Bearing centres and numbers.
@@ -104,6 +89,8 @@ export function bearingPlanSvg(m: PierModel, pairIndex: number, overlay?: Bearin
const highEdge = ib ? Y(ib.s) + 600 : Y(k.s) + 600;
parts.push(vdim(lowEdge, Y(f.s), `C4 ${Math.round(d(f.no).leftMm)}`));
parts.push(vdim(Y(k.s), highEdge, `C5 ${Math.round(d(k.no).rightMm)}`));
// C6: block depth along the 교각축 (1200 for 분리 and 일체 alike).
parts.push(`<text x="${xRight + 60}" y="${highEdge + 110}" font-size="90" fill="#18212B">C6 ${BLOCK_DEPTH_MM} (교각축 방향 블록 폭)</text>`);
return `<svg viewBox="-1900 -1300 3800 2600" width="100%" style="max-width: 560px; height: auto" role="img" aria-label="교량받침 상세 평면: 쌍 ${pair.frontNo}·${pair.backNo}, 블록 한 줄, 교축·교각축 방향, C1·C2·C3, C4·C5, 도면값 겹쳐 보기">${parts.join('')}</svg>`;
return `<svg viewBox="-1900 -1300 3800 2600" width="100%" style="max-width: 560px; height: auto" role="img" aria-label="교량받침 삽도 평면: 쌍 ${pair.frontNo}·${pair.backNo}, 교축·교각축 방향, C1–C6">${parts.join('')}</svg>`;
}
+33 -46
View File
@@ -1,27 +1,13 @@
import type { PierModel } from '../core/geometry';
import { pairEccentricities } from '../core/bearing-layout';
import { pairCopingSpans } from '../core/bearing-table';
/**
* Drawing values of one bearing (도면값), overlaid as dashed outlines (승인 2026-10-26).
* All lengths in mm; elx in m.
* 교량받침 삽도 · 단면 (elevation) for one pair (승인 2026-10-26, 모델값 2026-11-03): the model's blocks, mortar, bearing
* body and solplate per bearing. x is the 교축 (n), y the EL (up). Labels per bearing: EL.X (mortar top), h2 · h3 · h4 · E,
* h1 (block). Below: S1 (bearing centre to the coping edge), S2 (between the bearings), S1, and the coping width B.
*/
export interface BearingDrawingRef {
elx?: number;
h1: number;
h2: number;
h3: number;
h4: number;
E: number;
A: number;
B: number;
}
/**
* 교량받침 상세 · 단면 (elevation) for one pair (승인 2026-10-26): the model's blocks, mortar, bearing body,
* and solplate per bearing, with the drawing heights overlaid as dashed outlines. x is the 교축 (n), y the EL (up).
* Labels: EL.X (mortar top), h1 (block), h2 (mortar), h3 (bearing), h4 (solplate), E = h1 + h2 + h3 + h4.
*/
export function bearingSectionSvg(m: PierModel, pairIndex: number, refs?: { front?: BearingDrawingRef; back?: BearingDrawingRef }): string {
export function bearingSectionSvg(m: PierModel, pairIndex: number): string {
const pair = pairEccentricities(m.blocks)[pairIndex];
if (!pair) return '';
const f = m.blocks.find((b) => b.no === pair.frontNo && b.row === '전열')!;
@@ -46,37 +32,38 @@ export function bearingSectionSvg(m: PierModel, pairIndex: number, refs?: { fron
}
// Mortar, bearing body and solplate per bearing, from the model.
for (const [b, ref] of [[f, refs?.front], [k, refs?.back]] as const) {
for (const b of [f, k]) {
const pad = 1000;
parts.push(`<rect x="${X(b.n) - pad / 2}" y="${Y(b.padTop)}" width="${pad}" height="${50}" fill="#B7C7D3" stroke="#6E8496" stroke-width="6"></rect>`);
if (b.plan) {
const { A, margin, h3, T } = b.plan;
const L = A + margin;
const bodyTop = b.padTop + h3 / 1000;
parts.push(`<rect x="${X(b.n) - (L - 200) / 2}" y="${Y(bodyTop)}" width="${L - 200}" height="${h3}" fill="#8898AA" stroke="#5B6676" stroke-width="6"></rect>`);
const drop = (L * Math.abs(b.plan.S)) / 200;
const t1 = T + drop;
const t2 = T - drop;
const x0 = X(b.n) - L / 2;
const x1 = X(b.n) + L / 2;
parts.push(`<polygon points="${x0},${Y(bodyTop)} ${x1},${Y(bodyTop)} ${x1},${Y(bodyTop + t2 / 1000)} ${x0},${Y(bodyTop + t1 / 1000)}" fill="#F3D9B8" stroke="#B4531F" stroke-width="6"></polygon>`);
}
if (ref) {
// Drawing heights from the block bottom, dashed (overlay).
const base = b.bottom;
const dBlockTop = base + (ref.h1 ?? 0) / 1000;
const dPad = dBlockTop + ref.h2 / 1000;
const dBody = dPad + ref.h3 / 1000;
const dSol = dBody + ref.h4 / 1000;
parts.push(`<rect x="${X(b.n) - 600}" y="${Y(dBlockTop)}" width="1200" height="${(dBlockTop - base) * 1000}" fill="none" stroke="#B4531F" stroke-width="8" stroke-dasharray="40 25"></rect>`);
parts.push(`<line x1="${X(b.n) - 700}" y1="${Y(dSol)}" x2="${X(b.n) + 700}" y2="${Y(dSol)}" stroke="#B4531F" stroke-width="8" stroke-dasharray="40 25"></line>`);
parts.push(`<line x1="${X(b.n) - 700}" y1="${Y(dBody)}" x2="${X(b.n) + 700}" y2="${Y(dBody)}" stroke="#B4531F" stroke-width="8" stroke-dasharray="40 25"></line>`);
}
// EL.X at the mortar top, bearing number.
const { L, h3, T, T1, T2 } = b.plan;
const bodyTop = b.padTop + h3 / 1000;
parts.push(`<rect x="${X(b.n) - (L - 200) / 2}" y="${Y(bodyTop)}" width="${L - 200}" height="${h3}" fill="#8898AA" stroke="#5B6676" stroke-width="6"></rect>`);
const x0 = X(b.n) - L / 2;
const x1 = X(b.n) + L / 2;
parts.push(`<polygon points="${x0},${Y(bodyTop)} ${x1},${Y(bodyTop)} ${x1},${Y(bodyTop + T2 / 1000)} ${x0},${Y(bodyTop + T1 / 1000)}" fill="#F3D9B8" stroke="#B4531F" stroke-width="6"></polygon>`);
// EL.X at the mortar top, bearing number, and the heights of this bearing.
const E = Math.round((b.top - b.bottom) * 1000) + Math.round((b.padTop - b.top) * 1000) + h3 + T;
parts.push(`<circle cx="${X(b.n)}" cy="${Y(b.padTop)}" r="30" fill="#18212B"></circle>`);
parts.push(`<text x="${X(b.n) + 60}" y="${Y(b.padTop) - 60}" font-size="90" fill="#18212B">${b.no} · EL.X ${b.padTop.toFixed(3)}</text>`);
parts.push(`<text x="${X(b.n) + 60}" y="${Y(b.padTop) - 260}" font-size="80" fill="#55606E">h2 ${Math.round((b.padTop - b.top) * 1000)} · h3 ${h3} · h4 ${T} · E ${E}</text>`);
parts.push(`<text x="${X(b.n)}" y="${Y(b.bottom) + 160}" text-anchor="middle" font-size="90" fill="#55606E">h1 ${Math.round((b.top - b.bottom) * 1000)}</text>`);
}
return `<svg viewBox="-2000 -1200 4000 2000" width="100%" style="max-width: 560px; height: auto" role="img" aria-label="교량받침 상세 단면: 블록, 모르타르, 받침, 솔플레이트, EL.X, 도면 높이 점선">${parts.join('')}</svg>`;
}
// S1 · S2 · S1 along 교축 below the blocks: bearing centre to the coping edge (S1), between the bearings (S2).
const spans = pairCopingSpans(m, pairIndex);
if (spans) {
const lo = Math.min(f.n, k.n);
const xLo = X(lo);
const xHi = xLo + spans.S2;
const xEdge0 = xLo - spans.S1Low;
const xEdge1 = xHi + spans.S1High;
const yDim = Math.max(Y(f.bottom), Y(k.bottom)) + 420;
const dim = (x1: number, x2: number, label: string) =>
`<line x1="${x1}" y1="${yDim}" x2="${x2}" y2="${yDim}" stroke="#55606E" stroke-width="6"></line><line x1="${x1}" y1="${yDim - 50}" x2="${x1}" y2="${yDim + 50}" stroke="#55606E" stroke-width="6"></line><line x1="${x2}" y1="${yDim - 50}" x2="${x2}" y2="${yDim + 50}" stroke="#55606E" stroke-width="6"></line><text x="${(x1 + x2) / 2}" y="${yDim + 110}" text-anchor="middle" font-size="90" fill="#18212B">${label}</text>`;
parts.push(dim(xEdge0, xLo, `S1 ${spans.S1Low}`));
parts.push(dim(xLo, xHi, `S2 ${spans.S2}`));
parts.push(dim(xHi, xEdge1, `S1 ${spans.S1High}`));
parts.push(`<text x="${(xEdge0 + xEdge1) / 2}" y="${yDim + 280}" text-anchor="middle" font-size="90" fill="#55606E">코핑 폭 B ${spans.copingWidth} (받침중심 ↔ 코핑 연단: S1 · S2)</text>`);
}
return `<svg viewBox="-2000 -1200 4000 2000" width="100%" style="max-width: 560px; height: auto" role="img" aria-label="교량받침 삽도 단면: 블록, 모르타르, 받침, 솔플레이트, EL.X, S1·S2 코핑 치수">${parts.join('')}</svg>`;
}
+30
View File
@@ -0,0 +1,30 @@
import type { PierModel } from '../core/geometry';
import type { CastLayout } from '../core/rebar/cast-layout';
/** Front and section projections of the same centre lines used in the 3D scene. */
export function castRebarSvg(model: PierModel, layout: CastLayout): string {
const bars = layout.bars.filter(b => b.columnIndex === 0);
if (!bars.length) return '<p>입력 오류를 확인하세요. 생성된 기둥·말뚝 철근이 없습니다.</p>';
const pts = bars.flatMap(b => b.pts);
const bottom = pts.reduce((a, p) => Math.min(a, p[1]), Infinity);
const top = pts.reduce((a, p) => Math.max(a, p[1]), -Infinity);
const diameter = model.columnDiameterMm / 1000;
const scale = Math.min(180 / diameter, 480 / (top - bottom));
const x = (v: number) => 130 + (v - model.columns[0].s) * scale;
const y = (v: number) => 35 + (top - v) * scale;
const colour = (kind: string) => kind === 'main' ? '#0f5c66' : kind === 'coupler' ? '#0b3f46' : '#6b3fa0';
const front = bars.map(b => `<polyline points="${b.pts.map(p => `${x(p[0]).toFixed(2)},${y(p[1]).toFixed(2)}`).join(' ')}" fill="none" stroke="${colour(b.kind)}" stroke-width="${b.kind === 'coupler' ? 2 : 0.7}"/>`).join('');
const sectionHeight = model.groundEl + Math.min(1, (model.columns[0].top - model.groundEl) / 2);
const sectionScale = 140 / diameter;
const section = bars.filter(b => b.kind === 'main' && b.pts[0][1] <= sectionHeight && b.pts.at(-1)![1] >= sectionHeight).map(b => {
const p = b.pts[0];
return `<circle cx="${365 + (p[0] - model.columns[0].s) * sectionScale}" cy="${160 + p[2] * sectionScale}" r="${Math.max(1, b.diameterMm / 2000 * sectionScale)}" fill="#0f5c66"/>`;
}).join('');
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 470 560" role="img" aria-label="첫 기둥 및 현장타설말뚝 배근 정면·기둥 단면" style="width:100%;max-width:560px">
<text x="20" y="20" font-size="12">정면 · 실제 생성 경로</text>
<rect x="${x(model.columns[0].s - diameter / 2)}" y="${y(top)}" width="${diameter * scale}" height="${(top - bottom) * scale}" fill="none" stroke="#d9d6cc"/>
${front}<line x1="20" x2="245" y1="${y(model.groundEl)}" y2="${y(model.groundEl)}" stroke="#5b6878" stroke-dasharray="4 3"/>
<text x="20" y="${y(model.groundEl) - 5}" font-size="11">지반고 EL ${model.groundEl.toFixed(3)}</text>
<text x="285" y="40" font-size="12">기둥 주철근 단면</text><circle cx="365" cy="160" r="70" fill="none" stroke="#d9d6cc"/>${section}
<text x="20" y="548" font-size="11">단부·저부 P6 미확정 사항은 알림 참조 · 길이 단위 mm</text></svg>`;
}
+28
View File
@@ -0,0 +1,28 @@
import * as THREE from 'three';
/**
* Camera that contains a sphere (2026-11-03): keeps the view direction of `position → target`, moves the target to the
* sphere centre, and backs off until the sphere fits both the vertical and the horizontal field of view.
*/
export function fitCamera(
position: THREE.Vector3,
target: THREE.Vector3,
sphere: THREE.Sphere,
fovDeg: number,
aspect: number,
margin = 1.1,
): { position: THREE.Vector3; target: THREE.Vector3 } {
const halfV = THREE.MathUtils.degToRad(fovDeg / 2);
// The narrower of the two half-angles decides how far back the camera must stand.
const halfH = Math.atan(Math.tan(halfV) * aspect);
const halfFov = Math.min(halfV, halfH);
const fit = (sphere.radius / Math.sin(halfFov)) * margin;
const dir = position.clone().sub(target);
const current = dir.length();
const dist = Math.max(current, fit);
dir.normalize();
return {
position: sphere.center.clone().add(dir.multiplyScalar(dist)),
target: sphere.center.clone(),
};
}
+132 -61
View File
@@ -14,73 +14,15 @@ const TOP_PAD = 24;
* Horizontal positions are schematic (no dimensions): piles are spread across the footing.
*/
export function groundSectionSvg(input: PierInput, model: PierModel): string {
const rows = input.strataRows ?? [];
const ground = input.groundEl;
const footTop = Math.max(...model.footings.map((f) => f.top));
const footBottom = Math.min(...model.footings.map((f) => f.bottom));
const pileTips = model.piles.map((p) => p.bottom);
const lowestRow = rows.length ? rows[rows.length - 1].bottomEl : ground - 1;
// 하상: the 설계홍수위 (solid) and the 세굴선 replace the groundwater (dashed, 승인 2026-10-26).
const position = input.groundPosition ?? '육상';
const river = position === '하상';
const sea = position === '해상';
const level = river
? (Number.isFinite(input.floodLevelEl) ? input.floodLevelEl : undefined)
: sea
? (Number.isFinite(input.highWaterEl) ? input.highWaterEl : undefined)
: (input.groundWaterOn && Number.isFinite(input.groundWaterEl) ? input.groundWaterEl : undefined);
// 해상: the 저조위 is drawn too (dashed), on the MSL datum.
const lowLevel = sea && Number.isFinite(input.lowWaterEl) ? input.lowWaterEl : undefined;
const groundLabel = sea ? '해저면 EL' : '지반고';
const scour = river && Number.isFinite(input.scourDepthM) && (input.scourDepthM as number) >= 0 ? (input.scourDepthM as number) : undefined;
const scourEl = scour !== undefined ? ground - scour : undefined;
const yMin = Math.min(footBottom, lowestRow, ...pileTips, level ?? ground, lowLevel ?? ground, scourEl ?? ground) - 1;
const yMax = Math.max(ground, footTop, input.copingTopEl) + 0.5;
const { yMin } = groundRange(input, [footBottom, ...pileTips]);
const yMax = Math.max(input.groundEl, footTop, input.copingTopEl) + 0.5;
const scale = PLOT_H / (yMax - yMin);
const y = (el: number) => TOP_PAD + (yMax - el) * scale;
const H = TOP_PAD + PLOT_H + 16;
const parts: string[] = [];
// Layers: each band runs from the upper boundary (ground for the first) down to its 하단 EL.
let upper = ground;
rows.forEach((row, i) => {
const top = y(upper);
const bottom = y(row.bottomEl);
parts.push(`<rect x="0" y="${top}" width="${WIDTH}" height="${bottom - top}" fill="${LAYER_FILLS[i % LAYER_FILLS.length]}"></rect>`);
parts.push(`<text x="8" y="${(top + bottom) / 2 + 4}" font-size="12" fill="#18212B">${escapeText(row.name)}</text>`);
if (row.name === SUPPORT_STRATUM) {
parts.push(`<line x1="0" y1="${top}" x2="${WIDTH}" y2="${top}" stroke="#5B4A2E" stroke-width="2"></line>`);
// Caption just under the line, so it does not collide with the label of the band above.
parts.push(`<text x="8" y="${top + 14}" font-size="12" fill="#18212B">연암층 상면 EL ${upper.toFixed(3)}</text>`);
}
upper = row.bottomEl;
});
if (rows.length === 0 || upper > yMin) {
parts.push(`<rect x="0" y="${y(upper)}" width="${WIDTH}" height="${y(yMin) - y(upper)}" fill="${LAYER_FILLS[rows.length % LAYER_FILLS.length]}"></rect>`);
}
// Ground line and label.
parts.push(`<line x1="0" y1="${y(ground)}" x2="${WIDTH}" y2="${y(ground)}" stroke="#8A6D3B" stroke-width="1.5"></line>`);
parts.push(`<text x="8" y="${y(ground) - 6}" font-size="12" fill="#18212B">${groundLabel} ${ground.toFixed(3)}</text>`);
// Groundwater (육상, when on) as a dashed line; the 설계홍수위 (하상) as a solid line.
if (level !== undefined) {
const dash = river ? '' : ' stroke-dasharray="6 4"';
const label = river ? '설계홍수위 DHWL' : sea ? '고조위 HWL' : '지하수위';
parts.push(`<line x1="0" y1="${y(level)}" x2="${WIDTH}" y2="${y(level)}" stroke="#2F6DB5" stroke-width="${river ? 2 : 1}"${dash}></line>`);
parts.push(`<text x="8" y="${y(level) - 4}" font-size="12" fill="#123E70">${label} ${level.toFixed(3)}</text>`);
}
// 해상: 저조위 (dashed, MSL datum).
if (lowLevel !== undefined) {
parts.push(`<line x1="0" y1="${y(lowLevel)}" x2="${WIDTH}" y2="${y(lowLevel)}" stroke="#2F6DB5" stroke-dasharray="6 4"></line>`);
parts.push(`<text x="8" y="${y(lowLevel) + 12}" font-size="12" fill="#123E70">저조위 LWL ${lowLevel.toFixed(3)}</text>`);
}
// 세굴선 (하상·해상): the bed after scour.
if (scourEl !== undefined) {
parts.push(`<line x1="0" y1="${y(scourEl)}" x2="${WIDTH}" y2="${y(scourEl)}" stroke="#8A6D3B" stroke-dasharray="3 3"></line>`);
parts.push(`<text x="8" y="${y(scourEl) + 12}" font-size="12" fill="#5B4A2E">세굴선 ${scourEl.toFixed(3)}</text>`);
}
const parts = groundBands(input, y, yMin, WIDTH, (v) => v.toFixed(3));
// Footing, column stub, and piles.
const cx = WIDTH / 2;
@@ -100,6 +42,135 @@ export function groundSectionSvg(input: PierInput, model: PierModel): string {
return `<svg viewBox="0 0 ${WIDTH} ${H}" width="100%" style="max-width: ${WIDTH}px; height: auto" role="img" aria-label="지반·지층 단면: 지반면, 지층, 지하수위, 기초와 말뚝">${parts.join('')}</svg>`;
}
/** The 지반·지층 values the bands read (a pier input, or an abutment's 지반 values with its 지반고). */
type GroundBandInput = Pick<PierInput, 'groundEl' | 'groundPosition' | 'groundWaterOn' | 'groundWaterEl' | 'floodLevelEl' | 'scourDepthM' | 'highWaterEl' | 'lowWaterEl' | 'strataRows'>;
/** The water line (지하수위 / 설계홍수위 / 고조위), the 저조위 and the 세굴선 of the input, as ELs. */
function groundLevels(input: GroundBandInput) {
const position = input.groundPosition ?? '육상';
const river = position === '하상';
const sea = position === '해상';
const level = river
? (Number.isFinite(input.floodLevelEl) ? input.floodLevelEl : undefined)
: sea
? (Number.isFinite(input.highWaterEl) ? input.highWaterEl : undefined)
: (input.groundWaterOn && Number.isFinite(input.groundWaterEl) ? input.groundWaterEl : undefined);
const lowLevel = sea && Number.isFinite(input.lowWaterEl) ? input.lowWaterEl : undefined;
const scour = river && Number.isFinite(input.scourDepthM) && (input.scourDepthM as number) >= 0 ? (input.scourDepthM as number) : undefined;
const scourEl = scour !== undefined ? input.groundEl - scour : undefined;
return { river, sea, level, lowLevel, scourEl };
}
/** Lowest EL to draw: 1 m below the deepest of the layers, the structure ELs given, and the water and scour lines. */
function groundRange(input: GroundBandInput, structureEls: number[]): { yMin: number } {
const rows = input.strataRows ?? [];
const g = input.groundEl;
const { level, lowLevel, scourEl } = groundLevels(input);
const lowestRow = rows.length ? rows[rows.length - 1].bottomEl : g - 1;
return { yMin: Math.min(lowestRow, ...structureEls, level ?? g, lowLevel ?? g, scourEl ?? g) - 1 };
}
/**
* 지층 띠, 지반선, 수위선 (지하수위 점선 · 설계홍수위 실선 · 고조위 / 저조위), 세굴선 (2026-11-04: shared by the pier and the
* abutment 삽도). `fmt` writes an EL (the pier writes "28.931", the abutment "EL.28.931").
*/
function groundBands(input: GroundBandInput, y: (el: number) => number, yMin: number, width: number, fmt: (el: number) => string): string[] {
const rows = input.strataRows ?? [];
const ground = input.groundEl;
const { river, sea, level, lowLevel, scourEl } = groundLevels(input);
const groundLabel = sea ? '해저면 EL' : '지반고';
const parts: string[] = [];
// Layers: each band runs from the upper boundary (ground for the first) down to its 하단 EL.
let upper = ground;
rows.forEach((row, i) => {
const top = y(upper);
const bottom = y(row.bottomEl);
parts.push(`<rect x="0" y="${top}" width="${width}" height="${bottom - top}" fill="${LAYER_FILLS[i % LAYER_FILLS.length]}"></rect>`);
parts.push(`<text x="8" y="${(top + bottom) / 2 + 4}" font-size="12" fill="#18212B">${escapeText(row.name)}</text>`);
if (row.name === SUPPORT_STRATUM) {
parts.push(`<line x1="0" y1="${top}" x2="${width}" y2="${top}" stroke="#5B4A2E" stroke-width="2"></line>`);
// Caption just under the line, so it does not collide with the label of the band above.
parts.push(`<text x="8" y="${top + 14}" font-size="12" fill="#18212B">연암층 상면 ${fmt(upper).startsWith('EL') ? '' : 'EL '}${fmt(upper)}</text>`);
}
upper = row.bottomEl;
});
if (rows.length === 0 || upper > yMin) {
parts.push(`<rect x="0" y="${y(upper)}" width="${width}" height="${y(yMin) - y(upper)}" fill="${LAYER_FILLS[rows.length % LAYER_FILLS.length]}"></rect>`);
}
// Ground line and label.
parts.push(`<line x1="0" y1="${y(ground)}" x2="${width}" y2="${y(ground)}" stroke="#8A6D3B" stroke-width="1.5"></line>`);
parts.push(`<text x="8" y="${y(ground) - 6}" font-size="12" fill="#18212B">${groundLabel} ${fmt(ground)}</text>`);
// Groundwater (육상, when on) as a dashed line; the 설계홍수위 (하상) as a solid line.
if (level !== undefined) {
const dash = river ? '' : ' stroke-dasharray="6 4"';
const label = river ? '설계홍수위 DHWL' : sea ? '고조위 HWL' : '지하수위';
parts.push(`<line x1="0" y1="${y(level)}" x2="${width}" y2="${y(level)}" stroke="#2F6DB5" stroke-width="${river ? 2 : 1}"${dash}></line>`);
parts.push(`<text x="8" y="${y(level) - 4}" font-size="12" fill="#123E70">${label} ${fmt(level)}</text>`);
}
// 해상: 저조위 (dashed, MSL datum).
if (lowLevel !== undefined) {
parts.push(`<line x1="0" y1="${y(lowLevel)}" x2="${width}" y2="${y(lowLevel)}" stroke="#2F6DB5" stroke-dasharray="6 4"></line>`);
parts.push(`<text x="8" y="${y(lowLevel) + 12}" font-size="12" fill="#123E70">저조위 LWL ${fmt(lowLevel)}</text>`);
}
// 세굴선 (하상·해상): the bed after scour.
if (scourEl !== undefined) {
parts.push(`<line x1="0" y1="${y(scourEl)}" x2="${width}" y2="${y(scourEl)}" stroke="#8A6D3B" stroke-dasharray="3 3"></line>`);
parts.push(`<text x="8" y="${y(scourEl) + 12}" font-size="12" fill="#5B4A2E">세굴선 ${fmt(scourEl)}</text>`);
}
return parts;
}
/** 교대 지반 삽도 입력 (2026-11-04): the 지반 values and the generated 저판 · 벽체 · 흉벽 · 말뚝 (t in m, 전면 +). */
export interface AbutGroundDrawing {
ground: GroundBandInput;
footingTopEl: number;
footingBottomEl: number;
seatEl: number;
parapetTopEl: number;
bracketHeightM: number;
wallFrontM: number;
wallBackM: number;
parapetBackM: number;
footingFrontM: number;
footingBackM: number;
pileRowsT: number[];
pileBottomEl?: number;
}
/**
* 교대 지반·지층 단면 (삽도, 2026-11-04): 교대축 직각 단면(저판, 벽체, 브라켓 단차, 흉벽, 말뚝 열)을 지층 · 수위 위에 그린다.
* 세로는 EL 한 축척, 가로는 단면 모양을 보이는 모식 축척이다. 왼쪽이 배면.
*/
export function abutGroundSectionSvg(d: AbutGroundDrawing): string {
const g = d.ground;
const { yMin } = groundRange(g, [d.footingBottomEl, ...(d.pileBottomEl === undefined ? [] : [d.pileBottomEl])]);
const yMax = Math.max(g.groundEl, d.parapetTopEl) + 0.8;
const scale = PLOT_H / (yMax - yMin);
const y = (el: number) => TOP_PAD + (yMax - el) * scale;
const H = TOP_PAD + PLOT_H + 16;
const fmt = (v: number) => `EL.${v.toFixed(3)}`;
const parts = groundBands(g, y, yMin, WIDTH, fmt);
const kx = 150 / (d.footingFrontM - d.footingBackM);
const cx = WIDTH / 2;
const X = (t: number) => cx + t * kx;
const rect = (t0: number, t1: number, e0: number, e1: number, style: string) =>
`<rect x="${X(t0)}" y="${y(e1)}" width="${X(t1) - X(t0)}" height="${y(e0) - y(e1)}" ${style}></rect>`;
const bracketTop = d.parapetTopEl - d.bracketHeightM;
if (d.pileBottomEl !== undefined) {
for (const t of d.pileRowsT) parts.push(rect(t - 0.15, t + 0.15, d.pileBottomEl, d.footingBottomEl, 'fill="#7D8A97"'));
}
parts.push(rect(d.footingBackM, d.footingFrontM, d.footingBottomEl, d.footingTopEl, 'fill="#B7C7D3" stroke="#6E8496"'));
parts.push(rect(d.wallBackM, d.wallFrontM, d.footingTopEl, d.seatEl, 'fill="#D8C7AA" stroke="#9C8763"'));
parts.push(rect(d.wallBackM, d.parapetBackM, d.seatEl, bracketTop, 'fill="#D8C7AA" stroke="#9C8763"'));
parts.push(rect(d.parapetBackM, 0, d.seatEl, d.parapetTopEl, 'fill="#D8C7AA" stroke="#9C8763"'));
// Right-aligned at the drawing edge so the longer EL labels are not cut off.
parts.push(`<text x="${WIDTH - 4}" y="${y(d.footingBottomEl) + 14}" font-size="12" fill="#18212B" text-anchor="end">기초 하단 ${fmt(d.footingBottomEl)}</text>`);
if (d.pileBottomEl !== undefined) parts.push(`<text x="${WIDTH - 4}" y="${y(d.pileBottomEl) + 14}" font-size="12" fill="#18212B" text-anchor="end">말뚝 선단 ${fmt(d.pileBottomEl)}</text>`);
parts.push(`<text x="${X(0) + 4}" y="${y(d.parapetTopEl) + 4}" font-size="12" fill="#18212B">흉벽 상단 ${fmt(d.parapetTopEl)}</text>`);
parts.push(`<text x="${WIDTH - 4}" y="${H - 4}" font-size="11" fill="#55606E" text-anchor="end">← 배면 · 전면 →</text>`);
return `<svg viewBox="0 0 ${WIDTH} ${H}" width="100%" style="max-width: ${WIDTH + 80}px; height: auto" role="img" aria-label="교대 지반·지층 단면: 지반면, 지층, 수위, 저판 · 벽체 · 흉벽 · 말뚝">${parts.join('')}</svg>`;
}
function escapeText(s: string): string {
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
+61 -8
View File
@@ -5,6 +5,8 @@ import { bottomPiece, MAIN, topSplicePieces, widthLayout } from '../core/rebar/c
import { C14_INSET, c14Bars, CAP, COPING_REBAR, SHEAR_LOOP_STEP, shearBars, shearBarsMulti, shearUHeight, copingSideBars, SIDE_HOOK_LEG, stairBars, undersideAt } from '../core/rebar/coping';
import { sideRowLevels } from '../core/coping-geom';
import { columnRebar, columnInputFor } from '../core/rebar/column';
import { castRebarLayout } from '../core/rebar/cast-layout';
import { multicolCopingBars } from '../core/rebar/multicol-coping';
import { barDepths, barsAcross } from '../core/rebar/bearing-block';
import { blockFramePoint } from '../core/geometry';
import { INTEGRATED_BLOCK } from '../core/integrated-block';
@@ -49,7 +51,7 @@ const MEMBER_LABELS: Record<RebarMemberKey, string> = { bearing: '받침블럭',
const hex = (v: number) => `#${v.toString(16).padStart(6, '0')}`;
/** Rebar is drawn a little deeper than the 6.7 colours so it reads through the half-transparent members (승인 2026-10-23). */
const REBAR_SHADE = 0.8;
const rebarColor = (v: number) => new THREE.Color(v).multiplyScalar(REBAR_SHADE);
export const rebarColor = (v: number) => new THREE.Color(v).multiplyScalar(REBAR_SHADE);
const COLOR = {
coping: 0xb4531f,
@@ -65,6 +67,28 @@ const COLOR = {
const mm = (v: number) => v / 1000;
/** Arc-length interpolation avoids a linear scan per vertex on long spiral paths. */
class CastPolyline extends THREE.Curve<THREE.Vector3> {
private cumulative = [0];
constructor(private points: [number, number, number][]) {
super();
for (let i = 1; i < points.length; i++) this.cumulative.push(this.cumulative[i - 1] + Math.hypot(...points[i].map((v, j) => v - points[i - 1][j])));
}
getPoint(t: number, target = new THREE.Vector3()): THREE.Vector3 {
const distance = Math.max(0, Math.min(1, t)) * this.getLength();
let lo = 1, hi = this.points.length - 1;
while (lo < hi) { const mid = (lo + hi) >> 1; if (this.cumulative[mid] < distance) lo = mid + 1; else hi = mid; }
const fraction = (distance - this.cumulative[lo - 1]) / (this.cumulative[lo] - this.cumulative[lo - 1] || 1);
const a = this.points[lo - 1], b = this.points[lo];
return target.set(a[0] + (b[0] - a[0]) * fraction, a[1] + (b[1] - a[1]) * fraction, a[2] + (b[2] - a[2]) * fraction);
}
getPointAt(t: number, target = new THREE.Vector3()): THREE.Vector3 { return this.getPoint(t, target); }
getTangentAt(t: number, target = new THREE.Vector3()): THREE.Vector3 {
return target.copy(this.getPoint(Math.min(1, t + 1e-5))).sub(this.getPoint(Math.max(0, t - 1e-5))).normalize();
}
getLength(): number { return this.cumulative.at(-1)!; }
}
export function buildRebar3D(m: PierModel, common: Material = DEFAULT_MATERIAL, byMember: FckByMember = {}): RebarScene {
const mCoping = materialFor(common, byMember, 'coping');
const mColumn = materialFor(common, byMember, 'column');
@@ -140,7 +164,13 @@ export function buildRebar3D(m: PierModel, common: Material = DEFAULT_MATERIAL,
};
// Coping (straight coping only; a bent coping is not drawn in 3D yet).
if (straight) {
if (straight && m.columns.length > 1) {
for (const b of multicolCopingBars(m, mCoping)) {
if (/^C[1-8](-\d+)?$/.test(b.name)) addPath(b.name, 0, b.pts, b.diameterMm, COLOR.coping);
else for (let i = 1; i < b.pts.length; i++) addBar(b.name, 0, b.pts[i - 1], b.pts[i], b.diameterMm, COLOR.shear);
}
}
if (straight && m.columns.length <= 1) {
const w = widthLayout(B * 1000, m.copingRebar);
const xs = [w.yE, ...Array.from({ length: w.n + 1 }, (_, i) => w.yE + w.g + w.spacing * i), B * 1000 - w.yE].map((v) => mm(v) - B / 2);
const cover = 0.1;
@@ -248,7 +278,7 @@ export function buildRebar3D(m: PierModel, common: Material = DEFAULT_MATERIAL,
const c14Hook = mm(hookLength('main90', NOMINAL_DIAMETER.H19));
const c14Na = -B / 2 + mm(C14_INSET);
const c14Nb = B / 2 - mm(C14_INSET);
for (const b of c14Bars(L * 1000, topEl, [...side.c12, ...side.c13], (x) => undersideAt(L * 1000, x, m.endBottom, m.levelBottom, m.copingGeom.levelWidth), m.copingRebar.mainLayerGap / 1000)) {
for (const b of c14Bars(L * 1000, topEl, [...side.c13, ...side.c12], (x) => undersideAt(L * 1000, x, m.endBottom, m.levelBottom, m.copingGeom.levelWidth), m.copingRebar.mainLayerGap / 1000)) {
const xm = mm(b.x);
addBar('C14', 0, [xm, b.z, c14Na], [xm, b.z, c14Nb], NOMINAL_DIAMETER.H19, COLOR.coping);
// 연직 발 below the coping underside is left out (코핑철근.md §8).
@@ -279,9 +309,7 @@ export function buildRebar3D(m: PierModel, common: Material = DEFAULT_MATERIAL,
}
// Columns: P1 / P2 main bars on their radii, ties P3 / P5 / P4 as rings, P6 crossties across the column.
const colInput = columnInputFor(m);
const col = columnRebar(colInput, mColumn);
const zBot = col.zBot; // mm
if (m.footings.length > 0) {
const ring = (name: string, k: number, sc: number, y: number, r: number, d: number) => {
const N = 36;
for (let i = 0; i < N; i++) {
@@ -290,7 +318,10 @@ export function buildRebar3D(m: PierModel, common: Material = DEFAULT_MATERIAL,
addBar(name, k, [sc + r * Math.cos(a0), y, r * Math.sin(a0)], [sc + r * Math.cos(a1), y, r * Math.sin(a1)], d, COLOR.tie);
}
};
for (const c of m.columns) {
for (const [columnIndex, c] of m.columns.entries()) {
const colInput = columnInputFor(m, undefined, columnIndex);
const col = columnRebar(colInput, mColumn);
const zBot = col.zBot;
const sc = c.s;
const base = c.bottom;
const rt = mm(col.tieRadius);
@@ -376,6 +407,23 @@ export function buildRebar3D(m: PierModel, common: Material = DEFAULT_MATERIAL,
}
}
}
if (m.castInput) {
for (const bar of castRebarLayout(m, mColumn).bars) {
if (bar.pts.length === 2) {
addBar(bar.name, bar.k, bar.pts[0], bar.pts[1], bar.diameterMm, bar.kind === 'coupler' ? COLOR.couplerMain : COLOR.column);
} else {
records.push({ name: bar.name, k: bar.k, pts: bar.pts, dMm: bar.diameterMm });
const path = new CastPolyline(bar.pts);
const geometry = new THREE.TubeGeometry(path, Math.min(20000, bar.pts.length), mm(bar.diameterMm) / 2, 6, false);
geometry.applyMatrix4(Ms[bar.k]);
const mesh = new THREE.Mesh(geometry, new THREE.MeshStandardMaterial({ color: rebarColor(COLOR.tie), roughness: 0.45, metalness: 0.1 }));
mesh.userData = { name: bar.name, roleColor: COLOR.tie, dMm: bar.diameterMm, member: 'column' };
group.add(mesh);
const list = byName.get(bar.name) ?? []; list.push(mesh); byName.set(bar.name, list);
}
}
}
// Footing bars B1–B8 (교각축 · 교각축 직각), the grids from §3 and §6 of 확대기초철근 (1).md.
for (const f of m.footings) {
const Lf = f.length;
@@ -555,6 +603,11 @@ export function buildRebar3D(m: PierModel, common: Material = DEFAULT_MATERIAL,
}
}
return { group, byName, records, ...rebarControls(byName) };
}
/** Highlight, colour and legend over bars grouped by 철근표 number (교각 · 교대 3D 철근 공통). */
export function rebarControls(byName: Map<string, THREE.Mesh[]>): Pick<RebarScene, 'show' | 'setColorMode' | 'legend'> {
const show = (names: ReadonlySet<string> | null, isolate: boolean) => {
// 커플러 as a name selects every coupler bar.
const isOn = (key: string) => names === null || names.has(key) || (names.has('커플러') && key.includes('커플러'));
@@ -592,5 +645,5 @@ export function buildRebar3D(m: PierModel, common: Material = DEFAULT_MATERIAL,
const items = DIAMETER_GROUPS.map((g, i) => ({ label: g.label, color: hex(DIAMETER_COLORS[i]) })).filter((_, i) => present.has(i));
return present.has(-1) ? [...items, { label: '기타', color: hex(OTHER_COLOR) }] : items;
};
return { group, byName, records, show, setColorMode, legend };
return { show, setColorMode, legend };
}
+20 -16
View File
@@ -152,14 +152,14 @@ export function buildPierScene(m: PierModel, terrain?: TerrainGrid, groundEl: nu
pad.rotateY(turn);
pad.translate(b.s, b.padTop - 0.025, b.n);
add('pads', pad, 1, b.k);
if (b.plan) {
// 교량받침 본체 (승인 2026-10-23): the solplate plan less 100 mm on each side, from the mortar top up to the solplate.
const body = new THREE.BoxGeometry((b.plan.B + b.plan.margin) / 1000 - 0.2, b.plan.h3 / 1000, (b.plan.A + b.plan.margin) / 1000 - 0.2);
{
// 교량받침 본체: the solplate plan less 100 mm on each side, from the mortar top up to the solplate.
const body = new THREE.BoxGeometry(b.plan.W / 1000 - 0.2, b.plan.h3 / 1000, b.plan.L / 1000 - 0.2);
body.rotateY(turn);
body.translate(b.s, b.padTop + b.plan.h3 / 2000, b.n);
add('bearings', body, 1, b.k);
// Solplate: a wedge that tapers across the 교각축 직각 (T1 at the (−) side), on the bearing (승인 2026-10-23).
const sp = solplateGeometry(b, b.plan.h3, { A: b.plan.A, B: b.plan.B, margin: b.plan.margin, T: b.plan.T, slopePct: b.plan.S });
// Solplate: a wedge that tapers across the 교각축 직각 (T1 at the (−) side), on the bearing.
const sp = solplateGeometry(b, b.plan);
add('solplates', wedgeGeometry(sp.corners), 1, b.k);
}
}
@@ -208,17 +208,21 @@ const REBAR_VIEW_ALPHA = 0.5;
/** Turns the ③ rebar view on or off. Off restores each part's own opacity. Depth writes stop while on, so bars behind a member stay visible. */
export function setRebarView(scene: PierScene, on: boolean): void {
for (const group of Object.values(scene.parts)) {
group.traverse((o) => {
if (!(o instanceof THREE.Mesh)) return;
const mat = o.material as THREE.MeshStandardMaterial;
const base = mat.userData.baseOpacity as number;
mat.opacity = on ? REBAR_VIEW_ALPHA : base;
mat.transparent = on || base < 1;
mat.depthWrite = !on;
mat.needsUpdate = true;
});
}
for (const group of Object.values(scene.parts)) setRebarViewOn(group, on);
}
/** {@link setRebarView} for any group (교대 3D). A material without a recorded base opacity takes its current one as the base. */
export function setRebarViewOn(root: THREE.Object3D, on: boolean): void {
root.traverse((o) => {
if (!(o instanceof THREE.Mesh)) return;
const mat = o.material as THREE.MeshStandardMaterial;
mat.userData.baseOpacity ??= mat.opacity;
const base = mat.userData.baseOpacity as number;
mat.opacity = on ? REBAR_VIEW_ALPHA : base;
mat.transparent = on || base < 1;
mat.depthWrite = !on && base >= 1;
mat.needsUpdate = true;
});
}
export type ViewKind = 'iso' | 'front' | 'side' | 'top';
-47
View File
@@ -1,47 +0,0 @@
import type { BlockModel } from '../core/geometry';
/** Drawing values of one solplate (도면값), overlaid as dashed outlines. mm. */
export interface SolplateDrawingRef {
L: number;
W: number;
T1: number;
T2: number;
S: number;
}
/**
* 솔플레이트 상세 (승인 2026-10-26) for one bearing: the plan (L across the 교축, W along the 교각축) and the
* taper in side view (T1 at the (−) side, T2 at the (+) side, S % · T). Model solid, drawing dashed.
*/
export function solplateDetailSvg(block: BlockModel, ref?: SolplateDrawingRef): string {
const plan = block.plan;
if (!plan) return '';
const L = plan.A + plan.margin;
const W = plan.B + plan.margin;
const drop = (L * Math.abs(plan.S)) / 200;
const t1 = plan.T + drop;
const t2 = plan.T - drop;
const parts: string[] = [];
// Plan (left): L across, W along.
parts.push(`<rect x="${-L / 2}" y="${-W / 2}" width="${L}" height="${W}" fill="#F3D9B8" stroke="#B4531F" stroke-width="8"></rect>`);
parts.push(`<text x="0" y="${-W / 2 - 40}" text-anchor="middle" font-size="60" fill="#18212B">L ${L.toFixed(0)}</text>`);
parts.push(`<text x="${L / 2 + 40}" y="0" font-size="60" fill="#18212B">W ${W.toFixed(0)}</text>`);
if (ref) {
parts.push(`<rect x="${-ref.L / 2}" y="${-ref.W / 2}" width="${ref.L}" height="${ref.W}" fill="none" stroke="#B4531F" stroke-width="6" stroke-dasharray="40 25"></rect>`);
}
// Side view (right): thickness T1 at x = −L/2 and T2 at x = +L/2, shown with the taper.
const ox = L / 2 + 500;
const sc = 4; // exaggerated thickness for legibility, labelled
parts.push(`<polygon points="${ox - L / 2},0 ${ox + L / 2},0 ${ox + L / 2},${-t2 * sc} ${ox - L / 2},${-t1 * sc}" fill="#F3D9B8" stroke="#B4531F" stroke-width="8"></polygon>`);
parts.push(`<text x="${ox - L / 2}" y="${-t1 * sc - 40}" font-size="60" fill="#18212B">T1 ${t1.toFixed(0)}</text>`);
parts.push(`<text x="${ox + L / 2}" y="${-t2 * sc - 40}" text-anchor="end" font-size="60" fill="#18212B">T2 ${t2.toFixed(0)}</text>`);
parts.push(`<text x="${ox}" y="120" text-anchor="middle" font-size="60" fill="#55606E">S ${plan.S} % · T ${plan.T} (두께 과장 ×${sc})</text>`);
if (ref) {
const r1 = ref.T1;
const r2 = ref.T2;
parts.push(`<polygon points="${ox - ref.L / 2},0 ${ox + ref.L / 2},0 ${ox + ref.L / 2},${-r2 * sc} ${ox - ref.L / 2},${-r1 * sc}" fill="none" stroke="#B4531F" stroke-width="6" stroke-dasharray="40 25"></polygon>`);
}
const minX = -L / 2 - 200;
const maxX = ox + L / 2 + 200;
return `<svg viewBox="${minX} -900 ${maxX - minX} 1300" width="100%" style="max-width: 560px; height: auto" role="img" aria-label="솔플레이트 상세: 평면 L × W, 두께 T1 · T2 (두께 과장)">${parts.join('')}</svg>`;
}
+57
View File
@@ -0,0 +1,57 @@
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import * as XLSX from 'xlsx';
import * as THREE from 'three';
import { describe, expect, it } from 'vitest';
import { bearingRotDeg, buildAbut } from '../src/core/abut/geometry';
import { parseAbutments, toAbutInput } from '../src/core/abut/parse';
import { buildMembers } from '../src/core/abut/members';
import { buildWings } from '../src/core/abut/wings';
import { buildAbutGroup } from '../src/view/abut-scene';
import { abutElevationRows, resolveBearingRule } from '../src/core/abut/bearing-table';
const RAG = fileURLToPath(new URL('../../../RAG/운곡교P1입력.xlsx', import.meta.url));
const rec = parseAbutments(XLSX.read(readFileSync(RAG), { type: 'buffer' })).abutments[0];
const model = buildAbut(toAbutInput(rec));
describe('교대 받침 방위각 (엑셀 받침 시트 6번째 열)', () => {
it('reads the 방위각 of every bearing (75°56′55.9″ … 75°58′08.4″ in the workbook frame)', () => {
expect(rec.bearings.every((b) => b.azimuthDeg !== undefined && Number.isFinite(b.azimuthDeg))).toBe(true);
// model-xy = 90 − workbook value: 75.94886° → 14.05114°
expect(rec.bearings.find((b) => b.no === 1)!.azimuthDeg).toBeCloseTo(90 - (75 + 56 / 60 + 55.9 / 3600), 6);
});
it('turns each block like the pier rule (≈ 4.42° from the axis normal); against the wing (skew) line the rest is 0.63° (시안 AW01)', () => {
const front = { e: model.nLeft.e * model.frontSide, n: model.nLeft.n * model.frontSide };
// 날개벽 방향: CAD 각 lineDeg → 진북 = 90 − CAD (시안: 진북 194.68°, CAD 255.32°).
const wingRot = bearingRotDeg(90 - model.wing!.lineDeg, model.u, front);
for (const b of model.bearings) {
expect(Math.abs(b.rotDeg)).toBeGreaterThan(4.3);
expect(Math.abs(b.rotDeg)).toBeLessThan(4.5);
}
const first = model.bearings.find((b) => b.no === 1)!;
expect(Math.abs(first.rotDeg - wingRot)).toBeCloseTo(0.63, 1);
});
it('without the 방위각 the blocks stay on the axis (rotation 0)', () => {
const flat = buildAbut({ ...toAbutInput(rec), bearings: toAbutInput(rec).bearings.map((b) => ({ ...b, azimuthDeg: undefined })) });
expect(flat.bearings.every((b) => b.rotDeg === 0)).toBe(true);
});
it('the 3D block turns with the bearing: its footprint is no longer axis-aligned', () => {
const input = toAbutInput(rec);
const members = buildMembers(model, { bottomEl: input.foundationBottomEl, pileLengthMm: input.pileLengthMm });
const group = buildAbutGroup(model, members, buildWings(model, members));
const block = (group.getObjectByName('blocks') as THREE.Group).children[0] as THREE.Mesh;
const box = new THREE.Box3().setFromObject(block);
// a turned 1.2 m square is wider than 1.2 m along s
expect(box.max.x - box.min.x).toBeGreaterThan(1.2 + 1e-4);
});
it('the ELEVATION table shows the 방위각 (CAD = the workbook value, 진북) and the block turn', () => {
const [r] = abutElevationRows(model, resolveBearingRule(), rec.bearingHeightMm).filter((x) => x.no === 1);
expect(r.azimuthCadDeg).toBeCloseTo(75 + 56 / 60 + 55.9 / 3600, 6);
expect(r.azimuthNorthDeg).toBeCloseTo(90 - (75 + 56 / 60 + 55.9 / 3600), 6);
expect(r.rotDeg).toBeCloseTo(model.bearings.find((b) => b.no === 1)!.rotDeg, 9);
});
});
+29
View File
@@ -0,0 +1,29 @@
import { describe, expect, it } from 'vitest';
import { abutBearingPlanSvg, abutBearingSectionSvg, abutSolplateSvg, type AbutBearingDetail } from '../src/view/abut-bearing-svg';
// 운곡교 A1 받침 ① (교대 받침 표: 블럭 125 = 75 + 50, H 275 → T 25, h3 250, L 1000 × W 1100, T1 27.5 / T2 22.5, 회전 −4.42°).
const d: AbutBearingDetail = {
no: 1, blockMm: 1200, padMm: 1000, L: 1000, W: 1100,
h1: 75, h2: 50, h3: 250, h4: 25, T1: 27.5, T2: 22.5, rotDeg: -4.42, S: -0.5, seatEl: 44.367, z: 44.492,
};
describe('교대 받침 삽도 (시안 ② 교량받침 카드)', () => {
it('평면: block, pad and solplate L × W, turned by the bearing azimuth', () => {
const svg = abutBearingPlanSvg(d);
expect(svg).toContain('솔플 1000 × 1100');
expect(svg).toContain('패드 1000 (점선)');
expect(svg).toContain('rotate(4.420 150 130)');
expect(svg.match(/<rect /g)).toHaveLength(3);
});
it('단면: the four layers and E = h1 + h2 + h3 + h4 = 400', () => {
const svg = abutBearingSectionSvg(d);
for (const s of ['h1 75', 'h2 50', 'h3 250', 'h4 25', 'E = 400', '교좌면 EL.44.367', 'z EL.44.492']) expect(svg).toContain(s);
expect(svg.match(/<rect /g)).toHaveLength(4);
});
it('솔플레이트: L, W, T1 → T2 and the slope', () => {
const svg = abutSolplateSvg(d);
for (const s of ['T1 27.5', 'T2 22.5', 'L 1000 · W 1100', 'S -0.500 %']) expect(svg).toContain(s);
});
});
+99
View File
@@ -0,0 +1,99 @@
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import * as XLSX from 'xlsx';
import { describe, expect, it } from 'vitest';
import { buildAbut } from '../src/core/abut/geometry';
import { parseAbutments, toAbutInput } from '../src/core/abut/parse';
import { buildMembers } from '../src/core/abut/members';
import {
ABUT_BEARING_RULE_DEFAULT,
abutBearingChecks,
abutElevationRows,
abutSolplateRows,
resolveBearingRule,
} from '../src/core/abut/bearing-table';
import { applyBearingRule } from '../src/core/abut/edit';
import { checkStoredAbutments } from '../src/core/project';
import { p1Pairs } from './helpers';
const RAG = fileURLToPath(new URL('../../../RAG/운곡교P1입력.xlsx', import.meta.url));
const rec = parseAbutments(XLSX.read(readFileSync(RAG), { type: 'buffer' })).abutments[0];
const input = toAbutInput(rec);
const model = buildAbut(input);
const members = buildMembers(model, { bottomEl: input.foundationBottomEl, pileLengthMm: input.pileLengthMm });
const rule = resolveBearingRule();
const H = rec.bearingHeightMm;
// Bridge context as in the app: A1 ①–⑤ pair with P1 전열 ①–⑤ (2026-11-06), 교대 종단경사 i from the sheet.
const ctx = { pairs: p1Pairs(), slopeI: rec.slopeI };
describe('교대 받침 카드 (솔플레이트 결정 2026-11-06, h3 = 받침높이 − T_ref)', () => {
it('defaults: A 700 × B 640 + 100, T_ref 30, T_min 22; H 275 from the 교대 sheet → h3 245', () => {
expect(ABUT_BEARING_RULE_DEFAULT).toMatchObject({ tRefMm: 30, tMinMm: 22, marginMm: 100, aMm: 700, bMm: 640 });
expect(H).toBe(275);
const [r] = abutSolplateRows(model, rule, H, ctx);
expect([r.L, r.W, r.T, r.T1, r.T2]).toEqual([800, 740, 30, 38, 22]);
expect(abutElevationRows(model, rule, H, ctx)[0].h3).toBe(245);
});
it('ELEVATION: block 125 = 75 + 50, E = 75 + 50 + 245 + 30 = 400 (the girder underside of the sheet)', () => {
const rows = abutElevationRows(model, rule, H, ctx);
expect(rows).toHaveLength(5);
for (const r of rows) {
expect([r.h1, r.h2, r.h3, r.h4, r.E]).toEqual([75, 50, 245, 30, 400]);
expect(r.solplateTopEl).toBeCloseTo(r.seatEl + 0.4, 6);
}
const first = rows.find((r) => r.no === 1)!;
expect(first.seg).toBe('①');
expect(first.seatEl).toBeCloseTo(44.367, 3);
expect(first.z).toBeCloseTo(44.492, 3);
});
it('솔플레이트 TABLE: S from the P1 pair (−1.908 %, 계산), T1 38 on the 오르막 (A1) side; W08 within 1 mm', () => {
const rows = abutSolplateRows(model, rule, H, ctx);
expect(rows.map((r) => r.S.toFixed(3))).toEqual(['-1.908', '-1.906', '-1.904', '-1.904', '-1.902']);
expect(rows.every((r) => r.source === '계산' && r.pair?.startsWith('P1-'))).toBe(true);
expect(rows[0].check).toBe('16 ≈ 15.3 일치');
expect(rows.every((r) => r.azimuthSec !== undefined && r.azimuthSec <= 0.5)).toBe(true);
expect(abutSolplateRows(model, rule, H, { slopeI: rec.slopeI })[0].azimuthSec).toBeUndefined();
});
it('without a pair the 교대 종단경사 i (−0.0191) is used: S −1.91 % (입력), T 30', () => {
const [r] = abutSolplateRows(model, rule, H, { slopeI: rec.slopeI });
expect(r.source).toBe('입력');
expect(r.S).toBeCloseTo(-1.91, 9);
expect(r.T).toBe(30);
});
it('S typed −3 % → T 34; the block concrete loses 4 mm and E stays 400 (거더 하면 고정)', () => {
const steep = resolveBearingRule({ slopePct: -3 });
expect(abutSolplateRows(model, steep, H, ctx)[0]).toMatchObject({ T: 34, T1: 46, T2: 22, source: '수정됨' });
const r = abutElevationRows(model, steep, H, ctx)[0];
expect([r.h1, r.h4, r.E]).toEqual([71, 34, 400]);
});
it('checks: W02, W04 (흉벽 ≈ 145 · 전면 255), W03 and I01 all clear for A1', () => {
const lines = abutBearingChecks(model, members, rule, H, ctx);
expect(lines).toHaveLength(4);
expect(lines[0]).toContain('W02 없음');
// 시안 145 is bearing ① (lateral 0.745); the smallest gap over the five bearings is 144 (lateral 0.744).
expect(lines[1]).toMatch(/흉벽 14[45]/);
expect(lines[1]).toContain('전면 255');
expect(lines[1]).toContain('W04 없음');
expect(lines[2]).toContain('W03 없음');
expect(lines[3]).toContain('I01 없음');
});
it('the edit keeps only changed values, clears optional ones, refuses non-positive sizes and h3 ≤ 0, and the project file accepts it', () => {
const edited = applyBearingRule(applyBearingRule(rec, 'aMm', 650), 'tMm', 32);
expect(edited.bearingRule).toEqual({ aMm: 650, tMm: 32 });
expect(applyBearingRule(edited, 'tMm', undefined).bearingRule).toEqual({ aMm: 650 });
expect(() => applyBearingRule(rec, 'aMm', 0)).toThrow('0보다');
expect(() => applyBearingRule(rec, 'tRefMm', 300)).toThrow('h3');
expect(() => checkStoredAbutments([edited])).not.toThrow();
expect(() => checkStoredAbutments([{ ...edited, bearingRule: { aMm: 'x' } }])).toThrow('받침 규칙');
});
it('values stored by the 2026-11-03 C안 (lengthMm, widthMm) are ignored', () => {
expect(resolveBearingRule({ lengthMm: 900, widthMm: 1000 } as Record<string, unknown>)).toEqual(ABUT_BEARING_RULE_DEFAULT);
});
});
+152
View File
@@ -0,0 +1,152 @@
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import * as XLSX from 'xlsx';
import * as THREE from 'three';
import { describe, expect, it } from 'vitest';
import { buildAbut } from '../src/core/abut/geometry';
import { parseAbutments, toAbutInput } from '../src/core/abut/parse';
import { buildMembers } from '../src/core/abut/members';
import { buildWings } from '../src/core/abut/wings';
import { buildAbutGroup } from '../src/view/abut-scene';
const RAG = fileURLToPath(new URL('../../../RAG/운곡교P1입력.xlsx', import.meta.url));
const rec = parseAbutments(XLSX.read(readFileSync(RAG), { type: 'buffer' })).abutments[0];
const input = toAbutInput(rec);
const model = buildAbut(input);
const members = buildMembers(model, { bottomEl: input.foundationBottomEl, pileLengthMm: input.pileLengthMm });
const wings = buildWings(model, members);
const group = buildAbutGroup(model, members, wings);
const named = (name: string) => group.getObjectByName(name) as THREE.Group | undefined;
describe('교대 3D: 받침블럭과 접속슬래브 (spec §4.2, §4.8)', () => {
it('has a block and a pad for each of the 5 bearings', () => {
expect(named('blocks')?.children).toHaveLength(2 * model.bearings.length);
});
it('draws the 접속슬래브 as one slab', () => {
expect(named('slab')?.children).toHaveLength(1);
});
it('the slab does not overlap either wing in plan (spec §4.8: 날개벽 안쪽면 + 20)', () => {
// Axis frame: s along IP1 → IP3, t positive to the 전면, as toAxis() in abut-scene.ts.
const axis = (p: { e: number; n: number }) => {
const d = { e: p.e - model.ip1.e, n: p.n - model.ip1.n };
const f = { e: model.nLeft.e * model.frontSide, n: model.nLeft.n * model.frontSide };
return { s: d.e * model.u.e + d.n * model.u.n, t: d.e * f.e + d.n * f.n };
};
const inside = (x: number, y: number, poly: { s: number; t: number }[]) => {
let c = false;
for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) {
if (poly[i].t > y !== poly[j].t > y && x < ((poly[j].s - poly[i].s) * (y - poly[i].t)) / (poly[j].t - poly[i].t) + poly[i].s) c = !c;
}
return c;
};
const pos = (named('slab') as THREE.Group).children[0] as THREE.Mesh;
const attr = pos.geometry.getAttribute('position');
const polys = wings.map((w) => w.corners.map(axis));
for (let i = 0; i < attr.count; i++) {
for (const poly of polys) expect(inside(attr.getX(i), attr.getZ(i), poly)).toBe(false);
}
});
it('the slab reaches 7 m behind the 흉벽 back face and is 450 thick', () => {
const box = new THREE.Box3().setFromObject(named('slab') as THREE.Group);
expect(box.min.z).toBeCloseTo(members.shape.parapetBackM - 0.005 - 7, 2);
expect(box.max.y - box.min.y).toBeGreaterThan(0.45);
});
it('the 흉벽 and the 벽체 only touch (no shared volume) and the back ledge is vertical-step free', () => {
const volume = (a: THREE.Object3D, b: THREE.Object3D) => {
const box = new THREE.Box3().setFromObject(a).intersect(new THREE.Box3().setFromObject(b));
return Math.max(0, box.max.x - box.min.x) * Math.max(0, box.max.y - box.min.y) * Math.max(0, box.max.z - box.min.z);
};
// Same 교좌면 step only: the skewed prisms of neighbouring steps share a face, and their bounding boxes overlap slightly.
const walls = (named('wall') as THREE.Group).children;
const parapets = (named('parapet') as THREE.Group).children;
walls.forEach((w, k) => {
expect(volume(w, parapets[2 * k])).toBeLessThan(1e-9);
expect(volume(w, parapets[2 * k + 1])).toBeLessThan(1e-9);
});
// back face: the ledge (t −0.8 … −0.5) and the 흉벽 back (−0.5) meet at one horizontal edge, not a vertical offset
const backs = (named('wall') as THREE.Group).children.map((w) => new THREE.Box3().setFromObject(w).min.z);
expect(Math.min(...backs)).toBeCloseTo(members.shape.wallBackM, 6);
});
it('the 날개벽 meets the 흉벽 back face above the 브라켓 and the 벽체 back face below it, and never enters them', () => {
// All 벽체 and 흉벽 parts lie at t ≥ 벽체 배면 (−0.8). A wing vertex with t ≤ −0.8 therefore has no volume in common with them.
const z = (g: THREE.Object3D) => {
const pos = (g as THREE.Mesh).geometry.getAttribute('position');
let max = -Infinity;
for (let i = 0; i < pos.count; i++) max = Math.max(max, pos.getZ(i));
return max;
};
// Upper part (above the 브라켓 상면) meets the 흉벽 back face (−0.5); the lower part meets the 벽체 back face (−0.8).
const backTop = (s: number) => members.parapetTopStartEl + ((members.parapetTopEndEl - members.parapetTopStartEl) * s) / model.length - members.shape.bracketHeightM;
for (const w of (named('wings') as THREE.Group).children) {
expect(z(w)).toBeLessThanOrEqual(members.shape.parapetBackM + 1e-6);
const pos = (w as THREE.Mesh).geometry.getAttribute('position');
for (let i = 0; i < pos.count; i++) {
if (pos.getZ(i) > members.shape.wallBackM + 1e-6) expect(pos.getY(i)).toBeGreaterThanOrEqual(backTop(pos.getX(i)) - 1e-6);
}
}
for (const w of (named('wall') as THREE.Group).children) expect(new THREE.Box3().setFromObject(w).min.z).toBeGreaterThanOrEqual(members.shape.wallBackM - 1e-6);
});
it('the upper 헌치 and the 접속슬래브 stay on the 브라켓 상면 over the back strip, not in it', () => {
const backTop = (s: number) => members.parapetTopStartEl + ((members.parapetTopEndEl - members.parapetTopStartEl) * s) / model.length - members.shape.bracketHeightM;
for (const h of (named('haunches') as THREE.Group).children) {
const b = new THREE.Box3().setFromObject(h);
if (b.max.z < members.shape.parapetBackM + 1e-6) continue; // lower 헌치, behind the 벽체
expect(b.min.y).toBeGreaterThanOrEqual(Math.max(backTop(b.min.x), backTop(b.max.x)) - 1e-6);
}
const pos = ((named('slab') as THREE.Group).children[0] as THREE.Mesh).geometry.getAttribute('position');
for (let i = 0; i < pos.count; i++) {
if (pos.getZ(i) >= members.shape.wallBackM) expect(pos.getY(i)).toBeGreaterThanOrEqual(backTop(pos.getX(i)) - 1e-6);
}
});
it('the 헌치 sits on the 날개벽 내측면 (the face between the two wings), on the centre side, and does not enter the wing', () => {
const axis = (p: { e: number; n: number }) => {
const d = { e: p.e - model.ip1.e, n: p.n - model.ip1.n };
const f = { e: model.nLeft.e * model.frontSide, n: model.nLeft.n * model.frontSide };
return { s: d.e * model.u.e + d.n * model.u.n, t: d.e * f.e + d.n * f.n };
};
// Inner face of each wing: s = sEnd + a·0.45 + (ws/wt)·t (a = +1 at IP1, −1 at IP3). Haunch vertices must lie on the centre side of it.
const inner = wings.map((w) => {
const p0 = axis(w.corners[0]);
const p1 = axis(w.corners[1]);
const a = w.sEnd === 0 ? 1 : -1;
return { sEnd: w.sEnd, a, k: (p1.s - p0.s) / (p1.t - p0.t), tRoot: p0.t };
});
for (const h of (named('haunches') as THREE.Group).children) {
const pos = (h as THREE.Mesh).geometry.getAttribute('position');
for (let i = 0; i < pos.count; i++) {
const x = pos.getX(i);
const z = pos.getZ(i);
const w = x < model.length / 2 ? inner[0] : inner[1];
const sIn = w.sEnd + w.a * 0.45 + w.k * (z - w.tRoot);
expect(w.a * (x - sIn)).toBeGreaterThanOrEqual(-1e-6);
}
}
});
it('the 저판 plan is a skew parallelogram: its back corners sit on the wing outer lines, so the 날개벽 sits flush on the 저판 edge', () => {
const axis = (p: { e: number; n: number }) => {
const d = { e: p.e - model.ip1.e, n: p.n - model.ip1.n };
const f = { e: model.nLeft.e * model.frontSide, n: model.nLeft.n * model.frontSide };
return { s: d.e * model.u.e + d.n * model.u.n, t: d.e * f.e + d.n * f.n };
};
const p0 = axis(wings[0].corners[0]);
const p1 = axis(wings[0].corners[1]);
const k = (p1.s - p0.s) / (p1.t - p0.t); // s per m of t along the wing
const pos = ((named('footing') as THREE.Group).children[0] as THREE.Mesh).geometry.getAttribute('position');
const back = [...Array(pos.count).keys()].map((i) => ({ x: pos.getX(i), z: pos.getZ(i) })).filter((p) => Math.abs(p.z - members.shape.footingBackM) < 1e-6);
expect(back.length).toBeGreaterThanOrEqual(2);
for (const p of back) {
const onLeft = Math.abs(p.x - k * p.z) < 1e-6;
const onRight = Math.abs(p.x - (model.length + k * p.z)) < 1e-6;
expect(onLeft || onRight).toBe(true);
}
});
});
Loaded 100 of 182 files, more files were not shown because too many files have changed in this diff. Show more