Compare commits

..
137 Commits
Author SHA1 Message Date
ypshimandClaude Haiku 5.5 50bdc3d691 chore(landing): 사용 안내의 패널 이름을 아카이브로
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-11-10 17:57:49 +09:00
ypshimandClaude Haiku 5.5 fc3cc3b018 chore(landing): 랜딩 문구 변경 — 설계 플랫폼 · 교각·교대 아카이브
- 첫 화면 부제: 교량 하부구조 3D 통합모델링 → 교량 하부구조 설계 플랫폼
- 프로젝트 탐색 패널 제목: 교각·교대 설계 라이브러리 → 교각·교대 아카이브

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-11-10 17:55:37 +09:00
ypshimandClaude Haiku 5.5 ac646d554c feat(shape): 꺾임점에서 겹치는 기초를 하나의 꺾인 기초로 합친다
꺾임을 넘는 기초를 합치지 않아, 기둥 간격이 기초 길이보다 짧은 꺾인 교각(산외교 P11: 2.75 m)에서 두 기초가
겹치고(콘크리트 쐐기 틈, 철근 겹침, 수량 이중 계산) 말뚝도 겹쳤다. 꺾임 전후 기초가 겹치거나 미터 퍼짐 안에서
맞닿으면 하나의 꺾인 기초로 본다:
- 형상: 구간별 조각 둘(bent), 각 조각을 미터 퍼짐 + 10 mm 너머로 늘려 미터면에서 틈 없이 잇는다
- 말뚝: 편 길이에 한 번 배치하고 꺾임점 기준으로 구간을 나눈다
- 철근: 편 좌표의 한 기초(footing-units.ts)로 철근표와 3D가 같이 계산, 3D는 미터점에서 접는다

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-11-10 17:46:47 +09:00
ypshimandClaude Haiku 5.5 ed84761449 fix(rebar): 확대기초 철근 — 구간별 3D 위치, 기둥 소속, 길이별 행, 2조각 수량
- 3D: 확대기초 철근을 늘 구간 0에 그려, 꺾인 교각의 구간 1 기초(산외교 P11 ③)는 철근이 없고 구간 0 기초에 겹쳐 그려졌다
- 기초에 속한 기둥을 같은 구간 안에서만 찾는다 (B9 기둥 구역이 다른 구간 기둥으로 잘못 빠지던 것)
- 길이가 다른 기초의 철근을 가장 긴 길이 하나로 합치지 않고 길이별 조각으로 둔다
- 정척 초과 2조각 이음 행이 철근 한 개당 한 조각만 중량에 넣던 것을 두 조각으로 (P1 B6 31개 → 62조각)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-11-10 17:28:18 +09:00
ypshimandClaude Haiku 5.5 33aa2d9a61 feat(rebar): 전·후열 단차 300 mm 이상 블록 보강 C17·C18
전열·후열 상면 차가 300 mm 이상인 구간의 높은 쪽 블록에 (결정 2026-11-10, step-bars.ts):
- C17 ㄷ형 띠 H16 @전단간격: 단차면 + c ~ 측면 − c, 위 = 높은 상면 − c, 다리 낮은 상면 아래 l_d까지
- C18 수평 피부철근 H16 @측면간격: 단차면 안쪽, 낮은 상면 + 측면간격마다
T형(철근표·3D)과 다주형(편 좌표 → 꺾임 접기) 모두에 넣는다. 구간마다 따로 두고 이웃 구간과 잇지 않는다 (가정).

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-11-10 17:17:23 +09:00
ypshimandClaude Haiku 5.5 8f808f453e feat(view): 꺾인 코핑의 코핑 철근을 3D로 그린다
꺾인 코핑은 코핑 철근을 그리지 않았다. 다주형은 접힌 조각(pieces)을 각 구간 좌표로 그리고, T형은 같은
접기(coping-fold.ts)로 편 좌표 철근을 미터점에서 꺾어 그린다.

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-11-10 17:12:55 +09:00
ypshimandClaude Haiku 5.5 6a15a13fc7 feat(rebar): 꺾인 코핑 철근을 꺾임에 맞춰 접는다 (교축 꺾은 철근 · 미터 전단 1조)
편 좌표(g)로 계산한 다주형 코핑 철근을 꺾인 코핑에 맞춘다 (coping-fold.ts):
- 교축 방향 철근은 그 n에서 미터면과 만나는 점에서 꺾은 한 개, 길이는 실제 경로 (바깥 길고 안쪽 짧음)
- 교축 직각 철근(전단·크로스타이)은 미터 쐐기에 걸리면 빼고, 꺾임점에 가장 가까운 전단 위치 1조를 미터 방향으로
- 상면 1단 이음은 꺾임점에서 l_s/2 + 미터 퍼짐 밖으로
조각(pieces)은 구간별 국부 좌표로 담아 3D가 그대로 그릴 수 있게 한다. 곧은 코핑은 바뀌지 않는다.

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-11-10 17:09:27 +09:00
ypshimandClaude Haiku 5.5 51209cccf9 fix(rebar): 꺾인 다주형 코핑의 철근표를 다주형 규칙으로 낸다
꺾인 코핑이면 기둥 수와 관계없이 T형(기둥 1개) 코핑 행을 내어 산외교 P11(3주형)의 전단철근 등 수량이
틀렸다. 기둥이 1개일 때만 T형 행, 2개 이상이면 꺾여도 다주형 행(편 좌표 계산)을 쓴다.

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-11-10 17:06:10 +09:00
ypshimandClaude Haiku 5.5 0b3087e752 fix(shape): 받침 번호가 축 반대 방향이어도 계단을 g 순서로 만든다
전·후열 받침 수가 같으면 받침을 번호 순서로 두어, 번호가 IP1 → IP3 반대로 매겨진 교각(산외교 P9: ① g 12.1,
③ g 1.3)에서 계단 경계가 거꾸로(9.42 → 4.02) 놓이고 코핑 상면이 틀렸다. 받침을 늘 g 순서로 정렬한다.

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-11-10 16:55:22 +09:00
ypshimandClaude Haiku 5.5 7340f8c1a1 feat(rebar): 계단부 C15·C16을 모델의 실제 계단으로 만든다 (T형·다주형 공통)
T형은 P1 문서의 고정 위치(2.196~12.000 m)·높이·11개를 써서 코핑 길이가 다르면 철근이 코핑 밖으로 나갔고,
다주형은 높은 계단 전체에 C15 하나만 두었다. 공통 규칙 (step-bars.ts):
- 열마다(전·후열 단차가 있으면 각 쪽) 최저 상면 + h_16(100) 이상 구간이 계단, 경계는 mm 반올림
- 이웃보다 모두 높은 구간 C15(⊓), 나머지 계단 C16(L형, 낮은 쪽 다리, 높은 쪽으로 l_d 연장)
- 폭 방향: T형은 주철근 내부 격자 하나 건너, 다주형은 상면 주철근 하나 건너
- 양쪽이 높은 계단은 RB-S01 알림, RB-M04 폐지. 꺾임은 편 좌표의 한 개(꺾은 철근)
P1 문서값(C15 11 × 3852, C16 22 × 4070/4069, 137.5 · 10@250)을 그대로 재현한다.

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-11-10 16:53:15 +09:00
ypshimandClaude Haiku 5.5 9f336e2be4 fix(shape): 꺾임점 계단면을 미터면과 일치시킨다
계단 경계를 꺾임점에 맞춰도 각 구간의 계단면은 자기 축에 직각이고 미터면은 θ/2 기울어, 코핑 폭 안에서
±(B/2)·tan(θ/2) (P11 ±0.12 m)의 삼각형 조각이 남았다. 꺾임점의 계단면을 각 구간 프로파일에서 미터 퍼짐 + 10 mm만큼
잘려 나가는 쪽으로 옮겨, 구간 0은 앞 상면을, 구간 1은 뒤 상면을 미터면까지 가져가게 한다. 3D와 도면 투영이
모두 미터면으로 자르므로 둘 다 반영된다. 퍼짐이 블럭 반폭 이상이면 바꾸지 않는다.

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-11-10 15:57:20 +09:00
ypshimandClaude Haiku 5.5 a39a1ba8d2 fix(shape): 전·후열 나눔선을 교각 전체에서 하나로 둔다
횡방향 단차 코핑의 전·후열 나눔선을 구간마다 계산해, 꺾인 교각에서 두 구간의 판이 꺾임점에서 옆으로
어긋났다 (산외교 P11: −0.05 / +0.11). 두 열 전체 받침의 평균 n 하나를 쓰고, 어느 받침이든 그 선에서 블럭
반폭(0.6 m)보다 가까우면 지금처럼 구간별 선을 쓴다.

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-11-10 15:51:10 +09:00
ypshimandClaude Haiku 5.5 9777938aa8 fix(shape): 꺾임점 근처 계단 경계를 꺾임점에 맞춘다
계단 경계가 꺾임점에서 블럭 반폭(0.6 m) 이내이면 경계를 꺾임점으로 옮겨, 계단면이 미터면과 같아져 두 구간이
쐐기 틈 없이 만난다 (산외교 P11 후열 ⑥|⑦: 15.07 → 15.14). 양쪽 받침이 꺾임점에서 블럭 반폭 이상 떨어질 때만 옮긴다.

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-11-10 15:48:12 +09:00
ypshimandClaude Haiku 5.5 bd84ac6176 fix(shape): 후열 계단 경계를 후열 받침 사이 중앙에 둔다
전·후열 받침 수가 같으면 후열 계단 경계를 전열 경계로 썼기 때문에, 후열 받침이 전열과 마주 보지 않는 교각
(산외교 P11 ⑥|⑦: 경계 12.75, 받침 11.64 · 18.49)에서 경계가 중앙에서 벗어났다. 후열도 자기 받침 사이 중앙을 쓰되,
전열 경계와 블럭 반폭(0.6 m) 이내로 가까우면 전열 경계에 맞춘다 (P1처럼 약간 엇갈린 열에 얇은 단이 생기지 않게).

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-11-10 15:38:30 +09:00
ypshimandClaude Haiku 5.5 0fb39076b8 fix(shape): 횡방향 단차 코핑을 실제 전열·후열 쪽에 나눈다
코핑 두 쪽(전열 상면 · 후열 상면)을 늘 축의 (−) 쪽 = 전열로 두어, 전열이 (+) 쪽에 있는 교각(P1, 산외교 P11)에서는
전열 블럭이 후열 상면에 묻히고 후열 블럭이 떠 있었다. 각 쪽을 그 열 받침의 평균 n 쪽에 둔다 (순서는 [전열, 후열] 유지).
기존 테스트가 틀린 쪽을 기대하던 것을 고치고, 전열이 어느 쪽이든 블럭이 아래 코핑 상면에 놓이는지 검사한다.

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-11-10 15:30:43 +09:00
ypshimandClaude Haiku 5.5 b84288c48a feat(check): 받침블럭 콘크리트 최소 높이 W11 (63 mm, 제안)
교각 받침블럭마다 콘크리트 높이(패드 제외)가 0보다 크고 63 mm(피복 50 + D13, 교대 I01과 같은 기준)보다
낮으면 W11 경고를 낸다. 교대는 기존 I01(블럭 철근을 교좌면 아래로)이 같은 경우를 다룬다.

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-11-10 15:14:30 +09:00
ypshimandClaude Haiku 5.5 d7a61df7a9 feat(bearing): 받침별 받침높이로 h3를 정한다 (교각·교대, AP07 폐지)
받침 시트의 받침높이_mm(9열)를 받침마다 읽어 h3 = H − T_ref로 쓴다. 거더 하면 = z + H가 받침마다 지켜진다.
솔플레이트 입력에 h3를 적으면 그 값이 모든 받침에 우선한다. 받침높이가 없으면 기존 기본값(교각 181, 교대 교대 값 − T_ref).
교대는 받침마다 달라도 받침별 값을 쓰므로 AP07(첫 값 사용) 경고를 없앤다.
교각 받침높이가 T_ref 이하이면 S01(E). 입력 양식 받침 표에 받침높이_mm 열을 추가한다.

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-11-10 15:11:25 +09:00
ypshimandClaude Haiku 5.5 8aedc9b969 feat(shape): 교좌면 EL로 받침별 코핑 상면 계단을 만든다 (W10은 정보)
입력 교좌면 EL이 있으면 그 받침 구간의 코핑 상면 = 교좌면 EL (없으면 z − h1 / z − h2).
받침블럭 바닥과 코핑 상면이 항상 맞아 블럭이 뜨거나 묻히지 않는다. seatCalc는 기울기 계산값으로 남겨
W10 비교 기준으로 쓰고, W10은 경고(W)에서 정보(I)로 바꾼다.

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-11-10 15:07:19 +09:00
ypshim 4ef8412d89 fix(ground): 측량 레이어로 운곡교 지형 추출 대상 제한 2026-11-10 09:54:34 +09:00
ypshimandClaude Haiku 5.5 38854cbe4c feat(landing): '기설계교량 라이브러리' 카드를 '설계 DATA 아카이브'·'둘러보기'로 변경
카드 제목·버튼 문구와 팝업/닫기 접근성 이름을 함께 변경.

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-11-09 18:01:26 +09:00
ypshimandClaude Haiku 5.5 7d36c4b64a feat(view): ②·③ 모델과 입력창/철근표를 상하·좌우로 전환하고 분할선으로 크기 조절
- 중앙 도구줄 '상하 | 좌우' 전환(② 도구줄 신설), 분할선 드래그·키보드(←→↑↓, Shift×5, Home/End)·더블클릭 기본값
- 상하: 모델 높이 180–560 px, 좌우: 모델 폭 30–70 %(모델 왼쪽), 모델 접기 시 창 전체 사용
- 중앙 열 900 px 미만·1100 px 이하 화면은 자동 상하 + 안내, 넓어지면 복귀
- 단계별 표시 설정을 localStorage에 저장(기존 pierzainer.review-layout 이관), 프로젝트 파일 미포함
- 오른쪽 창 묶음(.workspace-pane) 도입에 맞춰 기존 검증 스크립트 선택자 갱신
- 단위시험 4개, verify-workspace-layout.cjs Chrome 27개 검사

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-11-09 18:01:20 +09:00
ypshimandClaude Haiku 5.5 ef596650cc feat(landing): 소개 영상 버튼과 재생 모달 추가
- 사용 가이드 옆 '▶ 소개 영상 1분' 버튼, 네이티브 dialog에서 재생
- 자체 호스팅 mp4(56초·1920×1080·H.264, 무음), 열 때만 로드(preload none), 무음 자동재생
- Esc·배경·닫기로 종료 시 일시정지·처음으로·포커스 복귀, 모션 감소 시 자동재생 안 함
- 자막 원본(.srt)은 보관만, 화면 미연결
- verify-intro-video.cjs: Chrome 17개 검사

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-11-09 18:01:12 +09:00
ypshimandClaude Haiku 5.5 fc520ec03d feat: 프로젝트 공통 지반모델·교대 지반 통합·재료표/수량 진입·옥성교 랜딩 히어로 (11-06~11-09 작업분)
공용 파일(main.ts·index.html·style.css)에서 서로 얽혀 있어 한 커밋으로 묶음.
- 지반: ① 형상 프로젝트 지반모델 관리창(생성·JSON 불러오기·교각별 비교·선택 적용),
  프로젝트 JSON 내장 groundModel 추출, 교대 8행 통합 적용·공통지형 중복격자 제외
- 재료표·수량: 상단 ⑤ 메뉴·배근 바로가기, 결과 교각/탭 기억, 수량 워크북·정합 보정
- 랜딩: 옥성교 6교각 히어로 이미지·투영 강조점·좌→우 광선·지형 라운드/도로 연결
- 검증 스크립트(verify-project-ground/ground-abutments/okseong-user-files/quantity-*/landing-hero 등)
정본: DOC/04_실행/실행_*_2026-11-09_v0.1.00.md

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-11-09 18:01:02 +09:00
ypshimandClaude Haiku 5.5 b2bebe47eb fix(ground): 지형이 아닌 일정 EL 객체(기호·윤곽선)를 높이 정합성으로 제외
- reject_height_outliers: 반경 15 m(부족 시 45 m) 내 서로 다른 높이 값의 중앙값과 비교
- |편차| > 10 m 이고 주변 높이 범위 밖으로 5 m 초과 시에만 제외, 최악 높이 묶음부터 반복
- 1 mm 이내 높이는 같은 묶음(CAD 부동소수 오차), 제외 수는 rejected_height_outliers로 기록
- 운곡교: EL 5.624(306)·-112.719(16)만 제외, 격자 최저 -85.98→21.50 m, 8 m 초과 튐 88→1
- 산외: -22.21·127.4 제외, 봉우리 후보 87.35 유지

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-11-09 17:15:39 +09:00
ypshimandClaude Haiku 5.5 d740406a8c fix(ground): WSL에서 지반 생성 Python·DWG 변환 실행 경로 해결
- 공통 resolvePython: Windows venv → .venv/bin/python → PIERZAINER_PYTHON → python3/python
- 지반 생성·수량산출 Excel·DWG 출력 서버가 같은 해석기 사용, ENOENT 안내 문구 보강
- WSL에서 번들 dwg2dxf.exe 호출 시 wslpath -w로 경로 변환, .exe 실행 권한 부여
- 실제 운곡교 DWG+Excel로 엔진/HTTP 경로 생성 확인(교각16·지형6,545점)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-11-09 14:03:16 +09:00
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
260 changed files with 23712 additions and 1982 deletions

No files matched your search

+1
View File
@@ -12,5 +12,6 @@ __pycache__/
*.zip *.zip
pierzainer_ground/data/cache/ pierzainer_ground/data/cache/
pierzainer_ground/data/output/**/cache/ pierzainer_ground/data/output/**/cache/
backup/
verify/ verify/
도면추출/output/ 도면추출/output/
+33 -2
View File
@@ -1,6 +1,14 @@
# PIERZAINER · PIER & ABUT # 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개 - `랜딩페이지/`: 랜딩 화면과 지도 포함 통합 샘플 12개
- `pierzainer_ground/`: 지반 생성 프로그램, 원본 자료, 최종 지반 JSON 및 재현에 필요한 데이터 - `pierzainer_ground/`: 지반 생성 프로그램, 원본 자료, 최종 지반 JSON 및 재현에 필요한 데이터
- `도면추출/`: 도면 추출 프로그램과 교각 샘플 Excel (기본 P1 샘플 포함) - `도면추출/`: 도면 추출 프로그램과 교각 샘플 Excel (기본 P1 샘플 포함)
- `tests/`: 현재 교각·Excel·지도 기능의 자동 검사 - `tests/`, `scripts/`, `verify/`: 현재 교각·Excel·지도 기능의 자동 검사와 유지보수 도구
`node_modules/`, `dist/`, `.verify/`, 지반 `cache/`는 실행·검증 중 재생성되는 파일이며 Git에 추가하지 않습니다. `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/`에 있습니다. 실행에 필요한 코드·원본 도면·샘플 Excel·최종 지반 JSON은 유지합니다. 과거 개발용 도구와 옛 좌표 기준의 검증 파일은 정리했으며 백업 목록은 상위 `_recovery/20261103_cleanup/`에 있습니다.
+108 -28
View File
@@ -11,22 +11,27 @@
<header class="top"> <header class="top">
<div class="brand"> <div class="brand">
<strong>Pierzainer</strong> <strong>Pierzainer</strong>
<span>교각 설계</span>
<span id="project-line" class="proj">프로젝트 미지정</span> <span id="project-line" class="proj">프로젝트 미지정</span>
</div> </div>
<nav class="steps" aria-label="단계"> <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-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" type="button">② 형상계획 <em id="step2-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" type="button">③ 철근배근 <em id="step3-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" type="button">④ 도면작성 <em id="step-drawing-status">형상 필요</em></button> <button id="nav-drawing" class="step" data-label="④ 도면작성" aria-label="④ 도면작성" type="button">④ 도면 <em id="step-drawing-status"></em></button>
<button id="nav-quantity" class="step" type="button">⑤ 재료표 <em id="step4-status">예정</em></button> <button id="nav-quantity" class="step" aria-label="⑤ 재료표·수량산출" type="button">⑤ 재료표·수량</button>
</nav> </nav>
<div class="actions"> <div class="actions">
<span id="save-status" class="savest">저장 전</span> <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="save-project" class="btn-s" type="button" disabled title="프로젝트 파일 저장 (Ctrl+S)">저장</button>
<button id="open-project" class="btn-s" type="button">프로젝트 열기</button> <details id="file-menu" class="file-menu">
<button id="regen" class="btn-p" type="button" disabled>선택 교각 형상 생성</button> <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 /> <input id="project-file" type="file" accept=".json" hidden />
</div> </div>
</header> </header>
@@ -43,7 +48,9 @@
<main class="grid" id="view-shape"> <main class="grid" id="view-shape">
<aside class="panel piers" aria-labelledby="pier-title"> <aside class="panel piers" aria-labelledby="pier-title">
<p id="pier-title" class="lbl">교각 목록</p> <button id="project-ground-open" class="btn-o" type="button">프로젝트 지반모델</button>
<p id="project-ground-status" class="pnote">모델 없음</p>
<p id="pier-title" class="lbl">교각·교대 목록</p>
<div class="pier-tools"> <div class="pier-tools">
<label>형식 <label>형식
<select id="type-filter"></select> <select id="type-filter"></select>
@@ -54,7 +61,7 @@
<p id="pier-empty" class="muted">불러온 교각이 없습니다.</p> <p id="pier-empty" class="muted">불러온 교각이 없습니다.</p>
<p class="pnote">시트 1장 = 교각 1기입니다. 오류가 난 교각만 건너뛰고 나머지는 생성합니다.</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> <p id="input-title" class="lbl">입력값</p>
<div class="pier-table-wrap"> <div class="pier-table-wrap">
<table id="inputs-all" class="grid-table compact"> <table id="inputs-all" class="grid-table compact">
@@ -71,6 +78,27 @@
<button id="edit-regen" class="btn primary" type="button" disabled>다시 생성 · 변경 0</button> <button id="edit-regen" class="btn primary" type="button" disabled>다시 생성 · 변경 0</button>
</section> </section>
</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> </aside>
<section class="panel viewer" id="viewer-block" aria-labelledby="viewer-title"> <section class="panel viewer" id="viewer-block" aria-labelledby="viewer-title">
@@ -159,15 +187,44 @@
<main class="grid plan" id="view-plan" hidden> <main class="grid plan" id="view-plan" hidden>
<aside class="panel piers" aria-labelledby="plan-pier-title"> <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> <div class="phead" aria-hidden="true"><span>교각</span><span>변경</span></div>
<ul id="plan-pier-items" class="pier-items"></ul> <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> <p class="lbl members-title">부재 · 5개</p>
<ul id="members" class="members"></ul> <ul id="members" class="members"></ul>
</aside> </aside>
<div class="center-col"> <div class="center-col workspace" id="plan-workspace" data-stage="plan">
<div class="workspace-tools" aria-label="계획 화면 배치 조절">
<div class="layout-switch" role="group" aria-label="모델과 입력창 배치">
<button type="button" class="btn small" data-layout="stack" aria-pressed="true">상하</button>
<button type="button" class="btn small" data-layout="side" aria-pressed="false">좌우</button>
</div>
<label class="model-height-control" for="plan-model-height"><span data-size-label>모델 높이</span>
<input id="plan-model-height" type="range" min="180" max="560" step="20" value="400" />
<output id="plan-model-height-value" for="plan-model-height">400 px</output>
</label>
<button id="plan-model-toggle" class="btn small" type="button" aria-expanded="true" aria-controls="slot-plan">모델 접기</button>
<span class="layout-note muted small" aria-live="polite"></span>
</div>
<div class="viewer-slot" id="slot-plan"></div> <div class="viewer-slot" id="slot-plan"></div>
<div class="workspace-splitter" role="separator" tabindex="0" aria-controls="slot-plan" aria-label="모델 크기 조절"></div>
<div class="workspace-pane">
<!-- ② 교대 부재 카드 (시안, 2026-11-03): the card of the row picked in 부재 · 교대, under the 3D view. -->
<section id="abut-panel-plan" class="panel viewer abut-card" hidden aria-labelledby="abut-panel-plan-title">
<div class="viewer-head">
<h2 id="abut-panel-plan-title"><span id="abut-panel-plan-member">교량받침 · 받침블럭 · 솔플레이트</span> <span class="muted small" id="abut-panel-plan-name"></span></h2>
</div>
<div id="abut-panel-plan-bearings" class="edit-row"></div>
<div id="abut-panel-plan-wing" class="edit-row"></div>
<div id="abut-panel-plan-slab" class="edit-row"></div>
<div id="abut-panel-plan-ground" class="edit-row"></div>
<div id="abut-panel-plan-shape" class="edit-row abut-shape-view"></div>
<div id="abut-panel-plan-footing" class="edit-row abut-shape-view"></div>
<p id="abut-panel-plan-msg" class="muted" aria-live="polite"></p>
</section>
<section class="panel viewer" aria-labelledby="member-title"> <section class="panel viewer" aria-labelledby="member-title">
<div class="viewer-head"> <div class="viewer-head">
<h2 id="member-title">코핑</h2> <h2 id="member-title">코핑</h2>
@@ -217,6 +274,7 @@
</footer> </footer>
</section> </section>
</section> </section>
</div>
</div> </div>
<aside class="panel results" aria-labelledby="changes-title"> <aside class="panel results" aria-labelledby="changes-title">
@@ -238,6 +296,7 @@
</aside> </aside>
<section class="panel viewer" aria-labelledby="quantity-title"> <section class="panel viewer" aria-labelledby="quantity-title">
<div class="viewer-head"> <div class="viewer-head">
<button id="quantity-back" class="btn-o" type="button">← ③ 철근배근</button>
<h2 id="quantity-title">철근재료표</h2> <h2 id="quantity-title">철근재료표</h2>
<button id="quantity-export" class="btn-o" type="button">엑셀로 내보내기</button> <button id="quantity-export" class="btn-o" type="button">엑셀로 내보내기</button>
<button id="quantity-print" class="btn-o" type="button">인쇄</button> <button id="quantity-print" class="btn-o" type="button">인쇄</button>
@@ -254,35 +313,54 @@
<ul id="rebar-tabs" class="members"></ul> <ul id="rebar-tabs" class="members"></ul>
<p class="pnote">형상은 ② 형상계획 기준입니다. 형상이 바뀌면 "형상 바뀜"으로 표시합니다.</p> <p class="pnote">형상은 ② 형상계획 기준입니다. 형상이 바뀌면 "형상 바뀜"으로 표시합니다.</p>
</aside> </aside>
<div class="center-col"> <div class="center-col workspace review-workspace" id="review-workspace" data-stage="rebar">
<div class="workspace-tools" aria-label="검토 공간 조절">
<div class="layout-switch" role="group" aria-label="모델과 철근표 배치">
<button type="button" class="btn small" data-layout="stack" aria-pressed="true">상하</button>
<button type="button" class="btn small" data-layout="side" aria-pressed="false">좌우</button>
</div>
<label class="model-height-control" for="review-model-height"><span data-size-label>모델 높이</span>
<input id="review-model-height" type="range" min="180" max="560" step="20" value="240" />
<output id="review-model-height-value" for="review-model-height">240 px</output>
</label>
<button id="review-model-toggle" class="btn small" type="button" aria-expanded="true" aria-controls="slot-rebar">모델 접기</button>
<button id="review-inputs" class="btn small" type="button">입력 보기</button>
<button id="review-table" class="btn small" type="button">철근표 보기</button>
<span class="layout-note muted small" aria-live="polite"></span>
</div>
<div class="viewer-slot" id="slot-rebar"></div> <div class="viewer-slot" id="slot-rebar"></div>
<div class="workspace-splitter" role="separator" tabindex="0" aria-controls="slot-rebar" aria-label="모델 크기 조절"></div>
<div class="workspace-pane">
<section class="panel viewer" aria-labelledby="rebar-title"> <section class="panel viewer" aria-labelledby="rebar-title">
<div class="viewer-head"><h2 id="rebar-title">철근표</h2></div> <div class="viewer-head"><h2 id="rebar-title">철근표</h2><button id="rebar-materials" class="btn-o" type="button">재료표·수량산출 보기</button></div>
<p id="rebar-note" class="num"></p> <p id="rebar-note" class="num"></p>
<div id="rebar-table"></div> <div id="rebar-table"></div>
</section> </section>
</div>
</div> </div>
<aside class="panel results" aria-labelledby="rebar-issues-title"> <aside class="panel results" aria-labelledby="rebar-issues-title">
<h2 id="rebar-issues-title">알림</h2> <h2 id="rebar-issues-title">알림</h2>
<ul id="rebar-issues" class="issues"></ul> <ul id="rebar-issues" class="issues"></ul>
<h2>검토 필요 (결정 대기)</h2> <h2>부재 검토</h2>
<ul class="issues"> <p id="review-summary" class="muted small" aria-live="polite"></p>
<li>Q-B5 B1·B3·B5·B7 다리 길이 (제안값 사용)</li> <ul id="review-tasks" class="issues review-tasks"></ul>
<li>Q-B6 B2·B6 이음 위치와 길이</li>
<li>Q-C9 P4–B11 간섭: 무시로 결정, 문서 반영 필요</li>
</ul>
<section class="common" aria-labelledby="common-title"> <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">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">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">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">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> <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">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">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">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> <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> <p id="mat-note" class="muted small"></p>
</section> </section>
</aside> </aside>
@@ -355,20 +433,22 @@
<div class="dlg-col"> <div class="dlg-col">
<section> <section>
<h3>미리보기</h3> <h3>미리보기</h3>
<p id="preview-title" class="muted"></p> <p id="preview-title" class="muted" aria-live="polite"></p>
<h4>교각 정보</h4> <h4>교각 정보</h4>
<div class="pv-scroll"><table id="pv-pier" class="grid-table"></table></div> <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> <h4>받침</h4>
<div class="pv-scroll"> <div class="pv-scroll">
<table id="pv-bearings" class="grid-table"> <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> <tbody></tbody>
</table> </table>
</div> </div>
<h4>IP</h4> <h4>IP</h4>
<div class="pv-scroll"> <div class="pv-scroll">
<table id="pv-ips" class="grid-table"> <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> <tbody></tbody>
</table> </table>
</div> </div>
@@ -377,9 +457,9 @@
</div> </div>
<footer class="dlg-foot"> <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-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> </footer>
</dialog> </dialog>
+3 -2
View File
@@ -4,6 +4,7 @@ import { existsSync } from 'node:fs';
import path from 'node:path'; import path from 'node:path';
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import type { Plugin } from 'vite'; import type { Plugin } from 'vite';
import { resolvePython,describeSpawnError } from './python-runtime.ts';
export const GROUND_LINK_API='/__pierzainer/ground-link'; export const GROUND_LINK_API='/__pierzainer/ground-link';
export function startGroundBat(bat:string,context:string,cwd:string){ export function startGroundBat(bat:string,context:string,cwd:string){
return spawn(process.env.ComSpec??'cmd.exe',['/d','/s','/c',`""${bat}""`],{cwd,windowsHide:true,windowsVerbatimArguments:true,env:{...process.env,PIERZAINER_GROUND_CONTEXT:context},stdio:'ignore'}); return spawn(process.env.ComSpec??'cmd.exe',['/d','/s','/c',`""${bat}""`],{cwd,windowsHide:true,windowsVerbatimArguments:true,env:{...process.env,PIERZAINER_GROUND_CONTEXT:context},stdio:'ignore'});
@@ -42,10 +43,10 @@ export function groundLinkPlugin(root:string):Plugin{
if(!existsSync(drawing)||!/\.(dwg|dxf)$/i.test(drawing)||drawing!==original&&!cached(drawing))throw new Error('종평면도를 선택하세요.'); if(!existsSync(drawing)||!/\.(dwg|dxf)$/i.test(drawing)||drawing!==original&&!cached(drawing))throw new Error('종평면도를 선택하세요.');
if(!existsSync(piers)||!/\.xlsx$/i.test(piers)||!cached(piers))throw new Error('교각 Excel을 선택하세요.'); if(!existsSync(piers)||!/\.xlsx$/i.test(piers)||!cached(piers))throw new Error('교각 Excel을 선택하세요.');
const id=randomUUID(),output=path.join(base,'data/output',`app_${id}`),job={output,logs:'',state:'running'};jobs.set(id,job); const id=randomUUID(),output=path.join(base,'data/output',`app_${id}`),job={output,logs:'',state:'running'};jobs.set(id,job);
const python=existsSync(path.join(base,'.venv/Scripts/python.exe'))?path.join(base,'.venv/Scripts/python.exe'):'python'; const python=resolvePython(root);
const child=spawn(python,['-B','-u','-X','utf8','-m','pierzainer_ground.web_job',drawing,piers,output],{cwd:base,windowsHide:true,stdio:['ignore','pipe','pipe']}); const child=spawn(python,['-B','-u','-X','utf8','-m','pierzainer_ground.web_job',drawing,piers,output],{cwd:base,windowsHide:true,stdio:['ignore','pipe','pipe']});
const append=(data:Buffer)=>{job.logs=(job.logs+data.toString('utf8')).slice(-200000);};child.stdout.on('data',append);child.stderr.on('data',append); const append=(data:Buffer)=>{job.logs=(job.logs+data.toString('utf8')).slice(-200000);};child.stdout.on('data',append);child.stderr.on('data',append);
child.once('error',error=>{job.state='error';job.logs+='\n'+error.message;});child.once('close',code=>{job.state=code===0?'done':'error';});reply(200,{id});return; child.once('error',error=>{job.state='error';job.logs+='\n'+describeSpawnError(error,python);});child.once('close',code=>{job.state=code===0?'done':'error';});reply(200,{id});return;
} }
if(action==='result'){ if(action==='result'){
const job=jobs.get(body.id);if(!job)throw new Error('지반 생성 작업을 찾을 수 없습니다.'); const job=jobs.get(body.id);if(!job)throw new Error('지반 생성 작업을 찾을 수 없습니다.');
+18 -9
View File
@@ -1,5 +1,7 @@
import './ground-popup.css'; import './ground-popup.css';
const API='/__pierzainer/ground-link'; const API='/__pierzainer/ground-link';
let importGround: ((text: string, name: string) => Promise<void>) | undefined;
export function registerGroundImporter(importer: (text: string, name: string) => Promise<void>) { importGround = importer; }
let pending:{file:File;folder?:string}|undefined,staged:Promise<string|undefined>=Promise.resolve(undefined),version=0; let pending:{file:File;folder?:string}|undefined,staged:Promise<string|undefined>=Promise.resolve(undefined),version=0;
const request=async(action:string,body:object)=>{const r=await fetch(`${API}/${action}`,{method:'POST',headers:{'Content-Type':'application/json','X-Pierzainer-Action':'ground-link'},body:JSON.stringify(body)});const value=await r.json();if(!r.ok)throw new Error(value.error??'지반 프로그램 연동 실패');return value;}; const request=async(action:string,body:object)=>{const r=await fetch(`${API}/${action}`,{method:'POST',headers:{'Content-Type':'application/json','X-Pierzainer-Action':'ground-link'},body:JSON.stringify(body)});const value=await r.json();if(!r.ok)throw new Error(value.error??'지반 프로그램 연동 실패');return value;};
const base64=(file:File)=>new Promise<string>((resolve,reject)=>{const reader=new FileReader();reader.onerror=()=>reject(new Error('Excel 파일을 읽지 못했습니다.'));reader.onload=()=>resolve(String(reader.result).split(',')[1]);reader.readAsDataURL(file);}); const base64=(file:File)=>new Promise<string>((resolve,reject)=>{const reader=new FileReader();reader.onerror=()=>reject(new Error('Excel 파일을 읽지 못했습니다.'));reader.onload=()=>resolve(String(reader.result).split(',')[1]);reader.readAsDataURL(file);});
@@ -9,6 +11,10 @@ document.addEventListener('click',event=>{if(!(event.target as Element).closest(
let opening=false,running=false,launchSerial=0; let opening=false,running=false,launchSerial=0;
let popup:HTMLDialogElement; let popup:HTMLDialogElement;
window.addEventListener('pierzainer:project-changed',()=>{
version++;launchSerial++;running=false;opening=false;
if(popup){popup.close();popup.querySelector<HTMLElement>('[data-status]')!.textContent='프로젝트가 변경되어 이전 작업의 결과를 연결하지 않습니다.';}
});
function getPopup(){ function getPopup(){
if(popup)return popup; if(popup)return popup;
popup=document.createElement('dialog');popup.className='ground-popup';popup.setAttribute('aria-labelledby','ground-popup-title'); popup=document.createElement('dialog');popup.className='ground-popup';popup.setAttribute('aria-labelledby','ground-popup-title');
@@ -28,13 +34,13 @@ window.addEventListener('pierzainer:open-ground',async()=>{
const drawingInput=dialog.querySelector<HTMLInputElement>('[data-drawing]')!,excelInput=dialog.querySelector<HTMLInputElement>('[data-excel]')!; const drawingInput=dialog.querySelector<HTMLInputElement>('[data-drawing]')!,excelInput=dialog.querySelector<HTMLInputElement>('[data-excel]')!;
drawingInput.value='';excelInput.value='';drawingInput.disabled=excelInput.disabled=false;dialog.querySelector('[data-drawing-name]')!.textContent='파일 확인 중…';dialog.querySelector('[data-excel-name]')!.textContent='파일 확인 중…';run.disabled=true;status.textContent='입력 파일 확인 중…';logs.textContent='';dialog.showModal(); drawingInput.value='';excelInput.value='';drawingInput.disabled=excelInput.disabled=false;dialog.querySelector('[data-drawing-name]')!.textContent='파일 확인 중…';dialog.querySelector('[data-excel-name]')!.textContent='파일 확인 중…';run.disabled=true;status.textContent='입력 파일 확인 중…';logs.textContent='';dialog.showModal();
let stageId:string|undefined,drawing=''; let stageId:string|undefined,drawing='';
try{stageId=await staged;const prepared=await request('prepare',{stageId});drawing=prepared.drawing; try{stageId=await staged;const prepared=await request('prepare',{stageId});if(snapshot!==version||ticket!==launchSerial)return;drawing=prepared.drawing;
const display=(kind:string,name:string,connected:boolean)=>{dialog.querySelector(`[data-${kind}-name]`)!.textContent=name;dialog.querySelector(`[data-pick="${kind}"]`)!.textContent=connected?'파일 변경':kind==='drawing'?'종평면도 선택':'Excel 선택';}; const display=(kind:string,name:string,connected:boolean)=>{dialog.querySelector(`[data-${kind}-name]`)!.textContent=name;dialog.querySelector(`[data-pick="${kind}"]`)!.textContent=connected?'파일 변경':kind==='drawing'?'종평면도 선택':'Excel 선택';};
const drawingName=drawing.split(/[\\/]/).pop()||'종평면도를 선택하세요.';const excelName=prepared.excelName||'교각 Excel을 선택하세요.'; const drawingName=drawing.split(/[\\/]/).pop()||'종평면도를 선택하세요.';const excelName=prepared.excelName||'교각 Excel을 선택하세요.';
display('drawing',drawingName,!!drawing);display('excel',excelName,prepared.excelSelected); display('drawing',drawingName,!!drawing);display('excel',excelName,prepared.excelSelected);
drawingInput.onchange=()=>display('drawing',drawingInput.files?.[0]?.name||drawingName,!!drawingInput.files?.[0]||!!drawing); drawingInput.onchange=()=>display('drawing',drawingInput.files?.[0]?.name||drawingName,!!drawingInput.files?.[0]||!!drawing);
excelInput.onchange=()=>display('excel',excelInput.files?.[0]?.name||excelName,!!excelInput.files?.[0]||prepared.excelSelected);status.textContent='입력을 확인하고 지반 생성을 누르세요.';run.disabled=false; excelInput.onchange=()=>display('excel',excelInput.files?.[0]?.name||excelName,!!excelInput.files?.[0]||prepared.excelSelected);status.textContent='입력을 확인하고 지반 생성을 누르세요.';run.disabled=false;
}catch(error){status.textContent=(error as Error).message;opening=false;return;} }catch(error){if(snapshot!==version||ticket!==launchSerial)return;status.textContent=(error as Error).message;opening=false;return;}
run.onclick=async()=>{ run.onclick=async()=>{
if(snapshot!==version){status.textContent='프로젝트가 변경되었습니다. 닫고 다시 열어 주세요.';return;} if(snapshot!==version){status.textContent='프로젝트가 변경되었습니다. 닫고 다시 열어 주세요.';return;}
run.disabled=true;drawingInput.disabled=excelInput.disabled=true; run.disabled=true;drawingInput.disabled=excelInput.disabled=true;
@@ -43,23 +49,26 @@ window.addEventListener('pierzainer:open-ground',async()=>{
status.textContent='입력 파일 전달 중…';const chosen=drawingInput.files?.[0],excel=excelInput.files?.[0]; status.textContent='입력 파일 전달 중…';const chosen=drawingInput.files?.[0],excel=excelInput.files?.[0];
if(chosen)drawing=await upload(chosen);if(!drawing)throw new Error('종평면도를 선택하세요.'); if(chosen)drawing=await upload(chosen);if(!drawing)throw new Error('종평면도를 선택하세요.');
const excelPath=excel?await upload(excel):undefined; const excelPath=excel?await upload(excel):undefined;
if(snapshot!==version||ticket!==launchSerial)return;
if(!excelPath&&!stageId)throw new Error('교각 Excel을 선택하세요.'); if(!excelPath&&!stageId)throw new Error('교각 Excel을 선택하세요.');
const job=await request('launch',{stageId,drawing,excel:excelPath});running=true;status.textContent='지반·지도 생성 중… 닫아도 생성은 계속됩니다.'; const job=await request('launch',{stageId,drawing,excel:excelPath});if(snapshot!==version||ticket!==launchSerial)return;running=true;status.textContent='지반·지도 생성 중… 닫아도 생성은 계속됩니다.';
const deadline=Date.now()+30*60*1000; const deadline=Date.now()+30*60*1000;
const poll=async()=>{ const poll=async()=>{
if(snapshot!==version||ticket!==launchSerial){running=false;return;} if(snapshot!==version||ticket!==launchSerial)return;
try{ try{
const result=await request('result',{id:job.id});logs.textContent=result.logs??'';logs.scrollTop=logs.scrollHeight; const result=await request('result',{id:job.id});logs.textContent=result.logs??'';logs.scrollTop=logs.scrollHeight;
if(snapshot!==version||ticket!==launchSerial)return;
if(result.state==='error')throw new Error('지반 생성 실패. 진행 로그를 확인하세요.'); if(result.state==='error')throw new Error('지반 생성 실패. 진행 로그를 확인하세요.');
if(result.ready){ if(result.ready){
const input=document.querySelector<HTMLInputElement>('#ground-import'); if(!importGround)throw new Error('프로젝트 지반모델 불러오기를 사용할 수 없습니다.');
status.textContent=input?'지반 생성 완료. 프로젝트에 불러왔습니다.':'지반 생성 완료. 지반·지층 화면으로 돌아오면 불러옵니다.'; await importGround(result.text,result.filename);
if(input){const transfer=new DataTransfer();transfer.items.add(new File([result.text],result.filename,{type:'application/json'}));input.files=transfer.files;input.dispatchEvent(new Event('change',{bubbles:true}));running=false;run.disabled=false;drawingInput.disabled=excelInput.disabled=false;return;} status.textContent='지반 생성 완료. 프로젝트 공통 모델로 불러왔습니다. 관리창에서 비교 후 적용하세요.';
running=false;run.disabled=false;drawingInput.disabled=excelInput.disabled=false;return;
} }
if(Date.now()>deadline)throw new Error('응답 대기 시간이 지났습니다. 결과 폴더를 확인하세요.');setTimeout(poll,2000); if(Date.now()>deadline)throw new Error('응답 대기 시간이 지났습니다. 결과 폴더를 확인하세요.');setTimeout(poll,2000);
}catch(error){status.textContent=(error as Error).message;running=false;run.disabled=false;drawingInput.disabled=excelInput.disabled=false;} }catch(error){if(snapshot!==version||ticket!==launchSerial)return;status.textContent=(error as Error).message;running=false;run.disabled=false;drawingInput.disabled=excelInput.disabled=false;}
};setTimeout(poll,1000); };setTimeout(poll,1000);
}catch(error){status.textContent=(error as Error).message;running=false;run.disabled=false;drawingInput.disabled=excelInput.disabled=false;} }catch(error){if(snapshot!==version||ticket!==launchSerial)return;status.textContent=(error as Error).message;running=false;run.disabled=false;drawingInput.disabled=excelInput.disabled=false;}
}; };
}); });
// Closing hides the panel; the current job and its polling continue. // Closing hides the panel; the current job and its polling continue.
@@ -29,6 +29,18 @@ class TerrainData:
stats: dict = field(default_factory=dict) stats: dict = field(default_factory=dict)
def _exe_path_arg(exe: str, file: Path) -> str:
"""Windows .exe를 WSL/Linux에서 부를 때 경로를 Windows 형식으로 바꾼다."""
import os
if os.name == "nt" or not exe.lower().endswith(".exe"):
return str(file)
wslpath = shutil.which("wslpath")
if not wslpath:
return str(file)
result = subprocess.run([wslpath, "-w", str(Path(file).resolve())], check=False, capture_output=True, text=True)
return result.stdout.strip() if result.returncode == 0 and result.stdout.strip() else str(file)
def convert_dwg_to_dxf(dwg: str | Path, out_dir: str | Path) -> Path: def convert_dwg_to_dxf(dwg: str | Path, out_dir: str | Path) -> Path:
"""LibreDWG의 dwg2dxf 로 DWG를 DXF로 바꾼다.""" """LibreDWG의 dwg2dxf 로 DWG를 DXF로 바꾼다."""
exe = shutil.which("dwg2dxf") exe = shutil.which("dwg2dxf")
@@ -55,7 +67,7 @@ def convert_dwg_to_dxf(dwg: str | Path, out_dir: str | Path) -> Path:
temporary=out.with_name(out.stem+'.converting.dxf') temporary=out.with_name(out.stem+'.converting.dxf')
try: try:
temporary.unlink(missing_ok=True) temporary.unlink(missing_ok=True)
result=subprocess.run([exe, "-y", "-o", str(temporary), str(dwg)], check=False, capture_output=True, timeout=300) result=subprocess.run([exe, "-y", "-o", _exe_path_arg(exe,temporary), _exe_path_arg(exe,dwg)], check=False, capture_output=True, timeout=300)
if result.returncode!=0 or not temporary.is_file() or temporary.stat().st_size==0: if result.returncode!=0 or not temporary.is_file() or temporary.stat().st_size==0:
raise RuntimeError(f"DWG 변환에 실패했습니다: {dwg}") raise RuntimeError(f"DWG 변환에 실패했습니다: {dwg}")
temporary.replace(out) temporary.replace(out)
@@ -12,11 +12,52 @@ import numpy as np
from ezdxf import recover from ezdxf import recover
from pyproj import Transformer from pyproj import Transformer
from pierzainer_ground.dxf_terrain import _densify,read_source_document from pierzainer_ground.dxf_terrain import _densify,read_source_document
from pierzainer_ground.config import GroundConfig
from pierzainer_ground.dxf_terrain import read_terrain_dxf
from pierzainer_ground.dtm import DTM from pierzainer_ground.dtm import DTM
ROOT=Path(__file__).resolve().parent.parent ROOT=Path(__file__).resolve().parent.parent
def reject_height_outliers(entities, radius=15.0, max_dz=10.0, min_levels=3, same=1e-3):
"""Drop height entities that disagree with the surrounding surveyed surface.
Drawings often keep plan symbols, hatching or design outlines at a stray
constant elevation (e.g. EL 5.624 or -112.719 among 20-40 m contours).
Each entity's height is compared with the median of the *distinct* nearby
height levels (each level votes once, so a dense stray group of short
ticks cannot outvote real contours); the worst height level is removed
first and the test is repeated, so a large stray group cannot mask itself.
Sparse areas fall back to a 3x search radius.
Heights within `same` (1 mm) form one level, absorbing CAD float noise.
Returns (keep flags, [(index, z, deviation)]).
"""
from scipy.spatial import cKDTree
n=len(entities)
keep=np.ones(n,bool)
if n<3:return keep.tolist(),[]
xy=np.vstack([e[0] for e in entities]);owner=np.concatenate([np.full(len(e[0]),i) for i,e in enumerate(entities)])
zs=np.array([float(e[1]) for e in entities]);sample_z=zs[owner]
tree=cKDTree(xy);rejected=[]
near=lambda e,r:np.unique(np.concatenate(tree.query_ball_point(e[0],r)) if len(e[0]) else np.array([],int)).astype(int)
neighbours=[near(e,radius) for e in entities];wide=[near(e,3*radius) for e in entities]
for _ in range(64):
alive=keep[owner];dev=np.zeros(n)
for i in np.flatnonzero(keep):
nb=neighbours[i];nb=nb[alive[nb]&(np.abs(sample_z[nb]-zs[i])>same)]
if len(np.unique(np.round(sample_z[nb]/same)))<min_levels:nb=wide[i];nb=nb[alive[nb]&(np.abs(sample_z[nb]-zs[i])>same)]
levels=np.unique(np.round(sample_z[nb]/same))*same
# Peaks/pits legitimately exceed the median; require the height to
# lie clearly outside the whole local range as well.
outside=max(levels[0]-zs[i],zs[i]-levels[-1]) if len(levels) else 0.0
if len(levels)>=min_levels and outside>max_dz/2:dev[i]=zs[i]-float(np.median(levels))
worst=int(np.argmax(np.abs(dev)))
if abs(dev[worst])<=max_dz:break
group=np.flatnonzero(keep&(np.abs(zs-zs[worst])<=same)&(np.abs(dev)>max_dz))
keep[group]=False;rejected.extend((int(i),float(zs[i]),float(dev[i])) for i in group)
return keep.tolist(),rejected
def read_survey_block(source, project, doc=None): def read_survey_block(source, project, doc=None):
"""Read actual height geometry in an explicitly selected survey block.""" """Read actual height geometry in an explicitly selected survey block."""
if doc is None: if doc is None:
@@ -24,6 +65,33 @@ def read_survey_block(source, project, doc=None):
block=doc.blocks.get(project['terrain_block']) block=doc.blocks.get(project['terrain_block'])
if block is None: if block is None:
raise ValueError(f"측량 블록이 없습니다: {project['terrain_block']}") raise ValueError(f"측량 블록이 없습니다: {project['terrain_block']}")
# A plan's modelspace mixes contours with borehole symbols and design
# outlines that also carry Z. Prefer the established semantic reader when
# this drawing declares known survey layers; retain the block reader for
# original survey XREFs with their own layer conventions.
cfg=GroundConfig(cad_axes=project.get('cad_axes','YX'),use_profile_points=False,
z_min=project.get('z_min',0),z_max=project.get('z_max',200),
contour_step=project['contour_step_m'],dedup_xy=project['dedup_m'])
allowed=cfg.layers.spot_block+cfg.layers.spot_text+cfg.layers.contour
if project.get('layers'):
allowed=tuple(layer for layer in allowed if layer in project['layers'])
if (block.name.lower()=='*model_space' and project.get('prefer_recognized_layers',True)
and any(e.dxf.layer in allowed for e in block)):
cfg.allowed_terrain_layers=allowed
terrain=read_terrain_dxf(source,cfg,document=doc)
a=terrain.points
if project.get('exclude_zero'):
a=a[np.abs(a[:,2])>1e-6]
if len(a)<3:
raise ValueError('인식된 측량 레이어에서 실제 표고점·등고선을 3점 이상 읽지 못했습니다.')
if project.get('terrain_crs') and project.get('crs'):
east,north=Transformer.from_crs(project['terrain_crs'],project['crs'],always_xy=True).transform(a[:,1],a[:,0])
a=np.column_stack([north,east,a[:,2]])
if not np.isfinite(a).all():
raise ValueError('측량 레이어 좌표 변환 결과가 유효하지 않습니다.')
sources=terrain.stats['by_source']
return a,{'semantic_layer_filter':1,'survey_points':sources['spot'],
'spot_text_points':sources['text'],'contour_points':sources['contour']},terrain.stats['contours']
to_model=(Transformer.from_crs(project['terrain_crs'],project['crs'],always_xy=True) to_model=(Transformer.from_crs(project['terrain_crs'],project['crs'],always_xy=True)
if project.get('terrain_crs') and project.get('crs') else None) if project.get('terrain_crs') and project.get('crs') else None)
points=[]; contours=[]; types=Counter() points=[]; contours=[]; types=Counter()
@@ -54,9 +122,11 @@ def read_survey_block(source, project, doc=None):
if e.is_closed: xy.append(xy[0]) if e.is_closed: xy.append(xy[0])
contours.append((xy,float(a[0,2]),e.dxf.handle)); types['constant_height_polylines']+=1 contours.append((xy,float(a[0,2]),e.dxf.handle)); types['constant_height_polylines']+=1
# POINT entities have priority over densified contour vertices. # POINT entities have priority over densified contour vertices.
for xy,z,handle in contours: sampled=[(np.asarray(_densify(xy,project['contour_step_m']),dtype=float).reshape(-1,2),z) for xy,z,handle in contours]
samples=_densify(xy,project['contour_step_m']) sampled=[(np.array([[x,y]]),z) for x,y,z in points]+sampled
points.extend((x,y,z) for x,y in samples) keep,rejected=reject_height_outliers(sampled,project.get('outlier_radius_m',15.0),project.get('outlier_dz_m',10.0)) if project.get('reject_outliers',True) else ([True]*len(sampled),[])
if rejected:types['rejected_height_outliers']=len(rejected)
points=[(x,y,z) for (xy,z),ok in zip(sampled,keep) if ok for x,y in xy]
a=np.array(points,dtype=float) a=np.array(points,dtype=float)
if len(a)<3: if len(a)<3:
raise ValueError('선택한 블록에서 실제 표고점·등고선을 3점 이상 읽지 못했습니다.') raise ValueError('선택한 블록에서 실제 표고점·등고선을 3점 이상 읽지 못했습니다.')
+26
View File
@@ -0,0 +1,26 @@
import { describe,it,expect } from 'vitest';
import path from 'node:path';
import { resolvePython,describeSpawnError } from './python-runtime';
const root='/repo',venv=path.join(root,'pierzainer_ground','.venv');
const has=(...files:string[])=>(file:string)=>files.includes(file);
describe('resolvePython',()=>{
it('uses the Windows venv on win32',()=>{
const exe=path.join(venv,'Scripts','python.exe');
expect(resolvePython(root,'win32',has(exe),{})).toBe(exe);
});
it('uses the POSIX venv on Linux/WSL',()=>{
const bin=path.join(venv,'bin','python');
expect(resolvePython(root,'linux',has(bin),{})).toBe(bin);
});
it('falls back to PIERZAINER_PYTHON, then python3 / python',()=>{
expect(resolvePython(root,'linux',has(),{PIERZAINER_PYTHON:'/opt/py'})).toBe('/opt/py');
expect(resolvePython(root,'linux',has(),{})).toBe('python3');
expect(resolvePython(root,'win32',has(),{})).toBe('python');
});
it('explains ENOENT instead of the raw spawn message',()=>{
const error=Object.assign(new Error('spawn python ENOENT'),{code:'ENOENT'});
expect(describeSpawnError(error,'python')).toContain('Python 실행 파일을 찾지 못했습니다');
});
});
+20
View File
@@ -0,0 +1,20 @@
import { existsSync } from 'node:fs';
import path from 'node:path';
/**
* Python used by the local dev-server tools (ground engine, quantity workbook, DWG export).
* Order: Windows venv → POSIX venv (WSL/Linux) → PIERZAINER_PYTHON → python3/python on PATH.
*/
export function resolvePython(root:string,platform:NodeJS.Platform=process.platform,exists:(file:string)=>boolean=existsSync,env:NodeJS.ProcessEnv=process.env):string{
const venv=path.join(root,'pierzainer_ground','.venv');
const candidates=platform==='win32'?[path.join(venv,'Scripts','python.exe')]:[path.join(venv,'bin','python'),path.join(venv,'Scripts','python.exe')];
const found=candidates.find(file=>exists(file));if(found)return found;
if(env.PIERZAINER_PYTHON)return env.PIERZAINER_PYTHON;
return platform==='win32'?'python':'python3';
}
/** Human-readable reason for a failed spawn (ENOENT otherwise shows only "spawn python ENOENT"). */
export function describeSpawnError(error:NodeJS.ErrnoException,python:string):string{
if(error.code==='ENOENT')return `Python 실행 파일을 찾지 못했습니다(${python}). pierzainer_ground/.venv를 만들고 requirements.txt를 설치하거나 PIERZAINER_PYTHON을 지정하세요.`;
return error.message;
}
@@ -25,4 +25,46 @@ class SurveyPolylineTests(unittest.TestCase):
doc=ezdxf.new();doc.modelspace().add_polyline3d([(1,1,20),(2,2,30),(3,3,40)]) doc=ezdxf.new();doc.modelspace().add_polyline3d([(1,1,20),(2,2,30),(3,3,40)])
with self.assertRaises(ValueError):read_survey_block(Path('unused.dxf'),self.options(),doc=doc) with self.assertRaises(ValueError):read_survey_block(Path('unused.dxf'),self.options(),doc=doc)
def contours(self,msp,levels):
for k,z in enumerate(levels):
msp.add_lwpolyline([(0,10*k),(200,10*k)],dxfattribs={'elevation':z})
def test_stray_constant_height_symbols_are_rejected(self):
# Ungok case: dense short ticks at EL 5.624 and a few at -112.719 among 20-40 m contours.
doc=ezdxf.new();msp=doc.modelspace();self.contours(msp,[28,29,30,31,32,33])
for i in range(60):msp.add_lwpolyline([(40+2*i,20),(40+2*i,22)],dxfattribs={'elevation':5.624})
for i in range(4):msp.add_lwpolyline([(100+i,25),(100+i,27)],dxfattribs={'elevation':-112.719})
points,counts,_=read_survey_block(Path('unused.dxf'),self.options(),doc=doc)
self.assertEqual(counts['rejected_height_outliers'],64)
self.assertGreaterEqual(points[:,2].min(),28)
def test_real_peak_above_contours_is_kept(self):
doc=ezdxf.new();msp=doc.modelspace();self.contours(msp,[70,72,74,76,78,80])
msp.add_lwpolyline([(100,25),(100,27)],dxfattribs={'elevation':84})
points,counts,_=read_survey_block(Path('unused.dxf'),self.options(),doc=doc)
self.assertNotIn('rejected_height_outliers',counts);self.assertEqual(points[:,2].max(),84)
def test_known_plan_layers_exclude_near_height_bore_and_base(self):
doc=ezdxf.new();msp=doc.modelspace()
for y,z in [(0,30),(100,32)]:
msp.add_lwpolyline([(0,y),(100,y)],dxfattribs={'layer':'CAA001','elevation':z})
doc.blocks.new('SPOT').add_circle((0,0),.1)
msp.add_blockref('SPOT',(20,50,31),dxfattribs={'layer':'CA002'})
msp.add_text('30.5',dxfattribs={'layer':'CA0021','insert':(80,50)})
for layer in ['CV-BORE-SYMB','CZ-BASE','CF-BRDG-NEWC']:
msp.add_lwpolyline([(45,45),(55,45),(55,55)],dxfattribs={'layer':layer,'elevation':27.5})
points,counts,_=read_survey_block(Path('unused.dxf'),self.options(),doc=doc)
self.assertGreaterEqual(points[:,2].min(),30)
self.assertEqual(counts['semantic_layer_filter'],1)
self.assertEqual(counts['survey_points'],1)
self.assertEqual(counts['spot_text_points'],1)
self.assertTrue(any(np.array_equal(p,[50,20,31]) for p in points))
self.assertTrue(any(np.array_equal(p,[50,80,30.5]) for p in points))
def test_known_layers_do_not_fall_back_to_symbols_when_empty(self):
doc=ezdxf.new();msp=doc.modelspace()
msp.add_lwpolyline([(0,0),(10,0)],dxfattribs={'layer':'CAA001','elevation':0})
msp.add_lwpolyline([(0,0),(10,0),(10,10)],dxfattribs={'layer':'CV-BORE-SYMB','elevation':28})
with self.assertRaises(ValueError):read_survey_block(Path('unused.dxf'),self.options(),doc=doc)
if __name__=='__main__':unittest.main() if __name__=='__main__':unittest.main()
View File
File mode changed.
View File
File mode changed.
View File
File mode changed.
View File
File mode changed.
View File
File mode changed.
View File
File mode changed.
View File
File mode changed.
View File
File mode changed.
View File
File mode changed.
View File
File mode changed.
+64
View File
@@ -0,0 +1,64 @@
// Run with Windows Node and an isolated Chrome on CDP 9444; Vite must be on 5173.
const fs = require('node:fs'), path = require('node:path'), assert = require('node:assert/strict');
const preview=process.argv.includes('--horizontal-preview');
const lowerPreview=process.argv.includes('--lower-preview');
const out = path.resolve(__dirname, lowerPreview?'../.verify/landing-lower-20261109':process.argv.includes('--all')?'../.verify/landing-hero-20261109':'../.verify/landing-horizontal-20261109');
fs.mkdirSync(out, { recursive: true });
const report = { checks: [], errors: [] };
let ws, send;
const delay = ms => new Promise(r => setTimeout(r, ms));
(async () => {
const target = await (await fetch('http://127.0.0.1:9444/json/new?about:blank', { method: 'PUT' })).json();
ws = new WebSocket(target.webSocketDebuggerUrl);
await new Promise((r, j) => { ws.onopen = r; ws.onerror = j; });
let seq = 0; const pending = new Map();
ws.onmessage = ({ data }) => {
const m = JSON.parse(data), p = pending.get(m.id);
if (p) { clearTimeout(p.timer); pending.delete(m.id); m.error ? p.reject(new Error(JSON.stringify(m.error))) : p.resolve(m.result); }
if (m.method === 'Runtime.exceptionThrown') report.errors.push(m.params.exceptionDetails.exception?.description || m.params.exceptionDetails.text);
};
send = (method, params = {}) => new Promise((resolve, reject) => {
const id = ++seq, timer = setTimeout(() => reject(new Error('Timeout ' + method)), 30000);
pending.set(id, { resolve, reject, timer }); ws.send(JSON.stringify({ id, method, params }));
});
const ev = async expression => {
const r = await send('Runtime.evaluate', { expression, awaitPromise: true, returnByValue: true });
if (r.exceptionDetails) throw new Error(r.exceptionDetails.exception?.description || r.exceptionDetails.text);
return r.result.value;
};
const until = async expression => {
for (let i = 0; i < 120; i++) { if (await ev(expression)) return; await delay(150); }
throw new Error('Wait ' + expression);
};
const check = (name, value) => { assert.ok(value, name); report.checks.push(name); console.log('PASS', name); };
const click = expression => ev(`(()=>{const e=${expression};if(!e||e.disabled)throw new Error('Missing target');e.click();})()`);
const shot = async name => {
const r = await send('Page.captureScreenshot', { format: 'png' });
fs.writeFileSync(path.join(out, name + '.png'), Buffer.from(r.data, 'base64'));
};
await send('Page.enable'); await send('Runtime.enable'); await send('DOM.enable');
await send('Emulation.setDeviceMetricsOverride', { width: 1600, height: 1000, deviceScaleFactor: 1, mobile: false });
await send('Page.navigate', { url: process.env.PIERZAINER_URL || 'http://127.0.0.1:5173/' });
await until(`typeof window.openSampleProject==='function'`);
const projectPath=process.env.OKSEONG_PROJECT||path.resolve(__dirname,'../.verify/ground-abutments-20261109/saved.pzr.json');
const projectText=fs.readFileSync(projectPath,'utf8');
const hash=require('node:crypto').createHash('sha256').update(projectText).digest('hex');
await ev(`window.__heroProject=JSON.parse(${JSON.stringify(projectText)})`);
const variants=lowerPreview ? [['lower-33',.65,0,30,true,true],['lower-27',.5,0,30,true,true],['lower-19',.35,0,30,true,true],['lower-27-cutaway',.5,0,30,true,true,8]] : preview ? [['horizontal',.85,0,30,true],['horizontal-smooth',.85,0,30,true,true]] : process.argv.includes('--all') ? [['low',.35,0],['balanced',.55,.12],['high',.8,-.12],['trim30',.55,.12,30],['trim20',.55,.12,20],['refined',.85,.12,30,true]] : [['horizontal-smooth',.85,0,30,true,true]];
for(const [name,elevation,along,margin,refined,smooth,cutaway] of variants){
const result=await ev(`(async()=>{const {renderLandingHero}=await import('/scripts/render-landing-hero.ts');return renderLandingHero(window.__heroProject,${elevation},${along},${margin},${refined},${smooth},${cutaway});})()`);
for(const type of ['png','webp'])fs.writeFileSync(path.join(out,name+'.'+type),Buffer.from(result[type].split(',')[1],'base64'));
fs.writeFileSync(path.join(out,name+'.json'),JSON.stringify({...result.metadata,sourceSha256:hash},null,2));
check(name+' includes six piers and two abutments',result.metadata.piers===6&&result.metadata.abutments===2&&result.metadata.points.length===8);
check(name+' all projected markers stay inside image',result.metadata.points.every(p=>p.x>0&&p.x<result.metadata.width&&p.y>0&&p.y<result.metadata.height));
check(name+' light samples actual terrain densely',result.metadata.terrainFlow.filter(Boolean).length>100&&result.metadata.terrainFlow.every(p=>!p||Math.abs(p.world[1]-p.el-.12)<.0001));
if(refined)check('both road extensions contain continuous terrain-supported sections',result.metadata.roads.length===2&&result.metadata.roads.every(r=>r.missingSections===0&&r.validSections===81&&r.triangles>0));
if(smooth){
check('outline trims real pixels without filling missing terrain',result.metadata.outline.addedOutsideSourcePixels===0&&result.metadata.outline.retainedTerrainRatio>.8&&result.metadata.outline.retainedTerrainRatio<.99);
check('smooth outline preserves all eight structure tops',await ev(`(()=>{const c=document.createElement('canvas');c.width=${result.metadata.width*2};c.height=${result.metadata.height*2};return new Promise(resolve=>{const i=new Image();i.onload=()=>{const ctx=c.getContext('2d');ctx.drawImage(i,0,0);resolve(${JSON.stringify(result.metadata.points)}.every(p=>{let n=0;for(let x=-8;x<=8;x++)for(let y=0;y<30;y++)n+=ctx.getImageData(Math.round(p.x*2+x),Math.round(p.y*2+y),1,1).data[3]>0;return n>15;}));};i.src=${JSON.stringify(result.png)};});})()`));
}
}
check('no runtime exceptions',report.errors.length===0);report.result='PASS';
})().catch(e=>{report.result='FAIL';report.failure=e.stack;process.exitCode=1;}).finally(()=>{
fs.writeFileSync(path.join(out,'report.json'),JSON.stringify(report,null,2));console.log(JSON.stringify(report));ws?.close();
});
+42
View File
@@ -0,0 +1,42 @@
"""Create a review sheet from native Three.js captures; validate local structure crops."""
import json
from pathlib import Path
from PIL import Image, ImageChops, ImageDraw, ImageFont
root = Path(__file__).resolve().parents[1]
out = root / '.verify/landing-horizontal-20261109'
metadata = json.loads((out / 'horizontal-smooth.json').read_text())
raw = Image.open(out / 'horizontal.png').convert('RGBA')
smooth = Image.open(out / 'horizontal-smooth.png').convert('RGBA')
checks = []
for point in metadata['points']:
x, y = point['x'] * 2, point['y'] * 2
box = (int(x - 48), int(y - 8), int(x + 48), int(y + 120))
assert ImageChops.difference(raw.crop(box), smooth.crop(box)).getbbox() is None, point['name']
checks.append(point['name'] + ' local structure pixels unchanged')
assert metadata['along'] == 0
assert metadata['outline']['addedOutsideSourcePixels'] == 0
checks.extend(['horizontal camera has zero along-axis component', 'no pixels added outside source'])
sheet = Image.new('RGB', (1600, 1050), '#f5f8fb')
draw = ImageDraw.Draw(sheet)
font = ImageFont.truetype('/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf', 23)
small = ImageFont.truetype('/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf', 17)
previous = out / 'before-horizontal/okseong-model-hero.png'
panels = [
('PREVIOUS / diagonal view, rough survey boundary', previous if previous.exists() else root / '랜딩페이지/assets/okseong-model-hero.png'),
('PROPOSAL / horizontal orthographic view, inward curved boundary', out / 'horizontal-smooth.png'),
]
for i, (label, path) in enumerate(panels):
y = 25 + i * 510
draw.rounded_rectangle((20, y, 1580, y + 480), radius=20, fill='white', outline='#dce4ed')
draw.text((45, y + 20), label, font=font, fill='#19334d')
image = Image.open(path).convert('RGBA')
image = image.crop(image.getbbox())
image.thumbnail((1500, 380), Image.Resampling.LANCZOS)
sheet.paste(image, ((1600 - image.width) // 2, y + 80 + (380 - image.height) // 2), image)
if i == 1:
draw.text((45, y + 445), '6 piers + 2 abutments preserved / original survey unchanged / preview only', font=small, fill='#52667a')
sheet.save(out / 'comparison.png')
(out / 'review-report.json').write_text(json.dumps({'result': 'PASS', 'checks': checks}, indent=2))
print(f'PASS {len(checks)} review checks; {out / "comparison.png"}')
+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;});
+57
View File
@@ -0,0 +1,57 @@
import * as THREE from 'three';
/** Rounded capture footprint; the underlying survey grid remains untouched. */
export function roundedTerrainPlanes(axis: THREE.Vector3, start: number, end: number, halfWidth: number, radius: number) {
const lateral = new THREE.Vector3(-axis.z, 0, axis.x);
const corners = [[end-radius,halfWidth-radius,0],[start+radius,halfWidth-radius,90],[start+radius,-halfWidth+radius,180],[end-radius,-halfWidth+radius,270]];
const points: [number,number][]=[];
for(const [s,t,angle] of corners) for(let i=0;i<=8;i++) {
const a=(angle+i*90/8)*Math.PI/180;
points.push([s+radius*Math.cos(a),t+radius*Math.sin(a)]);
}
return points.map((p,i)=>{
const q=points[(i+1)%points.length],ds=q[0]-p[0],dt=q[1]-p[1];
const normal=axis.clone().multiplyScalar(-dt).addScaledVector(lateral,ds).normalize();
const origin=axis.clone().multiplyScalar(p[0]).addScaledVector(lateral,p[1]);
return new THREE.Plane(normal,-normal.dot(origin));
});
}
/** Landing-only road ribbons, joining the photographed road to ground at each abutment. */
export function buildRoadExtensions(terrain: THREE.Mesh, anchors: {name:string;kind:string;point:THREE.Vector3}[], axis: THREE.Vector3, top: number) {
const piers=anchors.filter(a=>a.kind==='pier').toSorted((a,b)=>a.point.dot(axis)-b.point.dot(axis));
const abuts=anchors.filter(a=>a.kind==='abutment').toSorted((a,b)=>a.point.dot(axis)-b.point.dot(axis));
if(piers.length<2||abuts.length!==2)throw new Error('Road extensions require two abutments and at least two piers');
const group=new THREE.Group();group.name='landing-road-extensions';
const ray=new THREE.Raycaster(),down=new THREE.Vector3(0,-1,0),widthM=8;
const sample=(x:number,z:number)=>{
ray.set(new THREE.Vector3(x,top+100,z),down);
return ray.intersectObject(terrain,false)[0]?.point;
};
const descriptions=[];
const ends=[{name:'A1–P1',points:[abuts[0].point,piers[0].point,piers[1].point],from:0,to:.58},
{name:'P6–A2',points:[piers.at(-2)!.point,piers.at(-1)!.point,abuts[1].point],from:.42,to:1}];
for(const end of ends){
const curve=new THREE.CatmullRomCurve3(end.points.map(p=>new THREE.Vector3(p.x,0,p.z)),false,'catmullrom',.2);
const steps=80,positions:number[]=[],colors:number[]=[],indices:number[]=[];
let previous=-1,validSections=0,missingSections=0;
for(let i=0;i<=steps;i++){
const t=i/steps,u=end.from+(end.to-end.from)*t,p=curve.getPoint(u),tangent=curve.getTangent(u).normalize();
const cross=new THREE.Vector3(-tangent.z,0,tangent.x);
const offsets=Array.from({length:17},(_,j)=>-widthM/2+widthM*j/16);
const hits=offsets.map(o=>sample(p.x+cross.x*o,p.z+cross.z*o));
if(hits.some(h=>!h)){previous=-1;missingSections++;continue;}
const first=positions.length/3;
const blend=end.from===0?1-THREE.MathUtils.smoothstep(t,.72,1):THREE.MathUtils.smoothstep(t,0,.28);
const color=new THREE.Color('#dcd8c7').multiplyScalar(1+Math.sin(i*2.33)*.018);
hits.forEach((hit,j)=>{positions.push(hit!.x,hit!.y+.15,hit!.z);colors.push(color.r,color.g,color.b,blend*(j===0||j===offsets.length-1?0:.96));});
if(previous>=0)for(let j=0;j<offsets.length-1;j++)indices.push(previous+j,first+j,previous+j+1,previous+j+1,first+j,first+j+1);
previous=first;validSections++;
}
const geometry=new THREE.BufferGeometry();geometry.setAttribute('position',new THREE.Float32BufferAttribute(positions,3));geometry.setAttribute('color',new THREE.Float32BufferAttribute(colors,4));geometry.setIndex(indices);geometry.computeVertexNormals();
const mesh=new THREE.Mesh(geometry,new THREE.MeshBasicMaterial({vertexColors:true,side:THREE.DoubleSide,transparent:true,depthWrite:false,polygonOffset:true,polygonOffsetFactor:-2,polygonOffsetUnits:-2}));
mesh.name=end.name;mesh.renderOrder=2;group.add(mesh);
descriptions.push({name:end.name,widthM,validSections,missingSections,triangles:indices.length/3,visualizationOnly:true});
}
return {group,descriptions};
}
+48
View File
@@ -0,0 +1,48 @@
/** Capture-only silhouette trimming. Samples real alpha; never fills missing survey data. */
export function smoothLandingOutline(source: HTMLCanvasElement, terrain: HTMLCanvasElement, preserveExposedStructures = false) {
const width=source.width,height=source.height,scale=width/2172;
const alpha=terrain.getContext('2d')!.getImageData(0,0,width,height).data;
const tops:number[]=[],bottoms:number[]=[];
for(let x=0;x<width;x++) {
let top=height,bottom=-1;
for(let y=0;y<height;y++)if(alpha[(y*width+x)*4+3]>240){top=Math.min(top,y);bottom=y;}
tops.push(top);bottoms.push(bottom);
}
const support=tops.map((top,x)=>bottoms[x]-top>60*scale?x:-1).filter(x=>x>=0);
if(!support.length)throw new Error('No continuous terrain silhouette');
const left=support[0]+Math.ceil(12*scale),right=support.at(-1)!-Math.ceil(12*scale);
const inset=Math.ceil(4*scale),radius=Math.ceil(18*scale),average=Math.ceil(8*scale);
const eroded=(values:number[],upper:boolean)=>values.map((_,x)=>{
const local=values.slice(Math.max(0,x-radius-average),Math.min(width,x+radius+average+1));
return (upper?Math.max(...local):Math.min(...local))+(upper?inset:-inset);
});
const upper=eroded(tops,true),lower=eroded(bottoms,false);
const mean=(values:number[],x:number)=>{
const samples=values.slice(Math.max(0,x-average),Math.min(width,x+average+1));
return samples.reduce((a,b)=>a+b,0)/samples.length;
};
const points:[number,number][]=[];
const cap=Math.ceil(24*scale),step=Math.ceil(12*scale);
for(let x=left+cap;x<=right-cap;x+=step)points.push([x,mean(upper,x)]);
points.push([right-cap,mean(upper,right-cap)],[right,mean(upper,right-cap)+cap],[right,mean(lower,right-cap)-cap],[right-cap,mean(lower,right-cap)]);
for(let x=right-cap-step;x>=left+cap;x-=step)points.push([x,mean(lower,x)]);
points.push([left+cap,mean(lower,left+cap)],[left,mean(lower,left+cap)-cap],[left,mean(upper,left+cap)+cap],[left+cap,mean(upper,left+cap)]);
const mid=(a:[number,number],b:[number,number])=>[(a[0]+b[0])/2,(a[1]+b[1])/2];
const first=mid(points.at(-1)!,points[0]);
let path=`M${first[0]} ${first[1]}`;
points.forEach((p,i)=>{const next=mid(p,points[(i+1)%points.length]);path+=`Q${p[0]} ${p[1]} ${next[0]} ${next[1]}`;});path+='Z';
const canvas=document.createElement('canvas');canvas.width=width;canvas.height=height;
const ctx=canvas.getContext('2d')!;ctx.drawImage(source,0,0);
const original=ctx.getImageData(0,0,width,height).data;
ctx.globalCompositeOperation='destination-in';ctx.fill(new Path2D(path));
const outputImage=ctx.getImageData(0,0,width,height),output=outputImage.data;
// A cutaway may expose piles beyond the terrain silhouette. Preserve their rendered pixels.
if(preserveExposedStructures){
for(let i=0;i<alpha.length;i+=4)if(alpha[i+3]===0&&original[i+3]>0)output.set(original.subarray(i,i+4),i);
ctx.putImageData(outputImage,0,0);
}
// Compare to terrain alpha only: smoothing should remove its rough border, not invent pixels.
let before=0,after=0,addedPixels=0;
for(let i=3;i<alpha.length;i+=4){if(alpha[i]>240){before++;if(output[i]>240)after++;}if(original[i]===0&&output[i]>0)addedPixels++;}
return {canvas,metadata:{method:'inward-alpha-envelope-quadratic',path,retainedTerrainRatio:after/before,addedOutsideSourcePixels:addedPixels}};
}
+30
View File
@@ -0,0 +1,30 @@
"""Promote one rendered camera variant; preserve the previous landing image."""
import json
import shutil
import sys
from pathlib import Path
root = Path(__file__).resolve().parents[1]
assets = root / '랜딩페이지/assets'
variant = sys.argv[1] if len(sys.argv) > 1 else 'horizontal-smooth'
if variant not in ('balanced', 'low', 'high', 'trim30', 'trim20', 'refined', 'horizontal-smooth'):
raise SystemExit('Unknown camera variant')
source = root / ('.verify/landing-horizontal-20261109' if variant == 'horizontal-smooth' else '.verify/landing-hero-20261109')
metadata = json.loads((source / f'{variant}.json').read_text())
if variant == 'horizontal-smooth':
assert metadata['along'] == 0 and metadata['outline']['addedOutsideSourcePixels'] == 0
assert metadata['piers'] == 6 and metadata['abutments'] == 2
# Full curve control points remain in the capture audit, outside the service bundle.
metadata['outline'].pop('path')
backup = source / ('before-horizontal' if variant == 'horizontal-smooth' else 'before-refined' if variant == 'refined' else 'before-trim30')
backup.mkdir(exist_ok=True)
for name in ('okseong-model-hero.png', 'okseong-model-hero.webp', 'okseong-hero-capture.ts'):
if (assets / name).exists() and not (backup / name).exists():
shutil.copyfile(assets / name, backup / name)
for kind in ('png', 'webp'):
shutil.copyfile(source / f'{variant}.{kind}', assets / f'okseong-model-hero.{kind}')
metadata['terrainFlow'] = [None if p is None else {'x': p['x'], 'y': p['y']} for p in metadata['terrainFlow']]
(assets / 'okseong-hero-capture.ts').write_text(
'// Generated by scripts/promote-landing-hero.py from actual model projection.\n'
'export const heroCapture = ' + json.dumps(metadata, ensure_ascii=False, indent=2) + ' as const;\n'
)
+51
View File
@@ -0,0 +1,51 @@
"""Regenerate the user's two inputs in a separate folder, reusing exact caches."""
import hashlib
import json
import shutil
import sys
from pathlib import Path
import numpy as np
root=Path(__file__).resolve().parents[1]
sys.path.insert(0,str(root/'pierzainer_ground'))
from pierzainer_ground.drawing_job import DrawingJob,build_drawing
old=root/'pierzainer_ground/data/output/app_b614afc0-6829-4aef-96e3-3ff1365ff5db'
target=root/'pierzainer_ground/data/output/ungok_corrected_20261110'
evidence=root/'.verify/ungok-terrain-fixed-20261110'
evidence.mkdir(parents=True,exist_ok=True)
settings=json.loads((old/'generation_settings.json').read_text())
old_file=old/'ground_model_3d_with_maps.json'
sources=[Path(settings['drawing']),Path(settings['piers']),old_file]
digest=lambda p:hashlib.sha256(p.read_bytes()).hexdigest()
before={str(p):digest(p) for p in sources}
if (target/'ground_model_3d_with_maps.json').exists():
raise SystemExit('Corrected result already exists; preserve it rather than overwrite.')
(target/'cache').mkdir(parents=True,exist_ok=True)
for path in (old/'cache').iterdir():
if path.name.endswith(('.dxf','.dxf.source.json')) or path.name=='map_layers.json':
shutil.copyfile(path,target/'cache'/path.name)
settings.update(output=str(target),allow_download=False)
log=[]
data=build_drawing(DrawingJob(**settings),lambda text:(log.append(text),print(text,flush=True)))
original=json.loads(old_file.read_text())
def metrics(d):
g=d['terrain_grid'];z=np.array([np.nan if v is None else v for v in g['z']]).reshape(g['ny'],g['nx'])
edges=np.concatenate([np.abs(np.diff(z,axis=0)).ravel(),np.abs(np.diff(z,axis=1)).ravel()]);edges=edges[np.isfinite(edges)]
return {'valid_nodes':int(np.isfinite(z).sum()),'min_el':float(np.nanmin(z)),'max_el':float(np.nanmax(z)),
'max_adjacent_dz':float(edges.max()),'edges_over_5m':int((edges>5).sum()),'edges_over_8m':int((edges>8).sum())}
checks={
'original_inputs_and_previous_result_unchanged':all(digest(p)==before[str(p)] for p in sources),
'all_16_centers_have_ground':len(data['piers'])==16 and all(p['ground']['center'] is not None for p in data['piers']),
'same_structure_ids_and_coordinates':[(p['id'],p['X'],p['Y']) for p in data['piers']]==[(p['id'],p['X'],p['Y']) for p in original['piers']],
'same_grid_layout':all(data['terrain_grid'][k]==original['terrain_grid'][k] for k in ('origin','step','nx','ny')),
'semantic_source_filter_used':data['terrain']['source_counts'].get('semantic_layer_filter')==1,
'no_adjacent_jumps_over_5m':metrics(data)['edges_over_5m']==0,
'both_embedded_maps_retained':data['map_status']['status']=='mapped' and {l['id'] for l in data['visualization']['layers']}=={'satellite','ground'},
'original_source_provenance_matches':data['surface_provenance']['sha256']==before[str(sources[0])] and data['surface_provenance']['pier_sha256']==before[str(sources[1])],
}
report={'result':'PASS' if all(checks.values()) else 'FAIL','checks':checks,'before':metrics(original),'after':metrics(data),'output':str(target/'ground_model_3d_with_maps.json'),'inputs_sha256':before,'terrain':data['terrain'],'log':log,
'centers':[{'id':new['id'],'before':prior['ground']['center'],'after':new['ground']['center']} for prior,new in zip(original['piers'],data['piers'])]}
(evidence/'report.json').write_text(json.dumps(report,ensure_ascii=False,indent=2))
print(json.dumps(report,ensure_ascii=False))
if not all(checks.values()):raise SystemExit(1)
+123
View File
@@ -0,0 +1,123 @@
import * as THREE from 'three';
import type { ProjectFile } from '../src/core/project';
import { buildPier } from '../src/core/geometry';
import { buildAllPiers } from '../src/view/all-piers';
import { buildTerrainMapGroup, disposeTerrainMapGroup } from '../src/view/terrain-map';
import { buildAbut } from '../src/core/abut/geometry';
import { toAbutInput } from '../src/core/abut/parse';
import { buildMembers } from '../src/core/abut/members';
import { buildWings } from '../src/core/abut/wings';
import { buildAbutGroup } from '../src/view/abut-scene';
import { abutSoles, resolveBearingRule } from '../src/core/abut/bearing-table';
import { bridgePairs } from '../src/core/bridge-pairs';
import { buildRoadExtensions, roundedTerrainPlanes } from './landing-ground-treatment';
import { smoothLandingOutline } from './landing-smooth-outline';
/** Actual structure/terrain capture with optional landing-only road extensions. */
export async function renderLandingHero(project: ProjectFile, elevation: number, along = 0, endMarginM?: number, refined = false, smoothOutline = false, cutawayFrontM?: number) {
const width = 2172, height = refined ? 870 : 724, aspect=width/height;
const renderer = new THREE.WebGLRenderer({ alpha: true, antialias: true, preserveDrawingBuffer: true });
renderer.setPixelRatio(2); renderer.setSize(width, height); renderer.setClearColor(0xffffff, 0);
renderer.localClippingEnabled = true;
const scene = new THREE.Scene();
scene.add(new THREE.HemisphereLight(0xffffff, 0x7c8a9a, 2.1));
const sun = new THREE.DirectionalLight(0xffffff, 2.4); sun.position.set(-100, 250, 150); scene.add(sun);
const pairs=bridgePairs(project.piers.map(p=>p.input),project.abutments??[]);
const entries = project.piers.map(p => ({ name: p.input.name, model: buildPier(p.input,{pairs}) }));
const pivot = entries[0].model.pivot, grid = project.groundModel?.terrainGrid;
const all = buildAllPiers(entries, grid); scene.add(all.piers); if (all.terrain) scene.add(all.terrain);
const anchors = entries.map(e => ({ name: e.name, kind: 'pier', point: new THREE.Vector3(e.model.pivot.e - pivot.e, e.model.copingRefTop + 1, -(e.model.pivot.n - pivot.n)) }));
for (const rec of project.abutments ?? []) {
const model = buildAbut(toAbutInput(rec));
const members = buildMembers(model, { bottomEl: rec.foundationBottomEl, pileLengthMm: rec.pileLengthMm }, rec.shape);
const soles = abutSoles(model, resolveBearingRule(rec.bearingRule), rec.bearingHeightMm, {
pairs,slopeI:rec.slopeI,
inputAzimuthDeg: Object.fromEntries(rec.bearings.filter(b => b.azimuthDeg !== undefined).map(b => [String(b.no), b.azimuthDeg!])),
});
scene.add(buildAbutGroup(model, members, buildWings(model, members, rec.wings, rec.wingDims), pivot, rec.groundEl, grid, rec.slab, soles, undefined, !!all.terrain));
anchors.push({ name: rec.name, kind: 'abutment', point: new THREE.Vector3(rec.ip2.e-pivot.e, Math.max(members.parapetTopStartEl, members.parapetTopEndEl)+1, -(rec.ip2.n-pivot.n)) });
}
const layers = project.groundModel?.mapLayers ?? [];
await Promise.all(layers.map(l => new Promise<void>((resolve, reject) => { const i = new Image(); i.onload = () => resolve(); i.onerror = reject; i.src = l.image; })));
let mapError = false;
const maps = buildTerrainMapGroup(layers, pivot, 0, 'satellite', () => { mapError = true; }, grid); scene.add(maps);
const deadline=performance.now()+15000;
while (!maps.children.every(m => {
const image=((m as THREE.Mesh).material as THREE.MeshBasicMaterial).map!.image as HTMLImageElement|null;
return image?.complete && image.naturalWidth>0;
})) {
if(mapError || performance.now()>deadline) throw new Error('Map texture loading failed or timed out');
await new Promise(resolve=>setTimeout(resolve,20));
}
if (mapError) throw new Error('Map texture loading failed');
scene.updateMatrixWorld(true);
const box = new THREE.Box3().setFromObject(scene), target = box.getCenter(new THREE.Vector3());
const axis = new THREE.Vector3(entries.at(-1)!.model.pivot.e-pivot.e, 0, -(entries.at(-1)!.model.pivot.n-pivot.n)).normalize();
if (endMarginM !== undefined) {
const ends=anchors.filter(a=>a.kind==='abutment').map(a=>a.point.dot(axis));
if(ends.length!==2)throw new Error('End margin requires both abutments');
const planes=refined ? roundedTerrainPlanes(axis,Math.min(...ends)-endMarginM,Math.max(...ends)+endMarginM,56,18) : [new THREE.Plane(axis.clone(),-Math.min(...ends)+endMarginM),new THREE.Plane(axis.clone().negate(),Math.max(...ends)+endMarginM)];
if(all.terrain)(all.terrain.material as THREE.Material).clippingPlanes=planes;
maps.children.forEach(m=>{((m as THREE.Mesh).material as THREE.Material).clippingPlanes=planes;});
}
const road=refined&&all.terrain ? buildRoadExtensions(all.terrain,anchors,axis,box.max.y) : null;
if(road)scene.add(road.group);
if(cutawayFrontM!==undefined){
const front=new THREE.Vector3(-axis.z,0,axis.x);
const plane=new THREE.Plane(front.negate(),cutawayFrontM);
for(const mesh of [all.terrain,...maps.children])if(mesh){
const material=(mesh as THREE.Mesh).material as THREE.Material;
material.clippingPlanes=[...(material.clippingPlanes??[]),plane];
}
}
scene.updateMatrixWorld(true);
const direction = new THREE.Vector3(-axis.z, elevation, axis.x).addScaledVector(axis, along).normalize();
const camera = new THREE.OrthographicCamera(-1, 1, 1, -1, .1, 10000);
camera.position.copy(target).addScaledVector(direction, 2000); camera.lookAt(target); camera.updateMatrixWorld(true);
const bounds = new THREE.Box3();
scene.traverse(obj => {
if (!(obj instanceof THREE.Mesh)) return;
for (let p: THREE.Object3D | null = obj; p; p=p.parent) if (!p.visible) return;
const pos = obj.geometry.getAttribute('position');
for (let i=0;i<pos.count;i++) {
const point=new THREE.Vector3().fromBufferAttribute(pos,i).applyMatrix4(obj.matrixWorld);
const material: THREE.Material=Array.isArray(obj.material)?obj.material[0]:obj.material;
if(material.clippingPlanes?.some(p=>p.distanceToPoint(point)<0))continue;
bounds.expandByPoint(point.applyMatrix4(camera.matrixWorldInverse));
}
});
const w = Math.max(bounds.max.x-bounds.min.x, (bounds.max.y-bounds.min.y)*aspect)*1.12, h=w/aspect;
const cx=(bounds.max.x+bounds.min.x)/2, cy=(bounds.max.y+bounds.min.y)/2;
camera.left=cx-w/2; camera.right=cx+w/2; camera.top=cy+h/2; camera.bottom=cy-h/2; camera.updateProjectionMatrix();
let terrainCanvas:HTMLCanvasElement|undefined;
if(smoothOutline){
const hidden=scene.children.filter(o=>o!==all.terrain&&o!==maps&&o!==road?.group&&!(o instanceof THREE.Light)&&o.visible);
hidden.forEach(o=>{o.visible=false;});renderer.render(scene,camera);
terrainCanvas=document.createElement('canvas');terrainCanvas.width=renderer.domElement.width;terrainCanvas.height=renderer.domElement.height;
terrainCanvas.getContext('2d')!.drawImage(renderer.domElement,0,0);
hidden.forEach(o=>{o.visible=true;});
}
renderer.render(scene, camera);
const points=anchors.map(a => { const p=a.point.clone().project(camera); return { name:a.name,kind:a.kind,x:+((p.x+1)*width/2).toFixed(2),y:+((1-p.y)*height/2).toFixed(2) }; }).sort((a,b)=>a.x-b.x);
const ordered=anchors.toSorted((a,b)=>a.point.clone().project(camera).x-b.point.clone().project(camera).x);
const ray=new THREE.Raycaster(), down=new THREE.Vector3(0,-1,0), spacing=(grid?.step??5)/2;
const terrainFlow: ({x:number;y:number;el:number;world:[number,number,number]}|null)[]=[];
if(!all.terrain)throw new Error('Terrain mesh is required for surface-following light');
for(let i=0;i<ordered.length-1;i++){
const from=ordered[i].point,to=ordered[i+1].point;
const steps=Math.ceil(Math.hypot(to.x-from.x,to.z-from.z)/spacing);
for(let j=i===0?0:1;j<=steps;j++){
const p=from.clone().lerp(to,j/steps);p.y=box.max.y+100;
ray.set(p,down);const hit=ray.intersectObject(all.terrain,false)[0];
if(!hit){terrainFlow.push(null);continue;}
const surface=hit.point.clone();surface.y+=.12;const projected=surface.clone().project(camera);
terrainFlow.push({x:+((projected.x+1)*width/2).toFixed(3),y:+((1-projected.y)*height/2).toFixed(3),el:+hit.point.y.toFixed(5),world:surface.toArray()});
}
}
const smooth=terrainCanvas?smoothLandingOutline(renderer.domElement,terrainCanvas,cutawayFrontM!==undefined):undefined;
const output=smooth?.canvas??renderer.domElement;
const result={png:output.toDataURL('image/png'),webp:output.toDataURL('image/webp',.94),metadata:{width,height,elevation,along,...(cutawayFrontM!==undefined?{cutawayFrontM}:{}),...(smooth?{outline:smooth.metadata}:{}),...(endMarginM!==undefined?{endMarginM}:{}),...(refined?{cornerRadiusM:18,terrainHalfWidthM:56,roads:road?.descriptions}:{}),points,terrainFlow,camera:{position:camera.position.toArray(),target:target.toArray(),left:camera.left,right:camera.right,top:camera.top,bottom:camera.bottom},piers:entries.length,abutments:project.abutments?.length??0}};
disposeTerrainMapGroup(maps);
scene.traverse(o=>{if(o instanceof THREE.Mesh){o.geometry.dispose();for(const m of Array.isArray(o.material)?o.material:[o.material])m.dispose();}});
renderer.dispose();renderer.forceContextLoss();return result;
}
+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 .workspace-pane > .viewer').scrollTop})`);
await click('#review-model-toggle');await click('#review-table');
await ev(`document.querySelector('#view-rebar > .results').scrollTop=250`);await cap('05-collapsed-table-material-1280');
report.end=await ev(`({modelHidden:getComputedStyle(document.querySelector('#slot-rebar')).display,expanded:document.querySelector('#review-model-toggle').getAttribute('aria-expanded'),focused:document.activeElement.tagName,frames:[...document.querySelectorAll('#view-rebar > .panel,#review-workspace,#review-workspace .workspace-pane > .viewer')].map(e=>({id:e.id,class:e.className,overflow:getComputedStyle(e).overflow,clientHeight:e.clientHeight,scrollHeight:e.scrollHeight,scrollTop:e.scrollTop}))})`);
report.finished=new Date().toISOString();fs.writeFileSync(path.join(out,'raw.json'),JSON.stringify(report,null,2));console.log(JSON.stringify(report));
await send('Page.close');ws.close();
})().catch(e=>{report.failure=String(e.stack||e);fs.writeFileSync(path.join(out,'raw.json'),JSON.stringify(report,null,2));console.error(e);if(ws)ws.close();process.exitCode=1});
+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();});
+102
View File
@@ -0,0 +1,102 @@
"""Read-only terrain diagnostics: grid slopes, isolated peaks, and source TIN."""
import json
import sys
from pathlib import Path
import numpy as np
from collections import Counter
ROOT=Path(__file__).resolve().parents[1]
sys.path.insert(0,str(ROOT/'pierzainer_ground'))
from pierzainer_ground.survey_terrain import read_survey_block
from pierzainer_ground.dxf_terrain import read_source_document, read_terrain_dxf
from pierzainer_ground.dtm import DTM
from pierzainer_ground.config import GroundConfig
out=ROOT/'.verify/ungok-terrain-review-20261110'
out.mkdir(parents=True,exist_ok=True)
folder=ROOT/'pierzainer_ground/data/output/app_b614afc0-6829-4aef-96e3-3ff1365ff5db'
data=json.loads((folder/'ground_model_3d_with_maps.json').read_text())
settings=json.loads((folder/'generation_settings.json').read_text())
g=data['terrain_grid']; z=np.array([np.nan if v is None else v for v in g['z']]).reshape(g['ny'],g['nx'])
edges=[]
for j in range(g['ny']):
for i in range(g['nx']):
for dj,di in [(0,1),(1,0)]:
if j+dj<g['ny'] and i+di<g['nx'] and np.isfinite(z[j,i]) and np.isfinite(z[j+dj,i+di]):
edges.append({'row':j,'col':i,'to_row':j+dj,'to_col':i+di,'z':float(z[j,i]),'to_z':float(z[j+dj,i+di]),'dz':float(abs(z[j+dj,i+di]-z[j,i]))})
edges.sort(key=lambda e:-e['dz'])
summary={'file':str(folder),'terrain':data['terrain'],'provenance':data.get('surface_provenance'),'grid':{k:v for k,v in g.items() if k!='z'},'valid_nodes':int(np.isfinite(z).sum()),'z_range':[float(np.nanmin(z)),float(np.nanmax(z))],'adjacent_edge_count':len(edges),'edges_over_5m':sum(e['dz']>5 for e in edges),'edges_over_8m':sum(e['dz']>8 for e in edges),'worst_edges':edges[:12]}
source=next((folder/'cache').glob('*.dxf'))
doc,_=read_source_document(source)
cfg=GroundConfig()
points,counts,_=read_survey_block(source,{'terrain_block':settings['terrain_block'],'terrain_crs':settings['terrain_crs'],'crs':settings['crs'],'cad_axes':settings['cad_axes'],'contour_step_m':cfg.contour_step,'dedup_m':cfg.dedup_xy,'z_min':settings['z_min'],'z_max':settings['z_max'],'exclude_zero':True,'prefer_recognized_layers':False},doc=doc)
lo=np.array(g['origin'])-settings['max_edge'];hi=np.array(g['origin'])+[(g['nx']-1)*g['step'],(g['ny']-1)*g['step']]+settings['max_edge']
points=points[np.all((points[:,:2]>=lo)&(points[:,:2]<=hi),axis=1)]
dtm=DTM(points,settings['max_edge'])
summary['source_counts']=counts
summary['raw_height_layers']=dict(Counter(e.dxf.layer for e in doc.blocks.get(settings['terrain_block']) if e.dxftype() in ('POLYLINE','LWPOLYLINE')))
# Counterfactual only: use the drawing's two contour layers, without replacing
# the generated user model or changing source geometry.
contours,contour_counts,_=read_survey_block(source,{'terrain_block':settings['terrain_block'],'terrain_crs':settings['terrain_crs'],'crs':settings['crs'],'cad_axes':settings['cad_axes'],'contour_step_m':cfg.contour_step,'dedup_m':cfg.dedup_xy,'z_min':settings['z_min'],'z_max':settings['z_max'],'exclude_zero':True,'layers':['CAA001','CAA002']},doc=doc)
contours=contours[np.all((contours[:,:2]>=lo)&(contours[:,:2]<=hi),axis=1)]
clean_dtm=DTM(contours,settings['max_edge'])
gx,gy=np.meshgrid(g['origin'][0]+np.arange(g['nx'])*g['step'],g['origin'][1]+np.arange(g['ny'])*g['step'])
clean=clean_dtm.z_many(np.column_stack([gx.ravel(),gy.ravel()])).reshape(z.shape)
clean_edges=np.concatenate([np.abs(np.diff(clean,axis=0)).ravel(),np.abs(np.diff(clean,axis=1)).ravel()])
clean_edges=clean_edges[np.isfinite(clean_edges)]
summary['contour_only_diagnostic']={'layers':['CAA001','CAA002'],'counts':contour_counts,'valid_nodes':int(np.isfinite(clean).sum()),'z_range':[float(np.nanmin(clean)),float(np.nanmax(clean))],'max_adjacent_dz':float(clean_edges.max()),'edges_over_5m':int((clean_edges>5).sum()),'edges_over_8m':int((clean_edges>8).sum()),'not_applied':True}
typed_cfg=GroundConfig(use_profile_points=False,z_min=settings['z_min'],z_max=settings['z_max'])
typed_cfg.allowed_terrain_layers=typed_cfg.layers.spot_block+typed_cfg.layers.spot_text+typed_cfg.layers.contour
typed=read_terrain_dxf(source,typed_cfg,document=doc)
typed_local=typed.points[np.all((typed.points[:,:2]>=lo)&(typed.points[:,:2]<=hi),axis=1)]
typed_dtm=DTM(typed_local,settings['max_edge'])
typed_grid=typed_dtm.z_many(np.column_stack([gx.ravel(),gy.ravel()])).reshape(z.shape)
typed_edges=np.concatenate([np.abs(np.diff(typed_grid,axis=0)).ravel(),np.abs(np.diff(typed_grid,axis=1)).ravel()])
typed_edges=typed_edges[np.isfinite(typed_edges)]
summary['typed_layer_diagnostic']={'layers':list(typed_cfg.allowed_terrain_layers),'counts':typed.stats['by_source'],'valid_nodes':int(np.isfinite(typed_grid).sum()),'z_range':[float(np.nanmin(typed_grid)),float(np.nanmax(typed_grid))],'max_adjacent_dz':float(typed_edges.max()),'edges_over_5m':int((typed_edges>5).sum()),'edges_over_8m':int((typed_edges>8).sum()),'covered_piers':sum(typed_dtm.z_at(p['X'],p['Y']) is not None for p in data['piers']),'not_applied':True}
summary['pier_diagnostic']=[]
for pier in data['piers']:
observed=clean_dtm.z_at(pier['X'],pier['Y'])
summary['pier_diagnostic'].append({'id':pier['id'],'generated':pier['ground']['center'],'contour_only':observed,'difference':None if observed is None else observed-pier['ground']['center']})
for e in summary['worst_edges']:
triangles=[]
for row,col in [(e['row'],e['col']),(e['to_row'],e['to_col'])]:
xy=np.array(g['origin'])+np.array([col,row])*g['step']; k=int(dtm.tri.find_simplex(xy-dtm.origin))
triangles.append({'xy':xy.tolist(),'vertices':points[dtm.tri.simplices[k]].tolist() if k>=0 else [],'predicted':dtm.z_at(*xy)})
e['source_triangles']=triangles
# Raw constant-height CAD entities retained in the selected TIN: look for
# closed design outlines vs survey contours near the worst interpolation.
raw=[]
for entity in doc.blocks.get(settings['terrain_block']):
if entity.dxftype() not in ('LWPOLYLINE','POLYLINE'):continue
pts=np.array([tuple(p) for p in (entity.vertices_in_wcs() if entity.dxftype()=='LWPOLYLINE' else entity.points_in_wcs())])
if len(pts)<2 or np.ptp(pts[:,2])>1e-6:continue
raw.append({'handle':entity.dxf.handle,'layer':entity.dxf.layer,'z':float(pts[0,2]),'vertices':len(pts),'closed':bool(entity.closed if entity.dxftype()=='LWPOLYLINE' else entity.is_closed),'xy_min':pts[:,:2].min(0).tolist(),'xy_max':pts[:,:2].max(0).tolist(),'xyz':pts.tolist()})
for e in summary['worst_edges'][:6]:
for tri in e['source_triangles']:
tri['entities']=[]
for north,east,height in tri['vertices']:
candidates=[]
for entity in raw:
if abs(entity['z']-height)>1e-3:continue
xy=np.array(entity['xyz'])[:,:2]; d=float(np.linalg.norm(xy-[east,north],axis=1).min())
candidates.append((d,entity))
candidates.sort(key=lambda r:r[0])
tri['entities'].append([{'distance_to_vertex':round(d,4),**{k:v for k,v in ent.items() if k!='xyz'}} for d,ent in candidates[:2]])
(out/'report.json').write_text(json.dumps(summary,ensure_ascii=False,indent=2))
print(json.dumps({k:v for k,v in summary.items() if k not in ('worst_edges','provenance')},ensure_ascii=False))
for e in summary['worst_edges'][:3]:print(json.dumps(e,ensure_ascii=False))
try:
import matplotlib
matplotlib.use('Agg')
import matplotlib.pyplot as plt
fig,axes=plt.subplots(1,3,figsize=(16,5),constrained_layout=True)
extent=[g['origin'][0],g['origin'][0]+(g['nx']-1)*g['step'],g['origin'][1],g['origin'][1]+(g['ny']-1)*g['step']]
for ax,values,title in [(axes[0],z,'Generated: all height-bearing layers'),(axes[1],typed_grid,'Diagnostic: survey spots + contour layers')]:
im=ax.imshow(values,origin='lower',extent=extent,vmin=20,vmax=47,aspect='auto',cmap='terrain');ax.set_title(title);ax.set_xlabel('Northing (m)');ax.set_ylabel('Easting (m)')
fig.colorbar(im,ax=axes[:2],label='EL (m)',shrink=.7)
row=summary['worst_edges'][0]['to_row'];axis=g['origin'][0]+np.arange(g['nx'])*g['step']
axes[2].plot(axis,z[row],label='Generated');axes[2].plot(axis,typed_grid[row],label='Survey spots + contours');axes[2].set_title(f'Transect E={g["origin"][1]+row*g["step"]:.3f} m');axes[2].set_xlabel('Northing (m)');axes[2].set_ylabel('EL (m)');axes[2].legend();axes[2].grid(alpha=.25)
fig.savefig(out/'comparison.png',dpi=150);plt.close(fig)
except ImportError:
print('Plot dependency unavailable; numeric report preserved.')
+64
View File
@@ -0,0 +1,64 @@
import { readFileSync, mkdirSync, writeFileSync } from 'node:fs';
import path from 'node:path';
import * as XLSX from 'xlsx';
import * as THREE from 'three';
import { it, expect } from 'vitest';
import { parseGroundModel } from '../src/core/ground-model';
import { parseWorkbook } from '../src/core/parseWorkbook';
import { buildPier } from '../src/core/geometry';
import { parseAbutments, toAbutInput } from '../src/core/abut/parse';
import { buildAbut } from '../src/core/abut/geometry';
import { buildMembers } from '../src/core/abut/members';
import { buildWings } from '../src/core/abut/wings';
import { buildAbutGroup } from '../src/view/abut-scene';
import { terrainGeometry } from '../src/view/terrain';
import { terrainMapGeometry } from '../src/view/terrain-map';
it('records terrain and map placement against the actual Okseong project', () => {
const folder = '/mnt/c/Users/User/Downloads/NAVER WORKS';
const ground = parseGroundModel(readFileSync(path.join(folder, '옥성교 지형.json'), 'utf8'));
const wb = XLSX.read(readFileSync(path.join(folder, '옥성교 데이터_261106.xlsx')), { type: 'buffer' });
const piers = parseWorkbook(wb).piers;
const abuts = parseAbutments(wb, piers.map(p => p.name)).abutments;
const pivot = buildPier(piers[0]).pivot, grid = ground.terrainGrid!;
const terrain = terrainGeometry(grid, pivot), pos = terrain.getAttribute('position');
const layers = ground.mapLayers!.map(layer => {
const g = terrainMapGeometry(layer, pivot, 0, grid);
return { id: layer.id, corners: layer.survey_corners, vertices: g.getAttribute('position').count, triangles: g.getIndex()!.count / 3 };
});
const duplicates = abuts.map(rec => {
const model = buildAbut(toAbutInput(rec));
const members = buildMembers(model, { bottomEl: rec.foundationBottomEl, pileLengthMm: rec.pileLengthMm }, rec.shape);
const root = buildAbutGroup(model, members, buildWings(model, members, rec.wings, rec.wingDims), pivot, rec.groundEl, grid, rec.slab);
const shared = buildAbutGroup(model, members, buildWings(model, members, rec.wings, rec.wingDims), pivot, rec.groundEl, grid, rec.slab, undefined, undefined, true);
expect(shared.getObjectByName('ground')).toBeUndefined();
root.updateMatrixWorld(true);
const meshes: object[] = [];
root.getObjectByName('ground')?.traverse(obj => {
if (!(obj instanceof THREE.Mesh)) return;
const vertices = obj.geometry.getAttribute('position');
let maxDisplacement = 0;
if (vertices.count === pos.count) for (let i = 0; i < vertices.count; i++) {
const actual = new THREE.Vector3().fromBufferAttribute(vertices, i).applyMatrix4(obj.matrixWorld);
const expected = new THREE.Vector3().fromBufferAttribute(pos, i);
maxDisplacement = Math.max(maxDisplacement, actual.distanceTo(expected));
}
meshes.push({ vertices: vertices.count, triangles: obj.geometry.getIndex()?.count! / 3, sameGrid: vertices.count === pos.count, maxDisplacementM: maxDisplacement });
});
const gx = (rec.ip2.n - grid.origin[0]) / grid.step, gy = (rec.ip2.e - grid.origin[1]) / grid.step;
const i = Math.floor(gx), j = Math.floor(gy);
const inside = i >= 0 && j >= 0 && i + 1 < grid.nx && j + 1 < grid.ny;
const heights = inside ? [grid.z[j * grid.nx + i], grid.z[j * grid.nx + i + 1], grid.z[(j + 1) * grid.nx + i], grid.z[(j + 1) * grid.nx + i + 1]] : [];
const valid = heights.length === 4 && heights.every(h => h !== null);
const u = gx - i, v = gy - j;
const sampledAtIp2 = valid ? heights[0]! * (1-u) * (1-v) + heights[1]! * u * (1-v) + heights[2]! * (1-u) * v + heights[3]! * u * v : null;
return { abutment: rec.name, excelGroundEl: rec.groundEl, ip2: rec.ip2, modelEntry: ground.piers.some(p => p.id === rec.name), ip2GridSample: sampledAtIp2, meshes };
});
const values = grid.z.filter((v): v is number => v !== null);
const report = { grid: { origin: grid.origin, step: grid.step, nx: grid.nx, ny: grid.ny, valid: values.length, total: grid.z.length, min: Math.min(...values), max: Math.max(...values) }, terrain: { vertices: pos.count, triangles: terrain.getIndex()!.count / 3 }, layers, duplicates };
const out = path.resolve('.verify/terrain-review-20261109'); mkdirSync(out, { recursive: true });
writeFileSync(path.join(out, 'report.json'), JSON.stringify(report, null, 2));
console.log(JSON.stringify(report));
expect(layers.every(layer => layer.triangles === report.terrain.triangles)).toBe(true);
expect(duplicates.every(d => d.meshes.every(m => (m as { maxDisplacementM: number }).maxDisplacementM < 0.001))).toBe(true);
});
+55
View File
@@ -0,0 +1,55 @@
"""Apply the project ground manager integration to the existing application."""
from pathlib import Path
root = Path(__file__).resolve().parents[1]
p = root / 'src/main.ts'
s = p.read_text()
s = "import { createProjectGroundManager } from './view/project-ground';\nimport { applyProjectGround, groundInputChanged } from './core/project-ground';\nimport { registerGroundImporter } from '../pierzainer_ground/ground-link';\n" + s
s = s.replace('let groundModel: GroundModelFile | null = null;', 'let groundModel: GroundModelFile | null = null;\nlet groundProjectVersion = 0;\nlet groundImportSerial = 0;')
marker = 'function el<K extends keyof HTMLElementTagNameMap>'
insert = '''async function importProjectGround(text: string, name: string): Promise<void> {
const version = groundProjectVersion, serial = ++groundImportSerial;
const imported = parseGroundModel(text);
await Promise.all((imported.mapLayers ?? []).map(decodeGroundMapImage));
if (version !== groundProjectVersion || serial !== groundImportSerial) throw new Error('프로젝트 또는 불러올 모델이 변경되어 이전 결과를 적용하지 않았습니다.');
groundModel = imported;
groundMapMode = imported.mapLayers?.length ? 'satellite' : 'none';
setGroundMapMode(groundMapMode);
state.visible.ground = true;
setupScene(); renderAll();
setStatus(`${name} 지반모델을 프로젝트 공통으로 불러왔습니다. 설계 입력은 비교 후 적용하세요.`);
}
registerGroundImporter(importProjectGround);
const projectGround = createProjectGroundManager({
snapshot: () => ({ model: groundModel, piers: state.piers }),
importModel: importProjectGround,
apply: (names, fields) => {
if (!groundModel) return;
for (const p of state.piers) if (names.has(p.input.name)) p.draft = applyProjectGround(p.draft, groundModel, fields);
renderAll();
},
regenerate: () => {
state.piers = state.piers.map(p => groundInputChanged(p.input, p.draft) ? regenerateEntry(p) : p);
setupScene(); renderAll();
setStatus('지반 입력이 변경된 교각을 다시 생성했습니다. 생성 상태와 알림을 확인하세요.');
},
});
function resetProjectGround(): void {
groundProjectVersion++; groundImportSerial++;
projectGround.reset();
window.dispatchEvent(new Event('pierzainer:project-changed'));
}
'''
assert marker in s
s = s.replace(marker, insert + marker, 1)
s = s.replace(' updatePartToggles();\n $<HTMLButtonElement>', ' updatePartToggles();\n projectGround.refresh();\n $<HTMLButtonElement>', 1)
start = s.index(' // ② 지반 모델 (승인 2026-10-23):')
end = s.index(' if (!groundModel) {', start)
s = s[:start] + " wrap.appendChild(el('p', '프로젝트 공통 지반모델은 ① 형상의 [프로젝트 지반모델]에서 생성·불러오기·선택 적용합니다.', 'muted small'));\n\n" + s[end:]
s = s.replace(' * The ground model import and the 지반고 apply stay at the bottom, as before.', ' * Project model generation/import is managed from ①; this card retains local inputs.')
s = s.replace(' state.project = { ...parsed.project };', ' resetProjectGround();\n state.project = { ...parsed.project };', 1)
marker = " state.project = { ...state.project, name, bridge: $<HTMLInputElement>('bridge-name').value.trim() };"
assert marker in s
s = s.replace(marker, " resetProjectGround();\n groundModel = null;\n groundMapMode = 'none';\n setGroundMapMode(groundMapMode);\n" + marker, 1)
p.write_text(s)
+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 .workspace-pane > section').getBoundingClientRect().height`);
await send('Page.enable'); await send('Runtime.enable'); await send('DOM.enable');
await send('Emulation.setDeviceMetricsOverride', { width: 1600, height: 1000, deviceScaleFactor: 1, mobile: false });
await until(`!!document.querySelector('[data-card="excel"] button') && typeof window.loadBytesToApp === 'function'`);
await click(`document.querySelector('[data-card="excel"] button')`);
await ev(`document.getElementById('project-name').value = '디자인 개선 검증'; document.getElementById('project-name').dispatchEvent(new Event('input'));`);
await upload();
check('combined import scope', await ev(`document.getElementById('dlg-apply').textContent === '교각 1기 + 교대 1기 가져오기'`));
check('A1 source and dimensions preview', await ev(`document.getElementById('pv-abuts').textContent.includes('A1') && document.getElementById('pv-abuts').textContent.includes('12.197') && document.querySelector('#sheets > li:first-child').textContent.includes('교대 인식 결과')`));
await shot('01-import-both');
await click(`document.getElementById('import-abut-A1')`);
check('uncheck updates preview and count', await ev(`document.getElementById('dlg-apply').textContent.includes('교대 0기') && document.getElementById('pv-abuts').textContent.includes('아니오')`));
await click(`document.getElementById('dlg-apply')`);
check('unchecked abutment is not applied', await ev(`![...document.querySelectorAll('#pier-items button')].some(b => b.textContent.includes('교대'))`));
await click(`document.getElementById('open-import')`);
await upload();
await click(`document.getElementById('dlg-apply')`);
await click(`document.getElementById('nav-rebar')`);
await click(`[...document.querySelectorAll('#rebar-pier-items button')].find(b => b.textContent.includes('A1') && b.textContent.includes('교대'))`);
await member('확대기초');
check('A1 footing excludes pier review questions', await ev(`document.getElementById('review-summary').textContent.includes('A1') && document.getElementById('review-summary').textContent.includes('미결 0건') && !document.getElementById('review-tasks').textContent.includes('Q-C9')`));
await delay(4500); await shot('02-workspace-desktop');
await send('Emulation.setDeviceMetricsOverride', { width: 1280, height: 800, deviceScaleFactor: 1, mobile: false });
await delay(300);
if (await ev(`document.getElementById('review-model-toggle').getAttribute('aria-expanded') === 'false'`)) await click(`document.getElementById('review-model-toggle')`);
await ev(`const size = document.getElementById('review-model-height'); size.value = size.max; size.dispatchEvent(new Event('input'));`);
await delay(200);
report.layoutBefore = await lower();
await ev(`const range = document.getElementById('review-model-height'); range.value = '180'; range.dispatchEvent(new Event('input'));`);
await delay(200);
report.layoutResized = await lower();
check('height control expands evidence area', report.layoutResized >= report.layoutBefore);
check('compact viewer tools stay inside model', await ev(`document.querySelector('.viewer-stack-right').getBoundingClientRect().bottom <= document.getElementById('viewer-block').getBoundingClientRect().bottom + 1`));
await click(`document.getElementById('review-model-toggle')`);
await delay(200);
report.layoutCollapsed = await lower();
check('collapse adds model area to evidence', report.layoutCollapsed > report.layoutResized + 150);
await click(`document.getElementById('review-table')`);
check('table shortcut focuses and reveals table', await ev(`document.activeElement.matches('#rebar-table table.rebar-table') && document.activeElement.getBoundingClientRect().top >= document.querySelector('#review-workspace .workspace-pane > section').getBoundingClientRect().top - 2`));
await shot('03-table-laptop');
await click(`document.getElementById('review-inputs')`);
check('input shortcut preserves keyboard tab order', await ev(`document.activeElement.matches('#rebar-table input') && document.activeElement.tabIndex === 0`));
await shot('04-inputs-laptop');
check('layout preference persisted', await ev(`JSON.parse(localStorage.getItem('pierzainer.review-layout')).collapsed === true`));
await click(`document.getElementById('review-model-toggle')`);
await member('받침블럭');
check('block review is scoped', await ev(`document.getElementById('review-tasks').textContent.includes('Q-S4') && !document.getElementById('review-tasks').textContent.includes('Q-C9')`));
await click(`document.querySelector('#review-tasks button')`);
check('review action selects correct bar and row', await ev(`document.querySelector('#rebar-table .rebar-select').textContent.includes('선택 1 /') && document.activeElement.matches('tr') && document.activeElement.textContent.includes('S1')`));
await member('날개벽');
check('wing review shows only relevant unresolved work', await ev(`document.getElementById('review-summary').textContent.includes('미결 2건') && document.getElementById('review-tasks').textContent.includes('E19') && document.getElementById('review-tasks').textContent.includes('V-A2')`));
await shot('05-review-and-materials');
report.fields = await ev(`[...document.querySelectorAll('.material-fields .mat')].map(label => { const r = label.getBoundingClientRect(); const c = label.querySelector('input,select').getBoundingClientRect(); return { label: label.textContent, left: r.left, top: r.top, width: r.width, controlLeft: c.left, controlRight: c.right, bottom: r.bottom }; })`);
check('material fields align without overlap', report.fields.every((f, i, list) => f.controlRight <= f.left + f.width + 1 && (i === 0 || f.top >= list[i - 1].bottom)));
check('document has no horizontal overflow at laptop width', await ev('document.documentElement.scrollWidth === innerWidth'));
await send('Emulation.setDeviceMetricsOverride', { width: 1600, height: 1000, deviceScaleFactor: 1, mobile: false });
await click(`[...document.querySelectorAll('#rebar-pier-items button')].find(b => b.textContent.includes('P1') && !b.textContent.includes('교대'))`);
await member('기둥');
check('existing decision is not presented as unresolved', await ev(`document.getElementById('review-tasks').textContent.includes('Q-C9') && document.getElementById('review-tasks').textContent.includes('결정됨') && document.getElementById('review-summary').textContent.includes('미결 0건')`));
check('no runtime errors', report.errors.length === 0);
report.result = 'PASS';
}
main().catch((error) => { report.result = 'FAIL'; report.failure = error.stack; process.exitCode = 1; }).finally(async () => {
if (report.result === 'FAIL' && send) {
try { const shot = await send('Page.captureScreenshot', { format: 'png' }); fs.writeFileSync(path.join(out, 'failure.png'), Buffer.from(shot.data, 'base64')); } catch { /* preserve original failure */ }
}
report.finished = new Date().toISOString();
fs.writeFileSync(path.join(out, 'report.json'), JSON.stringify(report, null, 2));
console.log(JSON.stringify({ result: report.result, checks: report.checks.length, errors: report.errors, failure: report.failure }));
if (send) { try { await send('Page.close'); } catch { /* tab may already be closed */ } }
ws?.close();
});
+95
View File
@@ -0,0 +1,95 @@
// Run with Windows Node and an isolated Chrome on CDP 9444; Vite must be on 5173.
const fs = require('node:fs'), path = require('node:path'), assert = require('node:assert/strict');
const out = path.resolve(__dirname, '../.verify/ground-abutments-20261109');
fs.mkdirSync(out, { recursive: true });
const report = { checks: [], errors: [] };
let ws, send;
const delay = ms => new Promise(r => setTimeout(r, ms));
(async () => {
const target = await (await fetch('http://127.0.0.1:9444/json/new?about:blank', { method: 'PUT' })).json();
ws = new WebSocket(target.webSocketDebuggerUrl);
await new Promise((r, j) => { ws.onopen = r; ws.onerror = j; });
let seq = 0; const pending = new Map();
ws.onmessage = ({ data }) => {
const m = JSON.parse(data), p = pending.get(m.id);
if (p) { clearTimeout(p.timer); pending.delete(m.id); m.error ? p.reject(new Error(JSON.stringify(m.error))) : p.resolve(m.result); }
if (m.method === 'Runtime.exceptionThrown') report.errors.push(m.params.exceptionDetails.exception?.description || m.params.exceptionDetails.text);
};
send = (method, params = {}) => new Promise((resolve, reject) => {
const id = ++seq, timer = setTimeout(() => reject(new Error('Timeout ' + method)), 30000);
pending.set(id, { resolve, reject, timer }); ws.send(JSON.stringify({ id, method, params }));
});
const ev = async expression => {
const r = await send('Runtime.evaluate', { expression, awaitPromise: true, returnByValue: true });
if (r.exceptionDetails) throw new Error(r.exceptionDetails.exception?.description || r.exceptionDetails.text);
return r.result.value;
};
const until = async expression => {
for (let i = 0; i < 120; i++) { if (await ev(expression)) return; await delay(150); }
throw new Error('Wait ' + expression);
};
const check = (name, value) => { assert.ok(value, name); report.checks.push(name); console.log('PASS', name); };
const click = expression => ev(`(()=>{const e=${expression};if(!e||e.disabled)throw new Error('Missing target');e.click();})()`);
const shot = async name => {
const r = await send('Page.captureScreenshot', { format: 'png' });
fs.writeFileSync(path.join(out, name + '.png'), Buffer.from(r.data, 'base64'));
};
await send('Page.enable'); await send('Runtime.enable'); await send('DOM.enable');
await send('Emulation.setDeviceMetricsOverride', { width: 1600, height: 1000, deviceScaleFactor: 1, mobile: false });
await send('Page.navigate', { url: process.env.PIERZAINER_URL || 'http://127.0.0.1:5173/' });
await until(`typeof window.openSampleProject==='function'`);
const selectFile=async(selector,files)=>{
const doc=await send('DOM.getDocument'),{nodeId}=await send('DOM.querySelector',{nodeId:doc.root.nodeId,selector});
await send('DOM.setFileInputFiles',{nodeId,files});
};
const folder='C:\\Users\\User\\Downloads\\NAVER WORKS';
await ev(`window.openSampleProject()`);
await selectFile('#file',[path.join(folder,'옥성교 데이터_261106.xlsx')]);
await until(`document.getElementById('file-name').textContent.includes('선택된 파일: 옥성교 데이터_261106.xlsx')`);
await ev(`(()=>{const e=document.getElementById('project-name');e.value='교대 지반 검증';e.dispatchEvent(new Event('input'));document.getElementById('import-dialog').showModal();})()`);
await click(`document.getElementById('dlg-apply')`);
await click(`document.getElementById('nav-shape')`);
await click(`document.getElementById('project-ground-open')`);
check('manager lists six piers and both abutments before model import',await ev(`document.querySelectorAll('[data-ground-pier]').length===8&&document.querySelectorAll('[data-ground-kind="교대"]').length===2`));
await selectFile('#ground-import',[path.join(folder,'옥성교 지형.json')]);
await until(`document.getElementById('project-ground-status').textContent.includes('8/8')`);
check('both abutments show IP2 grid source and finite comparison elevations',await ev(`[...document.querySelectorAll('[data-ground-kind="교대"]')].every(e=>e.closest('tr').textContent.includes('IP2 격자 보간')&&!e.disabled)`));
const values=()=>ev(`[...document.querySelectorAll('.project-ground-table tbody tr')].map(r=>({name:r.children[1].textContent,el:Number(r.children[2].textContent),model:Number(r.children[3].textContent)}))`);
const before=await values();
check('import alone preserves original abutment inputs',before.find(r=>r.name==='A1').el===11.24&&before.find(r=>r.name==='A2').el===14.43);
await click(`document.querySelector('[data-ground-pier="A1"]')`);
await click(`document.getElementById('project-ground-apply')`);
const one=await values();
check('only selected A1 changes; A2 and P1 remain unchanged',one.find(r=>r.name==='A1').el===11.789&&one.find(r=>r.name==='A2').el===14.43&&one.find(r=>r.name==='P1').el===before.find(r=>r.name==='P1').el);
await click(`document.getElementById('project-ground-select-all')`);
check('select all includes both types',await ev(`document.querySelectorAll('[data-ground-pier]:checked').length===8`));
await click(`document.getElementById('project-ground-apply')`);
const applied=await values();
check('A2 receives IP2 interpolation',applied.find(r=>r.name==='A2').el===14.61);
check('pier regeneration remains explicit',await ev(`!document.getElementById('project-ground-regenerate').disabled`));
await click(`document.getElementById('project-ground-regenerate')`);
check('pending pier changes regenerate',await ev(`document.getElementById('project-ground-regenerate').disabled`));
await click(`document.getElementById('project-ground-el')`);
await click(`document.getElementById('project-ground-layers')`);
check('missing abutment soil layers cannot be applied',await ev(`[...document.querySelectorAll('[data-ground-kind="교대"]')].every(e=>e.disabled)`));
await click(`document.getElementById('project-ground-el')`);
await click(`document.getElementById('project-ground-layers')`);
await shot('01-unified-ground');
await click(`document.querySelector('#project-ground-dialog .viewer-head button')`);
await shot('02-shared-terrain');
await ev(`(()=>{const original=URL.createObjectURL;window.__saved=null;URL.createObjectURL=function(blob){blob.text().then(text=>{try{window.__saved=JSON.parse(text);}catch{}});return original.call(this,blob);};})()`);
await send('Browser.setDownloadBehavior',{behavior:'deny'});
await click(`document.getElementById('save-project')`);
await until(`document.getElementById('save-dialog').open`);
await click(`document.getElementById('sv-save')`);
await until(`!!window.__saved?.abutments`);
const saved=await ev(`window.__saved`);
check('saved project retains both abutment elevations and Excel source',Math.abs(saved.abutments.find(a=>a.name==='A1').groundEl-11.7886759049651)<1e-6&&Math.abs(saved.abutments.find(a=>a.name==='A2').groundEl-14.610314099767017)<1e-6&&saved.source.fileName==='옥성교 데이터_261106.xlsx');
const savedPath=path.join(out,'saved.pzr.json');fs.writeFileSync(savedPath,JSON.stringify(saved));
await selectFile('#project-file',[savedPath]);
await click(`document.getElementById('project-ground-open')`);
check('reopening project restores abutment inputs', (await values()).filter(r=>r.name==='A1'||r.name==='A2').every(r=>Math.abs(r.el-r.model)<0.001));
check('no runtime exceptions',report.errors.length===0);report.result='PASS';
})().catch(e=>{report.result='FAIL';report.failure=e.stack;process.exitCode=1;}).finally(()=>{
fs.writeFileSync(path.join(out,'report.json'),JSON.stringify(report,null,2));console.log(JSON.stringify(report));ws?.close();
});
+168
View File
@@ -0,0 +1,168 @@
// Run with Windows Node and an isolated Chrome on CDP 9444; Vite must be on 5173.
const fs = require('node:fs'), path = require('node:path'), assert = require('node:assert/strict');
const out = path.resolve(__dirname, '../.verify/ground-import-20261109');
fs.mkdirSync(out, { recursive: true });
const report = { checks: [], errors: [] };
let ws, send;
const delay = ms => new Promise(r => setTimeout(r, ms));
(async () => {
const target = await (await fetch('http://127.0.0.1:9444/json/new?about:blank', { method: 'PUT' })).json();
ws = new WebSocket(target.webSocketDebuggerUrl);
await new Promise((r, j) => { ws.onopen = r; ws.onerror = j; });
let seq = 0; const pending = new Map();
ws.onmessage = ({ data }) => {
const m = JSON.parse(data), p = pending.get(m.id);
if (p) { clearTimeout(p.timer); pending.delete(m.id); m.error ? p.reject(new Error(JSON.stringify(m.error))) : p.resolve(m.result); }
if (m.method === 'Runtime.exceptionThrown') report.errors.push(m.params.exceptionDetails.exception?.description || m.params.exceptionDetails.text);
};
send = (method, params = {}) => new Promise((resolve, reject) => {
const id = ++seq, timer = setTimeout(() => reject(new Error('Timeout ' + method)), 30000);
pending.set(id, { resolve, reject, timer }); ws.send(JSON.stringify({ id, method, params }));
});
const ev = async expression => {
const r = await send('Runtime.evaluate', { expression, awaitPromise: true, returnByValue: true });
if (r.exceptionDetails) throw new Error(r.exceptionDetails.exception?.description || r.exceptionDetails.text);
return r.result.value;
};
const until = async expression => {
for (let i = 0; i < 120; i++) { if (await ev(expression)) return; await delay(150); }
throw new Error('Wait ' + expression);
};
const check = (name, value) => { assert.ok(value, name); report.checks.push(name); console.log('PASS', name); };
const click = expression => ev(`(()=>{const e=${expression};if(!e||e.disabled)throw new Error('Missing target');e.click();})()`);
const shot = async name => {
const r = await send('Page.captureScreenshot', { format: 'png' });
fs.writeFileSync(path.join(out, name + '.png'), Buffer.from(r.data, 'base64'));
};
await send('Page.enable'); await send('Runtime.enable'); await send('DOM.enable');
await send('Emulation.setDeviceMetricsOverride', { width: 1600, height: 1000, deviceScaleFactor: 1, mobile: false });
await send('Page.navigate', { url: process.env.PIERZAINER_URL || 'http://127.0.0.1:5173/' });
await until(`typeof window.openSampleProject==='function'`);
const fixture = await ev(`(async()=>{
const XLSX=await import('/node_modules/xlsx/xlsx.mjs'),{parseWorkbook}=await import('/src/core/parseWorkbook.ts');
const bytes=await(await fetch('/'+encodeURIComponent('도면추출')+'/samples/'+encodeURIComponent('운곡교P1입력.xlsx'))).arrayBuffer();
const base=parseWorkbook(XLSX.read(bytes)).piers.find(p=>p.name==='P1');
if(!base)throw new Error('Missing sample pier');
const {defaultLevelWidthMm}=await import('/src/core/geometry.ts');
const piers=['직접기초','직접기초','현장타설말뚝'].map((foundationType,i)=>{const input={...base,name:'P'+(i+1),foundationType,type:'T형',columnCount:1,groundEl:30,pileLengthMm:24000,castInPlaceShape:{clayBottomEl:16},columnDiameterMm:2000,footingLengthMm:10400,footingWidthMm:8150,footingHeightMm:2000,blindingMarginMm:100,blindingThicknessMm:100,pileKind:'강관',pileOuterMm:609.6,pileThicknessMm:12,copingH1Mm:1000,copingH2Mm:1500,levelWidthMm:defaultLevelWidthMm('T형',2000)};return{id:input.name,input,defaults:input,draft:input};});
return{schemaVersion:1,project:{name:'병합 검증',bridge:'검증',client:'',author:'',crs:'',memo:''},source:{fileName:'합성 표본',sheets:[]},piers,history:[]};
})()`);
const file = path.join(out, 'fixture.pzr.json'); fs.writeFileSync(file, JSON.stringify(fixture));
await ev('window.openSampleProject()');
await click(`document.querySelector('#file-menu summary')`); await click(`document.getElementById('open-project')`);
const doc = await send('DOM.getDocument');
const { nodeId } = await send('DOM.querySelector', { nodeId: doc.root.nodeId, selector: '#project-file' });
await send('DOM.setFileInputFiles', { nodeId, files: [file] });
await until(`document.querySelectorAll('#rebar-pier-items button').length===3`);
const selectFile = async (selector, files) => {
const doc = await send('DOM.getDocument');
const {nodeId} = await send('DOM.querySelector', {nodeId:doc.root.nodeId, selector});
await send('DOM.setFileInputFiles', {nodeId, files});
};
await click(`document.getElementById('nav-shape')`);
check('shape has a visible project ground entry', await ev(`document.getElementById('project-ground-open').getBoundingClientRect().height>0`));
await click(`document.getElementById('project-ground-open')`);
check('manager opens without per-pier controls', await ev(`document.getElementById('project-ground-dialog').open&&document.querySelectorAll('#ground-import').length===1`));
const modelFile=path.join(out,'ground.json');
const raw={schema:2,generated:'2026-11-09',piers:[{id:'P1',ground:{center:29,excel:30},soil:{layers:[{name:'암반',bottom_el:10}]}},{id:'P2',ground:{center:null,excel:30}}]};
fs.writeFileSync(modelFile,JSON.stringify(raw));
await selectFile('#ground-import',[modelFile]);
await until(`document.getElementById('project-ground-info').textContent.includes('2026-11-09')`);
check('matching, out-of-range and missing piers are distinguished',await ev(`document.getElementById('project-ground-dialog').textContent.includes('지형 범위 밖')&&document.getElementById('project-ground-dialog').textContent.includes('모델에 없음')&&document.querySelector('[data-ground-pier="P2"]').disabled&&document.querySelector('[data-ground-pier="P3"]').disabled`));
check('import alone keeps current design input',await ev(`document.querySelector('.project-ground-table tbody tr').children[2].textContent==='30.000'`));
await click(`document.querySelector('[data-ground-pier="P1"]')`);
await click(`document.getElementById('project-ground-apply')`);
check('selected ground input applies and requires regeneration',await ev(`document.querySelector('.project-ground-table tbody tr').children[2].textContent==='29.000'&&!document.getElementById('project-ground-regenerate').disabled`));
await click(`document.getElementById('project-ground-regenerate')`);
check('regeneration consumes ground changes',await ev(`document.getElementById('project-ground-regenerate').disabled&&document.querySelector('.project-ground-table tbody tr').textContent.includes('지반고 일치')`));
await click(`document.getElementById('project-ground-el')`);
await click(`document.getElementById('project-ground-layers')`);
await click(`document.getElementById('project-ground-apply')`);
check('layers can be applied separately',await ev(`!document.getElementById('project-ground-regenerate').disabled&&document.querySelector('.project-ground-table tbody tr').children[2].textContent==='29.000'`));
await click(`document.getElementById('project-ground-regenerate')`);
await shot('01-project-ground');
await click(`document.querySelector('#project-ground-dialog .viewer-head button')`);
await click(`document.getElementById('nav-plan')`);
await click(`[...document.querySelectorAll('#members button')].find(b=>b.textContent.includes('지반'))`);
check('per-pier ground card has no model generation or file picker',await ev(`!document.getElementById('view-plan').textContent.includes('지반 모델 파일 불러오기 · 지반 생성 프로그램')&&document.querySelectorAll('#view-plan input[type=file]').length===0`));
await ev(`(()=>{window.__savedGround=null;const original=URL.createObjectURL;URL.createObjectURL=function(blob){blob.text().then(text=>{try{window.__savedGround=JSON.parse(text);}catch{}});return original.call(this,blob);};})()`);
await click(`document.getElementById('save-project')`);
await until(`!!window.__savedGround?.groundModel`);
const saved=await ev(`window.__savedGround`);
check('project saves shared model once and applied inputs',saved.groundModel.piers.length===2&&saved.piers[0].input.groundEl===29&&saved.piers[0].input.strataRows[0].name==='암반'&&saved.piers[1].input.groundEl===30);
const savedFile=path.join(out,'saved.pzr.json');fs.writeFileSync(savedFile,JSON.stringify(saved));
await selectFile('#project-file',[savedFile]);
await until(`document.getElementById('project-ground-status').textContent.includes('불러옴')`);
check('project reopen restores ground model',await ev(`document.getElementById('project-ground-status').textContent.includes('2/3')`));
// Mock the external engine; deliver an in-flight result after switching projects.
await ev(`(()=>{const original=window.fetch;window.__releaseGround=null;window.__groundReady=false;window.fetch=async(url,init)=>{const path=String(url);if(path.includes('/ground-link/prepare'))return new Response(JSON.stringify({drawing:'test.dxf',excelName:'test.xlsx',excelSelected:true}));if(path.includes('/ground-link/upload'))return new Response(JSON.stringify({file:'test.xlsx'}));if(path.includes('/ground-link/launch'))return new Response(JSON.stringify({id:'mock'}));if(path.includes('/ground-link/result')){window.__groundReady=true;return new Promise(resolve=>window.__releaseGround=()=>resolve(new Response(JSON.stringify({ready:true,state:'done',filename:'old.json',text:${JSON.stringify(JSON.stringify({...raw,generated:'mock-generated'}))}}))));}return original(url,init);};})()`);
const excel=path.join(out,'source.xlsx');fs.writeFileSync(excel,'mock');
const startGeneration = async () => {
await ev(`window.__groundReady=false`);
await click(`document.getElementById('nav-shape')`);
await click(`document.getElementById('project-ground-open')`);
await click(`[...document.querySelectorAll('#project-ground-dialog button')].find(b=>b.textContent==='지반모델 생성')`);
await until(`document.querySelector('.ground-popup [data-run]')&&!document.querySelector('.ground-popup [data-run]').disabled`);
await selectFile('#ground-excel-file',[excel]);
await click(`document.querySelector('.ground-popup [data-run]')`);
await until(`window.__groundReady`);
};
await startGeneration();
await click(`document.querySelector('.ground-popup [data-close]')`);
await click(`document.querySelector('#project-ground-dialog .viewer-head button')`);
await click(`document.getElementById('nav-rebar')`);
await ev(`window.__releaseGround()`);
await until(`document.querySelector('.ground-popup [data-status]').textContent.includes('생성 완료')`);
await click(`document.getElementById('nav-shape')`);
await click(`document.getElementById('project-ground-open')`);
check('generated model imports after leaving both manager and ground card',await ev(`document.getElementById('project-ground-info').textContent.includes('mock-generated')`));
await click(`document.querySelector('#project-ground-dialog .viewer-head button')`);
await startGeneration();
await selectFile('#project-file',[file]);
await until(`document.getElementById('project-ground-status').textContent==='모델 없음'`);
await ev(`window.__releaseGround()`);
await delay(300);
check('late generated result does not contaminate another project',await ev(`document.getElementById('project-ground-status').textContent==='모델 없음'`));
check('project switch closes manager and generator',await ev(`!document.getElementById('project-ground-dialog').open&&!document.querySelector('.ground-popup').open`));
await selectFile('#project-file',[savedFile]);
await until(`document.getElementById('project-ground-status').textContent.includes('불러옴')`);
await ev(`(async()=>{const bytes=await(await fetch('/'+encodeURIComponent('도면추출')+'/samples/'+encodeURIComponent('운곡교P1입력.xlsx'))).arrayBuffer();window.loadBytesToApp(new Uint8Array(bytes),'새 프로젝트.xlsx');const name=document.getElementById('project-name');name.value='새 프로젝트';name.dispatchEvent(new Event('input'));document.getElementById('import-dialog').showModal();})()`);
await until(`document.getElementById('import-dialog').open&&!document.getElementById('dlg-apply').disabled`);
await click(`document.getElementById('dlg-apply')`);
await until(`document.getElementById('project-ground-status').textContent==='모델 없음'`);
check('new Excel import clears previous project ground model',await ev(`document.getElementById('project-ground-status').textContent==='모델 없음'`));
// Real generated JSON and the adjacent settings file reproduce the reported failure.
const actualFolder=path.resolve(__dirname,'../pierzainer_ground/data/output/drawing_01_옥성교_종평면도');
const actualModel=path.join(actualFolder,'ground_model.json');
const expected=JSON.parse(fs.readFileSync(actualModel,'utf8'));
const six={...fixture,piers:Array.from({length:6},(_,i)=>{
const input={...fixture.piers[0].input,name:'P'+(i+1)};
return{id:input.name,input,defaults:input,draft:input};
})};
const sixFile=path.join(out,'six-piers.pzr.json');fs.writeFileSync(sixFile,JSON.stringify(six));
await selectFile('#project-file',[sixFile]);
await until(`document.querySelectorAll('#pier-items button').length===6`);
await click(`document.getElementById('nav-shape')`);
await click(`document.getElementById('project-ground-open')`);
await selectFile('#ground-import',[path.join(actualFolder,'generation_settings.json')]);
await until(`document.getElementById('project-ground-dialog').textContent.includes('지반 생성 설정 파일')`);
check('settings file reports correct result filenames and blocks application',await ev(`document.getElementById('project-ground-apply').disabled&&document.getElementById('project-ground-status').textContent==='모델 없음'&&document.getElementById('project-ground-dialog').textContent.includes('ground_model_3d_with_maps.json')`));
await selectFile('#ground-import',[actualModel]);
await until(`document.getElementById('project-ground-status').textContent.includes('6/6')`);
check('real Okseong JSON imports all six piers',await ev(`document.querySelectorAll('[data-ground-pier]:not(:disabled)').length===6`));
await click(`[...document.querySelectorAll('#project-ground-dialog button')].find(b=>b.textContent==='적용 가능한 교각 선택')`);
check('select all enables application',await ev(`document.querySelectorAll('[data-ground-pier]:checked').length===6&&!document.getElementById('project-ground-apply').disabled`));
await click(`document.getElementById('project-ground-apply')`);
const applied=await ev(`[...document.querySelectorAll('.project-ground-table tbody tr')].map(row=>Number(row.children[2].textContent))`);
check('all six design elevations match real JSON',applied.every((el,i)=>el===expected.piers[i].ground.center));
await click(`document.getElementById('project-ground-regenerate')`);
check('all six applied elevations regenerate',await ev(`document.getElementById('project-ground-regenerate').disabled`));
await selectFile('#ground-import',[path.join(actualFolder,'generation_settings.json')]);
await until(`document.getElementById('project-ground-dialog').textContent.includes('지반 생성 설정 파일')`);
check('invalid replacement preserves the loaded project ground model',await ev(`document.getElementById('project-ground-status').textContent.includes('6/6')`));
await shot('02-real-okseong');
check('no runtime exceptions',report.errors.length===0);
report.result='PASS';
})().catch(e=>{report.result='FAIL';report.failure=e.stack;process.exitCode=1;}).finally(()=>{
fs.writeFileSync(path.join(out,'report.json'),JSON.stringify(report,null,2));console.log(JSON.stringify(report));ws?.close();
});
+100
View File
@@ -0,0 +1,100 @@
// Run with Windows Node and an isolated Chrome on CDP 9444; Vite must be on 5173.
// Verifies the landing intro-video trigger + modal (lazy load, play, Esc/backdrop close, focus, layouts).
const fs = require('node:fs'), path = require('node:path'), assert = require('node:assert/strict');
const out = path.resolve(__dirname, '../.verify/intro-video-20261109');
fs.mkdirSync(out, { recursive: true });
const report = { checks: [], errors: [], metrics: {} };
let ws, send;
const delay = ms => new Promise(r => setTimeout(r, ms));
(async () => {
const target = await (await fetch('http://127.0.0.1:9444/json/new?about:blank', { method: 'PUT' })).json();
ws = new WebSocket(target.webSocketDebuggerUrl);
await new Promise((r, j) => { ws.onopen = r; ws.onerror = j; });
let seq = 0; const pending = new Map();
ws.onmessage = ({ data }) => {
const m = JSON.parse(data), p = pending.get(m.id);
if (p) { clearTimeout(p.timer); pending.delete(m.id); m.error ? p.reject(new Error(JSON.stringify(m.error))) : p.resolve(m.result); }
if (m.method === 'Runtime.exceptionThrown') report.errors.push(m.params.exceptionDetails.exception?.description || m.params.exceptionDetails.text);
};
send = (method, params = {}) => new Promise((resolve, reject) => {
const id = ++seq, timer = setTimeout(() => reject(new Error('Timeout ' + method)), 30000);
pending.set(id, { resolve, reject, timer }); ws.send(JSON.stringify({ id, method, params }));
});
const ev = async expression => {
const r = await send('Runtime.evaluate', { expression, awaitPromise: true, returnByValue: true, userGesture: true });
if (r.exceptionDetails) throw new Error(r.exceptionDetails.exception?.description || r.exceptionDetails.text);
return r.result.value;
};
const until = async (expression, tries = 120) => {
for (let i = 0; i < tries; i++) { if (await ev(expression)) return; await delay(150); }
throw new Error('Wait ' + expression);
};
const check = (name, value) => { assert.ok(value, name); report.checks.push(name); console.log('PASS', name); };
const shot = async name => {
const r = await send('Page.captureScreenshot', { format: 'png' });
fs.writeFileSync(path.join(out, name + '.png'), Buffer.from(r.data, 'base64'));
};
const viewport = (width, height, mobile = false) => send('Emulation.setDeviceMetricsOverride', { width, height, deviceScaleFactor: 1, mobile });
const load = async () => {
await send('Page.navigate', { url: process.env.PIERZAINER_URL || 'http://127.0.0.1:5173/' });
await until(`!!document.querySelector('.landing-video-trigger')`);
await delay(1800); // entry animations
};
const mp4Requests = `performance.getEntriesByType('resource').filter(e=>e.name.includes('pierzainer-intro')).length`;
const pressEsc = async () => {
for (const type of ['keyDown', 'keyUp']) await send('Input.dispatchKeyEvent', { type, key: 'Escape', code: 'Escape', windowsVirtualKeyCode: 27 });
};
await send('Page.enable'); await send('Runtime.enable');
await viewport(1600, 1000); await load();
await shot('01-landing-1600');
report.metrics.triggers = await ev(`[...document.querySelectorAll('.landing-guide-trigger,.landing-video-trigger')].map(e=>{const r=e.getBoundingClientRect();return[e.className,Math.round(r.left),Math.round(r.top),Math.round(r.right),Math.round(r.bottom)];})`);
check('trigger sits beside the guide button', await ev(`(()=>{const g=document.querySelector('.landing-guide-trigger').getBoundingClientRect(),v=document.querySelector('.landing-video-trigger').getBoundingClientRect();return v.width>0&&Math.abs(g.top-v.top)<1&&Math.abs(g.height-v.height)<1&&v.left>g.right;})()`));
check('video is not downloaded before opening', await ev(mp4Requests) === 0 && await ev(`!document.querySelector('#landing-video video source')`));
await shot('01-landing-1600');
await ev(`document.querySelector('.landing-video-trigger').click()`);
check('modal opens with focus on close button', await ev(`document.getElementById('landing-video').open&&document.activeElement.classList.contains('landing-video-close')`));
await until(`document.querySelector('#landing-video video').readyState>=3`, 200);
await delay(2200);
const media = await ev(`(()=>{const v=document.querySelector('#landing-video video');return{t:v.currentTime,d:v.duration,w:v.videoWidth,h:v.videoHeight,paused:v.paused,muted:v.muted,src:v.currentSrc.split('/').pop()};})()`);
report.metrics.media = media;
check('mp4 loads at 1920x1080 for about 56 s', media.w === 1920 && media.h === 1080 && Math.abs(media.d - 56) < 1);
check('video autoplays muted after opening', !media.paused && media.muted && media.t > 1);
check('modal fits the viewport', await ev(`(()=>{const r=document.getElementById('landing-video').getBoundingClientRect();return r.left>=0&&r.top>=0&&r.right<=innerWidth&&r.bottom<=innerHeight;})()`));
await shot('02-playing-1600');
await pressEsc(); await delay(200);
check('Esc closes, pauses, rewinds and restores focus', await ev(`(()=>{const v=document.querySelector('#landing-video video');return !document.getElementById('landing-video').open&&v.paused&&v.currentTime===0&&document.activeElement.classList.contains('landing-video-trigger');})()`));
await ev(`document.querySelector('.landing-video-trigger').click()`); await delay(300);
await send('Input.dispatchMouseEvent', { type: 'mousePressed', x: 8, y: 8, button: 'left', clickCount: 1 });
await send('Input.dispatchMouseEvent', { type: 'mouseReleased', x: 8, y: 8, button: 'left', clickCount: 1 });
await delay(200);
check('backdrop click closes the modal', await ev(`!document.getElementById('landing-video').open`));
await ev(`document.querySelector('.landing-video-trigger').click()`); await delay(300);
await ev(`document.querySelector('.landing-video-close').click()`); await delay(200);
check('close button closes and pauses', await ev(`!document.getElementById('landing-video').open&&document.querySelector('#landing-video video').paused`));
for (const [w, h, mobile] of [[1280, 800, false], [1920, 1080, false], [390, 844, true]]) {
await viewport(w, h, mobile); await load();
check(`${w}px trigger visible without horizontal scroll`, await ev(`(()=>{const r=document.querySelector('.landing-video-trigger').getBoundingClientRect();return r.width>0&&r.right<=innerWidth&&document.documentElement.scrollWidth<=innerWidth;})()`));
await ev(`document.querySelector('.landing-video-trigger').click()`); await delay(500);
check(`${w}px modal fits viewport`, await ev(`(()=>{const r=document.getElementById('landing-video').getBoundingClientRect();return r.left>=0&&r.right<=innerWidth&&r.bottom<=innerHeight+1;})()`));
await shot(`03-modal-${w}`);
await ev(`document.querySelector('.landing-video-close').click()`);
}
await viewport(1600, 1000);
await send('Emulation.setEmulatedMedia', { features: [{ name: 'prefers-reduced-motion', value: 'reduce' }] });
await load();
await ev(`document.querySelector('.landing-video-trigger').click()`); await delay(1500);
check('reduced motion: opens without autoplay', await ev(`document.getElementById('landing-video').open&&document.querySelector('#landing-video video').paused`));
await send('Emulation.setEmulatedMedia', { features: [] });
check('no runtime exceptions', report.errors.length === 0);
report.result = 'PASS';
})().catch(e => { report.result = 'FAIL'; report.failure = e.stack; process.exitCode = 1; }).finally(() => {
fs.writeFileSync(path.join(out, 'report.json'), JSON.stringify(report, null, 2));
console.log(JSON.stringify({ result: report.result, failure: report.failure, metrics: report.metrics, errors: report.errors })); ws?.close();
});
+85
View File
@@ -0,0 +1,85 @@
// Run with Windows Node and an isolated Chrome on CDP 9444; Vite must be on 5173.
const fs = require('node:fs'), path = require('node:path'), assert = require('node:assert/strict');
const out = path.resolve(__dirname, '../.verify/landing-horizontal-20261109/applied-browser');
fs.mkdirSync(out, { recursive: true });
const report = { checks: [], errors: [] };
let ws, send;
const delay = ms => new Promise(r => setTimeout(r, ms));
(async () => {
const target = await (await fetch('http://127.0.0.1:9444/json/new?about:blank', { method: 'PUT' })).json();
ws = new WebSocket(target.webSocketDebuggerUrl);
await new Promise((r, j) => { ws.onopen = r; ws.onerror = j; });
let seq = 0; const pending = new Map();
ws.onmessage = ({ data }) => {
const m = JSON.parse(data), p = pending.get(m.id);
if (p) { clearTimeout(p.timer); pending.delete(m.id); m.error ? p.reject(new Error(JSON.stringify(m.error))) : p.resolve(m.result); }
if (m.method === 'Runtime.exceptionThrown') report.errors.push(m.params.exceptionDetails.exception?.description || m.params.exceptionDetails.text);
};
send = (method, params = {}) => new Promise((resolve, reject) => {
const id = ++seq, timer = setTimeout(() => reject(new Error('Timeout ' + method)), 30000);
pending.set(id, { resolve, reject, timer }); ws.send(JSON.stringify({ id, method, params }));
});
const ev = async expression => {
const r = await send('Runtime.evaluate', { expression, awaitPromise: true, returnByValue: true });
if (r.exceptionDetails) throw new Error(r.exceptionDetails.exception?.description || r.exceptionDetails.text);
return r.result.value;
};
const until = async expression => {
for (let i = 0; i < 120; i++) { if (await ev(expression)) return; await delay(150); }
throw new Error('Wait ' + expression);
};
const check = (name, value) => { assert.ok(value, name); report.checks.push(name); console.log('PASS', name); };
const click = expression => ev(`(()=>{const e=${expression};if(!e||e.disabled)throw new Error('Missing target');e.click();})()`);
const shot = async name => {
const r = await send('Page.captureScreenshot', { format: 'png' });
fs.writeFileSync(path.join(out, name + '.png'), Buffer.from(r.data, 'base64'));
};
await send('Page.enable'); await send('Runtime.enable'); await send('DOM.enable');
await send('Emulation.setDeviceMetricsOverride', { width: 1600, height: 1000, deviceScaleFactor: 1, mobile: false });
await send('Page.navigate', { url: process.env.PIERZAINER_URL || 'http://127.0.0.1:5173/' });
await until(`typeof window.openSampleProject==='function'`);
await until(`document.querySelector('.landing-promo-image')?.complete&&document.querySelector('.landing-promo-image').naturalWidth>0`);
await delay(1800);
check('landing uses the new high-resolution WebP',await ev(`document.querySelector('.landing-promo-image').src.includes('okseong-model-hero.webp')&&document.querySelector('.landing-promo-image').naturalWidth===4344`));
check('eight projected markers include both abutments',await ev(`document.querySelectorAll('.landing-pier-beacon').length===8&&!!document.querySelector('[data-structure="A1"]')&&!!document.querySelector('[data-structure="A2"]')`));
check('30m end margin is active',await ev(`(async()=>{const {heroCapture}=await import('/'+encodeURIComponent('랜딩페이지')+'/assets/okseong-hero-capture.ts');return heroCapture.endMarginM===30;})()`));
check('taller frame and rounded terrain are active',await ev(`(async()=>{const {heroCapture}=await import('/'+encodeURIComponent('랜딩페이지')+'/assets/okseong-hero-capture.ts');return heroCapture.height===870&&heroCapture.cornerRadiusM===18&&document.querySelector('.landing-promo-image').naturalHeight===1740;})()`));
check('horizontal view and smooth inward outline are applied',await ev(`(async()=>{const {heroCapture}=await import('/'+encodeURIComponent('랜딩페이지')+'/assets/okseong-hero-capture.ts');return heroCapture.along===0&&heroCapture.outline.method==='inward-alpha-envelope-quadratic'&&heroCapture.outline.addedOutsideSourcePixels===0&&heroCapture.outline.retainedTerrainRatio>.9;})()`));
const expectedHash=require('node:crypto').createHash('sha256').update(fs.readFileSync(path.resolve(__dirname,'../.verify/landing-horizontal-20261109/horizontal-smooth.webp'))).digest('hex');
check('served landing image matches the reviewed horizontal capture',await ev(`(async()=>{const bytes=await(await fetch(document.querySelector('.landing-promo-image').src)).arrayBuffer();const hash=[...new Uint8Array(await crypto.subtle.digest('SHA-256',bytes))].map(n=>n.toString(16).padStart(2,'0')).join('');return hash===${JSON.stringify(expectedHash)};})()`));
check('both road extensions follow valid terrain sections',await ev(`(async()=>{const {heroCapture}=await import('/'+encodeURIComponent('랜딩페이지')+'/assets/okseong-hero-capture.ts');return heroCapture.roads.length===2&&heroCapture.roads.every(r=>r.missingSections===0&&r.validSections===81&&r.visualizationOnly);})()`));
check('decorative frame includes both abutments',await ev(`(()=>{const box=document.querySelector('.landing-hud-bracket').getBBox(),a=document.querySelector('[data-structure="A1"]').transform.baseVal.getItem(0).matrix,b=document.querySelector('[data-structure="A2"]').transform.baseVal.getItem(0).matrix;return box.x<a.e&&box.x+box.width>b.e;})()`));
check('restored light follows projected structures from left to right',await ev(`(()=>{const path=document.getElementById('landing-alignment-path'),first=path.getPointAtLength(0),last=path.getPointAtLength(path.getTotalLength()),a=document.querySelector('[data-structure="A1"]').transform.baseVal.getItem(0).matrix,b=document.querySelector('[data-structure="A2"]').transform.baseVal.getItem(0).matrix;return first.x<last.x&&Math.abs(first.x-a.e)<.1&&Math.abs(last.x-b.e)<.1;})()`));
check('light follows dense terrain samples rather than coping elevations',await ev(`(async()=>{const {heroCapture}=await import('/'+encodeURIComponent('랜딩페이지')+'/assets/okseong-hero-capture.ts');const path=document.getElementById('landing-alignment-path'),samples=heroCapture.terrainFlow.filter(Boolean),first=path.getPointAtLength(0),a=document.querySelector('[data-structure="A1"]').transform.baseVal.getItem(0).matrix;return samples.length>100&&(path.getAttribute('d').match(/L/g)||[]).length>100&&Math.abs(first.y-samples[0].y)<.1&&Math.abs(first.y-a.f)>10;})()`));
check('vertical scan, moving grid, and rising sparks are removed',await ev(`!document.querySelector('.landing-scan-mask,.landing-scan-beam,.landing-terrain-grid,.landing-pier-spark')&&![...document.querySelectorAll('.landing-hero *')].some(e=>e.getAnimations().some(a=>['landing-scan-pass','landing-beacon-spark','landing-grid-breathe'].includes(a.animationName)))`));
check('traveling light is white',await ev(`[...document.querySelectorAll('#landing-flow-fade stop')].every(e=>getComputedStyle(e).stopColor==='rgb(255, 255, 255)')&&getComputedStyle(document.querySelector('.landing-energy-track')).stroke==='rgb(255, 255, 255)'`));
const offset=async time=>ev(`(()=>{const e=document.querySelector('.landing-energy-flow'),a=e.getAnimations()[0];if(!a)throw new Error('Missing flow animation');a.pause();a.currentTime=${time};return parseFloat(getComputedStyle(e).strokeDashoffset);})()`);
const left=await offset(3500),right=await offset(5000);
check('light actually advances along the path over time',right<left);
await shot('light-moving');
await ev(`document.querySelector('.landing-energy-flow').getAnimations()[0].play()`);
check('asset has transparent background and opaque terrain',await ev(`(()=>{const c=document.createElement('canvas');c.width=100;c.height=34;const ctx=c.getContext('2d');ctx.drawImage(document.querySelector('.landing-promo-image'),0,0,100,34);const data=ctx.getImageData(0,0,100,34).data;let lo=255,hi=0;for(let i=3;i<data.length;i+=4){lo=Math.min(lo,data[i]);hi=Math.max(hi,data[i]);}return lo===0&&hi===255;})()`));
await send('Emulation.setEmulatedMedia',{features:[{name:'prefers-reduced-motion',value:'reduce'}]});
for(const [width,height] of [[1920,1080],[1600,900],[1280,800],[390,844]]){
await send('Emulation.setDeviceMetricsOverride',{width,height,deviceScaleFactor:1,mobile:width<760});
await delay(200);
await shot(`${width}-landing`);
report.layout=await ev(`[...document.querySelectorAll('.landing-cards .landing-primary')].map(e=>({text:e.textContent,rect:e.getBoundingClientRect().toJSON()}))`);
check(`no horizontal overflow at ${width}`,await ev(`document.documentElement.scrollWidth<=innerWidth`));
check(`all entry actions remain visible at ${width}`,await ev(`[...document.querySelectorAll('.landing-cards .landing-primary')].every(e=>{const r=e.getBoundingClientRect(),hit=document.elementFromPoint(r.x+r.width/2,r.y+r.height/2);return r.width>0&&r.height>0&&r.left>=0&&r.right<=innerWidth+1&&r.top>=0&&r.bottom<=innerHeight&&!!hit&&(hit===e||e.contains(hit));})`));
check(`image and projected markers align at ${width}`,await ev(`(()=>{const img=document.querySelector('.landing-promo-image').getBoundingClientRect(),svg=document.querySelector('.landing-model-motion'),view=svg.viewBox.baseVal;return [...document.querySelectorAll('.landing-pier-beacon')].every(g=>{const t=g.transform.baseVal.getItem(0).matrix,p=svg.createSVGPoint();p.x=t.e;p.y=t.f;const actual=p.matrixTransform(svg.getScreenCTM());return Math.abs(actual.x-(img.left+t.e/view.width*img.width))<2&&Math.abs(actual.y-(img.top+t.f/view.height*img.height))<2;});})()`));
check(`first-visit guide does not cover structures at ${width}`,await ev(`(()=>{const hint=document.querySelector('.landing-guide-hint');if(hint.hidden)return true;const r=hint.getBoundingClientRect(),svg=document.querySelector('.landing-model-motion');return [...document.querySelectorAll('.landing-pier-beacon')].every(g=>{const m=g.transform.baseVal.getItem(0).matrix,p=svg.createSVGPoint();p.x=m.e;p.y=m.f;const q=p.matrixTransform(svg.getScreenCTM());return q.x<r.left||q.x>r.right||q.y<r.top||q.y>r.bottom;});})()`));
await shot(`${width}-landing`);
}
check('reduced motion stops beacon animation',await ev(`getComputedStyle(document.querySelector('.landing-pier-pulse')).animationName==='none'`));
check('reduced motion stops light travel',await ev(`getComputedStyle(document.querySelector('.landing-energy-flow')).animationName==='none'`));
await send('Emulation.setDeviceMetricsOverride',{width:1600,height:1000,deviceScaleFactor:1,mobile:false});
await click(`document.querySelector('[data-card="sample"] .landing-primary')`);
check('design library still opens',await ev(`document.querySelector('.sample-library-popup').open`));
await click(`document.querySelector('.sample-library-close')`);
await click(`document.querySelector('[data-card="excel"] .landing-primary')`);
check('Excel entry still opens',await ev(`document.getElementById('import-dialog').open`));
check('no runtime exceptions',report.errors.length===0);report.result='PASS';
})().catch(e=>{report.result='FAIL';report.failure=e.stack;process.exitCode=1;}).finally(()=>{
fs.writeFileSync(path.join(out,'report.json'),JSON.stringify(report,null,2));console.log(JSON.stringify(report));ws?.close();
});
+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; });
+200
View File
@@ -0,0 +1,200 @@
// Run with Windows Node and an isolated Chrome on CDP 9444; Vite must be on 5173.
const fs = require('node:fs'), path = require('node:path'), assert = require('node:assert/strict');
const out = path.resolve(__dirname, '../.verify/okseong-user-files-20261109');
fs.mkdirSync(out, { recursive: true });
const report = { checks: [], errors: [] };
let ws, send;
const delay = ms => new Promise(r => setTimeout(r, ms));
(async () => {
const target = await (await fetch('http://127.0.0.1:9444/json/new?about:blank', { method: 'PUT' })).json();
ws = new WebSocket(target.webSocketDebuggerUrl);
await new Promise((r, j) => { ws.onopen = r; ws.onerror = j; });
let seq = 0; const pending = new Map();
ws.onmessage = ({ data }) => {
const m = JSON.parse(data), p = pending.get(m.id);
if (p) { clearTimeout(p.timer); pending.delete(m.id); m.error ? p.reject(new Error(JSON.stringify(m.error))) : p.resolve(m.result); }
if (m.method === 'Runtime.exceptionThrown') report.errors.push(m.params.exceptionDetails.exception?.description || m.params.exceptionDetails.text);
};
send = (method, params = {}) => new Promise((resolve, reject) => {
const id = ++seq, timer = setTimeout(() => reject(new Error('Timeout ' + method)), 30000);
pending.set(id, { resolve, reject, timer }); ws.send(JSON.stringify({ id, method, params }));
});
const ev = async expression => {
const r = await send('Runtime.evaluate', { expression, awaitPromise: true, returnByValue: true });
if (r.exceptionDetails) throw new Error(r.exceptionDetails.exception?.description || r.exceptionDetails.text);
return r.result.value;
};
const until = async expression => {
for (let i = 0; i < 120; i++) { if (await ev(expression)) return; await delay(150); }
throw new Error('Wait ' + expression);
};
const check = (name, value) => { assert.ok(value, name); report.checks.push(name); console.log('PASS', name); };
const click = expression => ev(`(()=>{const e=${expression};if(!e||e.disabled)throw new Error('Missing target');e.click();})()`);
const shot = async name => {
const r = await send('Page.captureScreenshot', { format: 'png' });
fs.writeFileSync(path.join(out, name + '.png'), Buffer.from(r.data, 'base64'));
};
await send('Page.enable'); await send('Runtime.enable'); await send('DOM.enable');
await send('Emulation.setDeviceMetricsOverride', { width: 1600, height: 1000, deviceScaleFactor: 1, mobile: false });
await send('Page.navigate', { url: process.env.PIERZAINER_URL || 'http://127.0.0.1:5173/' });
await until(`typeof window.openSampleProject==='function'`);
const fixture = await ev(`(async()=>{
const XLSX=await import('/node_modules/xlsx/xlsx.mjs'),{parseWorkbook}=await import('/src/core/parseWorkbook.ts');
const bytes=await(await fetch('/'+encodeURIComponent('도면추출')+'/samples/'+encodeURIComponent('운곡교P1입력.xlsx'))).arrayBuffer();
const base=parseWorkbook(XLSX.read(bytes)).piers.find(p=>p.name==='P1');
if(!base)throw new Error('Missing sample pier');
const {defaultLevelWidthMm}=await import('/src/core/geometry.ts');
const piers=['직접기초','직접기초','현장타설말뚝'].map((foundationType,i)=>{const input={...base,name:'P'+(i+1),foundationType,type:'T형',columnCount:1,groundEl:30,pileLengthMm:24000,castInPlaceShape:{clayBottomEl:16},columnDiameterMm:2000,footingLengthMm:10400,footingWidthMm:8150,footingHeightMm:2000,blindingMarginMm:100,blindingThicknessMm:100,pileKind:'강관',pileOuterMm:609.6,pileThicknessMm:12,copingH1Mm:1000,copingH2Mm:1500,levelWidthMm:defaultLevelWidthMm('T형',2000)};return{id:input.name,input,defaults:input,draft:input};});
return{schemaVersion:1,project:{name:'병합 검증',bridge:'검증',client:'',author:'',crs:'',memo:''},source:{fileName:'합성 표본',sheets:[]},piers,history:[]};
})()`);
const file = path.join(out, 'fixture.pzr.json'); fs.writeFileSync(file, JSON.stringify(fixture));
await ev('window.openSampleProject()');
await click(`document.querySelector('#file-menu summary')`); await click(`document.getElementById('open-project')`);
const doc = await send('DOM.getDocument');
const { nodeId } = await send('DOM.querySelector', { nodeId: doc.root.nodeId, selector: '#project-file' });
await send('DOM.setFileInputFiles', { nodeId, files: [file] });
await until(`document.querySelectorAll('#rebar-pier-items button').length===3`);
const selectFile = async (selector, files) => {
const doc = await send('DOM.getDocument');
const {nodeId} = await send('DOM.querySelector', {nodeId:doc.root.nodeId, selector});
await send('DOM.setFileInputFiles', {nodeId, files});
};
await click(`document.getElementById('nav-shape')`);
check('shape has a visible project ground entry', await ev(`document.getElementById('project-ground-open').getBoundingClientRect().height>0`));
await click(`document.getElementById('project-ground-open')`);
check('manager opens without per-pier controls', await ev(`document.getElementById('project-ground-dialog').open&&document.querySelectorAll('#ground-import').length===1`));
const modelFile=path.join(out,'ground.json');
const raw={schema:2,generated:'2026-11-09',piers:[{id:'P1',ground:{center:29,excel:30},soil:{layers:[{name:'암반',bottom_el:10}]}},{id:'P2',ground:{center:null,excel:30}}]};
fs.writeFileSync(modelFile,JSON.stringify(raw));
await selectFile('#ground-import',[modelFile]);
await until(`document.getElementById('project-ground-info').textContent.includes('2026-11-09')`);
check('matching, out-of-range and missing piers are distinguished',await ev(`document.getElementById('project-ground-dialog').textContent.includes('지형 범위 밖')&&document.getElementById('project-ground-dialog').textContent.includes('모델에 없음')&&document.querySelector('[data-ground-pier="P2"]').disabled&&document.querySelector('[data-ground-pier="P3"]').disabled`));
check('import alone keeps current design input',await ev(`document.querySelector('.project-ground-table tbody tr').children[2].textContent==='30.000'`));
await click(`document.querySelector('[data-ground-pier="P1"]')`);
await click(`document.getElementById('project-ground-apply')`);
check('selected ground input applies and requires regeneration',await ev(`document.querySelector('.project-ground-table tbody tr').children[2].textContent==='29.000'&&!document.getElementById('project-ground-regenerate').disabled`));
await click(`document.getElementById('project-ground-regenerate')`);
check('regeneration consumes ground changes',await ev(`document.getElementById('project-ground-regenerate').disabled&&document.querySelector('.project-ground-table tbody tr').textContent.includes('지반고 일치')`));
await click(`document.getElementById('project-ground-el')`);
await click(`document.getElementById('project-ground-layers')`);
await click(`document.getElementById('project-ground-apply')`);
check('layers can be applied separately',await ev(`!document.getElementById('project-ground-regenerate').disabled&&document.querySelector('.project-ground-table tbody tr').children[2].textContent==='29.000'`));
await click(`document.getElementById('project-ground-regenerate')`);
await shot('01-project-ground');
await click(`document.querySelector('#project-ground-dialog .viewer-head button')`);
await click(`document.getElementById('nav-plan')`);
await click(`[...document.querySelectorAll('#members button')].find(b=>b.textContent.includes('지반'))`);
check('per-pier ground card has no model generation or file picker',await ev(`!document.getElementById('view-plan').textContent.includes('지반 모델 파일 불러오기 · 지반 생성 프로그램')&&document.querySelectorAll('#view-plan input[type=file]').length===0`));
await ev(`(()=>{window.__savedGround=null;const original=URL.createObjectURL;URL.createObjectURL=function(blob){blob.text().then(text=>{try{window.__savedGround=JSON.parse(text);}catch{}});return original.call(this,blob);};})()`);
await click(`document.getElementById('save-project')`);
await until(`!!window.__savedGround?.groundModel`);
const saved=await ev(`window.__savedGround`);
check('project saves shared model once and applied inputs',saved.groundModel.piers.length===2&&saved.piers[0].input.groundEl===29&&saved.piers[0].input.strataRows[0].name==='암반'&&saved.piers[1].input.groundEl===30);
const savedFile=path.join(out,'saved.pzr.json');fs.writeFileSync(savedFile,JSON.stringify(saved));
await selectFile('#project-file',[savedFile]);
await until(`document.getElementById('project-ground-status').textContent.includes('불러옴')`);
check('project reopen restores ground model',await ev(`document.getElementById('project-ground-status').textContent.includes('2/3')`));
// Mock the external engine; deliver an in-flight result after switching projects.
await ev(`(()=>{const original=window.fetch;window.__releaseGround=null;window.__groundReady=false;window.fetch=async(url,init)=>{const path=String(url);if(path.includes('/ground-link/prepare'))return new Response(JSON.stringify({drawing:'test.dxf',excelName:'test.xlsx',excelSelected:true}));if(path.includes('/ground-link/upload'))return new Response(JSON.stringify({file:'test.xlsx'}));if(path.includes('/ground-link/launch'))return new Response(JSON.stringify({id:'mock'}));if(path.includes('/ground-link/result')){window.__groundReady=true;return new Promise(resolve=>window.__releaseGround=()=>resolve(new Response(JSON.stringify({ready:true,state:'done',filename:'old.json',text:${JSON.stringify(JSON.stringify({...raw,generated:'mock-generated'}))}}))));}return original(url,init);};})()`);
const excel=path.join(out,'source.xlsx');fs.writeFileSync(excel,'mock');
const startGeneration = async () => {
await ev(`window.__groundReady=false`);
await click(`document.getElementById('nav-shape')`);
await click(`document.getElementById('project-ground-open')`);
await click(`[...document.querySelectorAll('#project-ground-dialog button')].find(b=>b.textContent==='지반모델 생성')`);
await until(`document.querySelector('.ground-popup [data-run]')&&!document.querySelector('.ground-popup [data-run]').disabled`);
await selectFile('#ground-excel-file',[excel]);
await click(`document.querySelector('.ground-popup [data-run]')`);
await until(`window.__groundReady`);
};
await startGeneration();
await click(`document.querySelector('.ground-popup [data-close]')`);
await click(`document.querySelector('#project-ground-dialog .viewer-head button')`);
await click(`document.getElementById('nav-rebar')`);
await ev(`window.__releaseGround()`);
await until(`document.querySelector('.ground-popup [data-status]').textContent.includes('생성 완료')`);
await click(`document.getElementById('nav-shape')`);
await click(`document.getElementById('project-ground-open')`);
check('generated model imports after leaving both manager and ground card',await ev(`document.getElementById('project-ground-info').textContent.includes('mock-generated')`));
await click(`document.querySelector('#project-ground-dialog .viewer-head button')`);
await startGeneration();
await selectFile('#project-file',[file]);
await until(`document.getElementById('project-ground-status').textContent==='모델 없음'`);
await ev(`window.__releaseGround()`);
await delay(300);
check('late generated result does not contaminate another project',await ev(`document.getElementById('project-ground-status').textContent==='모델 없음'`));
check('project switch closes manager and generator',await ev(`!document.getElementById('project-ground-dialog').open&&!document.querySelector('.ground-popup').open`));
await selectFile('#project-file',[savedFile]);
await until(`document.getElementById('project-ground-status').textContent.includes('불러옴')`);
await ev(`(async()=>{const bytes=await(await fetch('/'+encodeURIComponent('도면추출')+'/samples/'+encodeURIComponent('운곡교P1입력.xlsx'))).arrayBuffer();window.loadBytesToApp(new Uint8Array(bytes),'새 프로젝트.xlsx');const name=document.getElementById('project-name');name.value='새 프로젝트';name.dispatchEvent(new Event('input'));document.getElementById('import-dialog').showModal();})()`);
await until(`document.getElementById('import-dialog').open&&!document.getElementById('dlg-apply').disabled`);
await click(`document.getElementById('dlg-apply')`);
await until(`document.getElementById('project-ground-status').textContent==='모델 없음'`);
check('new Excel import clears previous project ground model',await ev(`document.getElementById('project-ground-status').textContent==='모델 없음'`));
// Real generated JSON and the adjacent settings file reproduce the reported failure.
const actualFolder=path.resolve(__dirname,'../pierzainer_ground/data/output/drawing_01_옥성교_종평면도');
const actualModel=path.join(actualFolder,'ground_model.json');
const expected=JSON.parse(fs.readFileSync(actualModel,'utf8'));
const six={...fixture,piers:Array.from({length:6},(_,i)=>{
const input={...fixture.piers[0].input,name:'P'+(i+1)};
return{id:input.name,input,defaults:input,draft:input};
})};
const sixFile=path.join(out,'six-piers.pzr.json');fs.writeFileSync(sixFile,JSON.stringify(six));
await selectFile('#project-file',[sixFile]);
await until(`document.querySelectorAll('#pier-items button').length===6`);
await click(`document.getElementById('nav-shape')`);
await click(`document.getElementById('project-ground-open')`);
await selectFile('#ground-import',[path.join(actualFolder,'generation_settings.json')]);
await until(`document.getElementById('project-ground-dialog').textContent.includes('지반 생성 설정 파일')`);
check('settings file reports correct result filenames and blocks application',await ev(`document.getElementById('project-ground-apply').disabled&&document.getElementById('project-ground-status').textContent==='모델 없음'&&document.getElementById('project-ground-dialog').textContent.includes('ground_model_3d_with_maps.json')`));
await selectFile('#ground-import',[actualModel]);
await until(`document.getElementById('project-ground-status').textContent.includes('6/6')`);
check('real Okseong JSON imports all six piers',await ev(`document.querySelectorAll('[data-ground-pier]:not(:disabled)').length===6`));
await click(`[...document.querySelectorAll('#project-ground-dialog button')].find(b=>b.textContent==='적용 가능한 교각 선택')`);
check('select all enables application',await ev(`document.querySelectorAll('[data-ground-pier]:checked').length===6&&!document.getElementById('project-ground-apply').disabled`));
await click(`document.getElementById('project-ground-apply')`);
const applied=await ev(`[...document.querySelectorAll('.project-ground-table tbody tr')].map(row=>Number(row.children[2].textContent))`);
check('all six design elevations match real JSON',applied.every((el,i)=>el===expected.piers[i].ground.center));
await click(`document.getElementById('project-ground-regenerate')`);
check('all six applied elevations regenerate',await ev(`document.getElementById('project-ground-regenerate').disabled`));
await selectFile('#ground-import',[path.join(actualFolder,'generation_settings.json')]);
await until(`document.getElementById('project-ground-dialog').textContent.includes('지반 생성 설정 파일')`);
check('invalid replacement preserves the loaded project ground model',await ev(`document.getElementById('project-ground-status').textContent.includes('6/6')`));
await shot('02-real-okseong');
await click(`document.querySelector('#project-ground-dialog .viewer-head button')`);
const userFolder=process.env.OKSEONG_FILES||'C:\\Users\\User\\Downloads\\NAVER WORKS';
const userExcel=path.join(userFolder,'옥성교 데이터_261106.xlsx'),userJson=path.join(userFolder,'옥성교 지형.json');
await selectFile('#file',[userExcel]);
await until(`document.getElementById('file-name').textContent.includes('선택된 파일: 옥성교 데이터_261106.xlsx')&&!document.getElementById('dlg-apply').disabled`);
await ev(`document.getElementById('import-dialog').showModal()`);
await click(`document.getElementById('dlg-apply')`);
await until(`document.getElementById('project-ground-status').textContent==='모델 없음'`);
const before=await ev(`[...document.querySelectorAll('#inputs-all tbody tr')].map(row=>row.textContent)`);
const projectBefore=await ev(`document.getElementById('project-line').textContent`);
await click(`document.getElementById('nav-shape')`);
await click(`document.getElementById('project-ground-open')`);
await selectFile('#ground-import',[userJson]);
await until(`document.getElementById('project-ground-status').textContent.includes('6/6')`);
check('user JSON imports six elevations plus terrain and two maps',await ev(`document.getElementById('project-ground-info').textContent.includes('지도 2개')&&document.getElementById('project-ground-info').textContent.includes('지형 있음')`));
check('importing embedded model preserves user Excel inputs and project',await ev(`JSON.stringify([...document.querySelectorAll('#inputs-all tbody tr')].map(row=>row.textContent))`)===JSON.stringify(before)&&await ev(`document.getElementById('project-line').textContent`)===projectBefore);
await click(`[...document.querySelectorAll('#project-ground-dialog button')].find(b=>b.textContent==='적용 가능한 교각 선택')`);
await click(`document.getElementById('project-ground-apply')`);
const userExpected=JSON.parse(fs.readFileSync(userJson,'utf8')).groundModel;
const userApplied=await ev(`[...document.querySelectorAll('.project-ground-table tbody tr')].map(row=>Number(row.children[2].textContent))`);
check('user Excel accepts all six elevations from embedded ground model',userApplied.length===6&&userApplied.every((el,i)=>el===userExpected.piers[i].centerEl));
await click(`document.getElementById('project-ground-regenerate')`);
check('user project regenerates after ground application',await ev(`document.getElementById('project-ground-regenerate').disabled`));
await shot('03-user-files-applied');
await click(`document.querySelector('#project-ground-dialog .viewer-head button')`);
await send('Browser.setDownloadBehavior',{behavior:'deny'});
await ev(`window.__savedGround=null`);
await click(`document.getElementById('save-project')`);
await until(`document.getElementById('save-dialog').open`);
await click(`document.getElementById('sv-save')`);
await until(`window.__savedGround?.source?.fileName==='옥성교 데이터_261106.xlsx'`);
check('saved result retains user Excel source and applied embedded terrain',await ev(`window.__savedGround.piers.length===6&&window.__savedGround.piers[0].input.groundEl===18.011&&window.__savedGround.groundModel.mapLayers.length===2`));
check('no runtime exceptions',report.errors.length===0);
report.result='PASS';
})().catch(e=>{report.result='FAIL';report.failure=e.stack;process.exitCode=1;}).finally(()=>{
fs.writeFileSync(path.join(out,'report.json'),JSON.stringify(report,null,2));console.log(JSON.stringify(report));ws?.close();
});
+138
View File
@@ -0,0 +1,138 @@
// Run with Windows Node and an isolated Chrome on CDP 9444; Vite must be on 5173.
const fs = require('node:fs'), path = require('node:path'), assert = require('node:assert/strict');
const out = path.resolve(__dirname, '../.verify/project-ground-20261109');
fs.mkdirSync(out, { recursive: true });
const report = { checks: [], errors: [] };
let ws, send;
const delay = ms => new Promise(r => setTimeout(r, ms));
(async () => {
const target = await (await fetch('http://127.0.0.1:9444/json/new?about:blank', { method: 'PUT' })).json();
ws = new WebSocket(target.webSocketDebuggerUrl);
await new Promise((r, j) => { ws.onopen = r; ws.onerror = j; });
let seq = 0; const pending = new Map();
ws.onmessage = ({ data }) => {
const m = JSON.parse(data), p = pending.get(m.id);
if (p) { clearTimeout(p.timer); pending.delete(m.id); m.error ? p.reject(new Error(JSON.stringify(m.error))) : p.resolve(m.result); }
if (m.method === 'Runtime.exceptionThrown') report.errors.push(m.params.exceptionDetails.exception?.description || m.params.exceptionDetails.text);
};
send = (method, params = {}) => new Promise((resolve, reject) => {
const id = ++seq, timer = setTimeout(() => reject(new Error('Timeout ' + method)), 30000);
pending.set(id, { resolve, reject, timer }); ws.send(JSON.stringify({ id, method, params }));
});
const ev = async expression => {
const r = await send('Runtime.evaluate', { expression, awaitPromise: true, returnByValue: true });
if (r.exceptionDetails) throw new Error(r.exceptionDetails.exception?.description || r.exceptionDetails.text);
return r.result.value;
};
const until = async expression => {
for (let i = 0; i < 120; i++) { if (await ev(expression)) return; await delay(150); }
throw new Error('Wait ' + expression);
};
const check = (name, value) => { assert.ok(value, name); report.checks.push(name); console.log('PASS', name); };
const click = expression => ev(`(()=>{const e=${expression};if(!e||e.disabled)throw new Error('Missing target');e.click();})()`);
const shot = async name => {
const r = await send('Page.captureScreenshot', { format: 'png' });
fs.writeFileSync(path.join(out, name + '.png'), Buffer.from(r.data, 'base64'));
};
await send('Page.enable'); await send('Runtime.enable'); await send('DOM.enable');
await send('Emulation.setDeviceMetricsOverride', { width: 1600, height: 1000, deviceScaleFactor: 1, mobile: false });
await send('Page.navigate', { url: process.env.PIERZAINER_URL || 'http://127.0.0.1:5173/' });
await until(`typeof window.openSampleProject==='function'`);
const fixture = await ev(`(async()=>{
const XLSX=await import('/node_modules/xlsx/xlsx.mjs'),{parseWorkbook}=await import('/src/core/parseWorkbook.ts');
const bytes=await(await fetch('/'+encodeURIComponent('도면추출')+'/samples/'+encodeURIComponent('운곡교P1입력.xlsx'))).arrayBuffer();
const base=parseWorkbook(XLSX.read(bytes)).piers.find(p=>p.name==='P1');
if(!base)throw new Error('Missing sample pier');
const {defaultLevelWidthMm}=await import('/src/core/geometry.ts');
const piers=['직접기초','직접기초','현장타설말뚝'].map((foundationType,i)=>{const input={...base,name:'P'+(i+1),foundationType,type:'T형',columnCount:1,groundEl:30,pileLengthMm:24000,castInPlaceShape:{clayBottomEl:16},columnDiameterMm:2000,footingLengthMm:10400,footingWidthMm:8150,footingHeightMm:2000,blindingMarginMm:100,blindingThicknessMm:100,pileKind:'강관',pileOuterMm:609.6,pileThicknessMm:12,copingH1Mm:1000,copingH2Mm:1500,levelWidthMm:defaultLevelWidthMm('T형',2000)};return{id:input.name,input,defaults:input,draft:input};});
return{schemaVersion:1,project:{name:'병합 검증',bridge:'검증',client:'',author:'',crs:'',memo:''},source:{fileName:'합성 표본',sheets:[]},piers,history:[]};
})()`);
const file = path.join(out, 'fixture.pzr.json'); fs.writeFileSync(file, JSON.stringify(fixture));
await ev('window.openSampleProject()');
await click(`document.querySelector('#file-menu summary')`); await click(`document.getElementById('open-project')`);
const doc = await send('DOM.getDocument');
const { nodeId } = await send('DOM.querySelector', { nodeId: doc.root.nodeId, selector: '#project-file' });
await send('DOM.setFileInputFiles', { nodeId, files: [file] });
await until(`document.querySelectorAll('#rebar-pier-items button').length===3`);
const selectFile = async (selector, files) => {
const doc = await send('DOM.getDocument');
const {nodeId} = await send('DOM.querySelector', {nodeId:doc.root.nodeId, selector});
await send('DOM.setFileInputFiles', {nodeId, files});
};
await click(`document.getElementById('nav-shape')`);
check('shape has a visible project ground entry', await ev(`document.getElementById('project-ground-open').getBoundingClientRect().height>0`));
await click(`document.getElementById('project-ground-open')`);
check('manager opens without per-pier controls', await ev(`document.getElementById('project-ground-dialog').open&&document.querySelectorAll('#ground-import').length===1`));
const modelFile=path.join(out,'ground.json');
const raw={schema:2,generated:'2026-11-09',piers:[{id:'P1',ground:{center:29,excel:30},soil:{layers:[{name:'암반',bottom_el:10}]}},{id:'P2',ground:{center:null,excel:30}}]};
fs.writeFileSync(modelFile,JSON.stringify(raw));
await selectFile('#ground-import',[modelFile]);
await until(`document.getElementById('project-ground-info').textContent.includes('2026-11-09')`);
check('matching, out-of-range and missing piers are distinguished',await ev(`document.getElementById('project-ground-dialog').textContent.includes('지형 범위 밖')&&document.getElementById('project-ground-dialog').textContent.includes('모델에 없음')&&document.querySelector('[data-ground-pier="P2"]').disabled&&document.querySelector('[data-ground-pier="P3"]').disabled`));
check('import alone keeps current design input',await ev(`document.querySelector('.project-ground-table tbody tr').children[2].textContent==='30.000'`));
await click(`document.querySelector('[data-ground-pier="P1"]')`);
await click(`document.getElementById('project-ground-apply')`);
check('selected ground input applies and requires regeneration',await ev(`document.querySelector('.project-ground-table tbody tr').children[2].textContent==='29.000'&&!document.getElementById('project-ground-regenerate').disabled`));
await click(`document.getElementById('project-ground-regenerate')`);
check('regeneration consumes ground changes',await ev(`document.getElementById('project-ground-regenerate').disabled&&document.querySelector('.project-ground-table tbody tr').textContent.includes('지반고 일치')`));
await click(`document.getElementById('project-ground-el')`);
await click(`document.getElementById('project-ground-layers')`);
await click(`document.getElementById('project-ground-apply')`);
check('layers can be applied separately',await ev(`!document.getElementById('project-ground-regenerate').disabled&&document.querySelector('.project-ground-table tbody tr').children[2].textContent==='29.000'`));
await click(`document.getElementById('project-ground-regenerate')`);
await shot('01-project-ground');
await click(`document.querySelector('#project-ground-dialog .viewer-head button')`);
await click(`document.getElementById('nav-plan')`);
await click(`[...document.querySelectorAll('#members button')].find(b=>b.textContent.includes('지반'))`);
check('per-pier ground card has no model generation or file picker',await ev(`!document.getElementById('view-plan').textContent.includes('지반 모델 파일 불러오기 · 지반 생성 프로그램')&&document.querySelectorAll('#view-plan input[type=file]').length===0`));
await ev(`(()=>{window.__savedGround=null;const original=URL.createObjectURL;URL.createObjectURL=function(blob){blob.text().then(text=>{try{window.__savedGround=JSON.parse(text);}catch{}});return original.call(this,blob);};})()`);
await click(`document.getElementById('save-project')`);
await until(`!!window.__savedGround?.groundModel`);
const saved=await ev(`window.__savedGround`);
check('project saves shared model once and applied inputs',saved.groundModel.piers.length===2&&saved.piers[0].input.groundEl===29&&saved.piers[0].input.strataRows[0].name==='암반'&&saved.piers[1].input.groundEl===30);
const savedFile=path.join(out,'saved.pzr.json');fs.writeFileSync(savedFile,JSON.stringify(saved));
await selectFile('#project-file',[savedFile]);
await until(`document.getElementById('project-ground-status').textContent.includes('불러옴')`);
check('project reopen restores ground model',await ev(`document.getElementById('project-ground-status').textContent.includes('2/3')`));
// Mock the external engine; deliver an in-flight result after switching projects.
await ev(`(()=>{const original=window.fetch;window.__releaseGround=null;window.__groundReady=false;window.fetch=async(url,init)=>{const path=String(url);if(path.includes('/ground-link/prepare'))return new Response(JSON.stringify({drawing:'test.dxf',excelName:'test.xlsx',excelSelected:true}));if(path.includes('/ground-link/upload'))return new Response(JSON.stringify({file:'test.xlsx'}));if(path.includes('/ground-link/launch'))return new Response(JSON.stringify({id:'mock'}));if(path.includes('/ground-link/result')){window.__groundReady=true;return new Promise(resolve=>window.__releaseGround=()=>resolve(new Response(JSON.stringify({ready:true,state:'done',filename:'old.json',text:${JSON.stringify(JSON.stringify({...raw,generated:'mock-generated'}))}}))));}return original(url,init);};})()`);
const excel=path.join(out,'source.xlsx');fs.writeFileSync(excel,'mock');
const startGeneration = async () => {
await ev(`window.__groundReady=false`);
await click(`document.getElementById('nav-shape')`);
await click(`document.getElementById('project-ground-open')`);
await click(`[...document.querySelectorAll('#project-ground-dialog button')].find(b=>b.textContent==='지반모델 생성')`);
await until(`document.querySelector('.ground-popup [data-run]')&&!document.querySelector('.ground-popup [data-run]').disabled`);
await selectFile('#ground-excel-file',[excel]);
await click(`document.querySelector('.ground-popup [data-run]')`);
await until(`window.__groundReady`);
};
await startGeneration();
await click(`document.querySelector('.ground-popup [data-close]')`);
await click(`document.querySelector('#project-ground-dialog .viewer-head button')`);
await click(`document.getElementById('nav-rebar')`);
await ev(`window.__releaseGround()`);
await until(`document.querySelector('.ground-popup [data-status]').textContent.includes('생성 완료')`);
await click(`document.getElementById('nav-shape')`);
await click(`document.getElementById('project-ground-open')`);
check('generated model imports after leaving both manager and ground card',await ev(`document.getElementById('project-ground-info').textContent.includes('mock-generated')`));
await click(`document.querySelector('#project-ground-dialog .viewer-head button')`);
await startGeneration();
await selectFile('#project-file',[file]);
await until(`document.getElementById('project-ground-status').textContent==='모델 없음'`);
await ev(`window.__releaseGround()`);
await delay(300);
check('late generated result does not contaminate another project',await ev(`document.getElementById('project-ground-status').textContent==='모델 없음'`));
check('project switch closes manager and generator',await ev(`!document.getElementById('project-ground-dialog').open&&!document.querySelector('.ground-popup').open`));
await selectFile('#project-file',[savedFile]);
await until(`document.getElementById('project-ground-status').textContent.includes('불러옴')`);
await ev(`(async()=>{const bytes=await(await fetch('/'+encodeURIComponent('도면추출')+'/samples/'+encodeURIComponent('운곡교P1입력.xlsx'))).arrayBuffer();window.loadBytesToApp(new Uint8Array(bytes),'새 프로젝트.xlsx');const name=document.getElementById('project-name');name.value='새 프로젝트';name.dispatchEvent(new Event('input'));document.getElementById('import-dialog').showModal();})()`);
await until(`document.getElementById('import-dialog').open&&!document.getElementById('dlg-apply').disabled`);
await click(`document.getElementById('dlg-apply')`);
await until(`document.getElementById('project-ground-status').textContent==='모델 없음'`);
check('new Excel import clears previous project ground model',await ev(`document.getElementById('project-ground-status').textContent==='모델 없음'`));
check('no runtime exceptions',report.errors.length===0);
report.result='PASS';
})().catch(e=>{report.result='FAIL';report.failure=e.stack;process.exitCode=1;}).finally(()=>{
fs.writeFileSync(path.join(out,'report.json'),JSON.stringify(report,null,2));console.log(JSON.stringify(report));ws?.close();
});
+97
View File
@@ -0,0 +1,97 @@
// Run with Windows Node and an isolated Chrome on CDP 9444; Vite must be on 5173.
const fs = require('node:fs'), path = require('node:path'), assert = require('node:assert/strict');
const out = path.resolve(__dirname, '../.verify/quantity-generation-20261106');
fs.mkdirSync(out, { recursive: true });
const report = { checks: [], errors: [] };
let ws, send;
const delay = ms => new Promise(r => setTimeout(r, ms));
(async () => {
const target = await (await fetch('http://127.0.0.1:9444/json/new?about:blank', { method: 'PUT' })).json();
ws = new WebSocket(target.webSocketDebuggerUrl);
await new Promise((r, j) => { ws.onopen = r; ws.onerror = j; });
let seq = 0; const pending = new Map();
ws.onmessage = ({ data }) => {
const m = JSON.parse(data), p = pending.get(m.id);
if (p) { clearTimeout(p.timer); pending.delete(m.id); m.error ? p.reject(new Error(JSON.stringify(m.error))) : p.resolve(m.result); }
if (m.method === 'Runtime.exceptionThrown') report.errors.push(m.params.exceptionDetails.exception?.description || m.params.exceptionDetails.text);
};
send = (method, params = {}) => new Promise((resolve, reject) => {
const id = ++seq, timer = setTimeout(() => reject(new Error('Timeout ' + method)), 30000);
pending.set(id, { resolve, reject, timer }); ws.send(JSON.stringify({ id, method, params }));
});
const ev = async expression => {
const r = await send('Runtime.evaluate', { expression, awaitPromise: true, returnByValue: true });
if (r.exceptionDetails) throw new Error(r.exceptionDetails.exception?.description || r.exceptionDetails.text);
return r.result.value;
};
const until = async expression => {
for (let i = 0; i < 120; i++) { if (await ev(expression)) return; await delay(150); }
throw new Error('Wait ' + expression);
};
const check = (name, value) => { assert.ok(value, name); report.checks.push(name); console.log('PASS', name); };
const click = expression => ev(`(()=>{const e=${expression};if(!e||e.disabled)throw new Error('Missing target');e.click();})()`);
const shot = async name => {
const r = await send('Page.captureScreenshot', { format: 'png' });
fs.writeFileSync(path.join(out, name + '.png'), Buffer.from(r.data, 'base64'));
};
await send('Page.enable'); await send('Runtime.enable'); await send('DOM.enable');
await send('Emulation.setDeviceMetricsOverride', { width: 1600, height: 1000, deviceScaleFactor: 1, mobile: false });
await send('Page.navigate', { url: 'http://127.0.0.1:5173/' });
await until(`typeof window.openSampleProject==='function'`);
const fixture = await ev(`(async()=>{
const XLSX=await import('/node_modules/xlsx/xlsx.mjs'),{parseWorkbook}=await import('/src/core/parseWorkbook.ts');
const bytes=await(await fetch('/'+encodeURIComponent('도면추출')+'/samples/'+encodeURIComponent('운곡교P1입력.xlsx'))).arrayBuffer();
const base=parseWorkbook(XLSX.read(bytes)).piers.find(p=>p.name==='P1');
if(!base)throw new Error('Missing sample pier');
const {defaultLevelWidthMm}=await import('/src/core/geometry.ts');
const piers=['직접기초','직접기초','현장타설말뚝'].map((foundationType,i)=>{const input={...base,name:'P'+(i+1),foundationType,type:'T형',columnCount:1,groundEl:30,pileLengthMm:24000,castInPlaceShape:{clayBottomEl:16},columnDiameterMm:2000,footingLengthMm:10400,footingWidthMm:8150,footingHeightMm:2000,blindingMarginMm:100,blindingThicknessMm:100,pileKind:'강관',pileOuterMm:609.6,pileThicknessMm:12,copingH1Mm:1000,copingH2Mm:1500,levelWidthMm:defaultLevelWidthMm('T형',2000)};return{id:input.name,input,defaults:input,draft:input};});
return{schemaVersion:1,project:{name:'병합 검증',bridge:'검증',client:'',author:'',crs:'',memo:''},source:{fileName:'합성 표본',sheets:[]},piers,history:[]};
})()`);
const file = path.join(out, 'fixture.pzr.json'); fs.writeFileSync(file, JSON.stringify(fixture));
await ev('window.openSampleProject()');
await click(`document.querySelector('#file-menu summary')`); await click(`document.getElementById('open-project')`);
const doc = await send('DOM.getDocument');
const { nodeId } = await send('DOM.querySelector', { nodeId: doc.root.nodeId, selector: '#project-file' });
await send('DOM.setFileInputFiles', { nodeId, files: [file] });
await until(`document.querySelectorAll('#rebar-pier-items button').length===3`);
await click(`document.getElementById('nav-rebar')`);
check('local rebar screen renders', await ev(`!document.getElementById('view-rebar').hidden&&!!document.querySelector('#rebar-table table')`));
await click(`[...document.querySelectorAll('#rebar-pier-items button')].find(b=>b.textContent.startsWith('P3 ·'))`);
await click(`[...document.querySelectorAll('#rebar-tabs button')].find(b=>b.textContent.startsWith('기둥'))`);
check('unified cast input and physical rebar preserved', await ev(`!!document.getElementById('cip-apply')&&document.getElementById('rebar-table').textContent.includes('말뚝-P1')`));
await shot('01-cast');
await click(`[...document.querySelectorAll('button')].find(b=>b.textContent==='철근재료표 보기')`);
check('materials is the default result tab', await ev(`!document.getElementById('view-quantity').hidden&&document.querySelector('[data-takeoff-mode="materials"]').getAttribute('aria-pressed')==='true'`));
check('local material table preserved', await ev(`document.getElementById('quantity-body').textContent.includes('말뚝-P1')`));
await shot('02-materials');
check('partial results explicitly export as review only', await ev(`!document.getElementById('quantity-export').disabled&&document.getElementById('quantity-export').textContent.includes('검토용')`));
check('piece details expose count and individual lengths', await ev(`!!document.querySelector('#quantity-body details table')&&document.getElementById('quantity-body').textContent.includes('가공 본수')`));
await click(`[...document.querySelectorAll('#quantity-pier-items button')].find(b=>b.textContent.startsWith('P1 ·'))`);
check('current regular pier permits export', await ev(`!document.getElementById('quantity-export').disabled`));
await click(`document.querySelector('[data-takeoff-mode="takeoff"]')`);
check('selected pier receives automatic rebar weight', await ev(`!document.querySelector('input[data-variable="rebar"]')&&document.querySelector('.takeoff-workspace').textContent.includes('기존 철근재료표')`));
await ev(`(()=>{const e=document.querySelector('select[aria-label="수량산출 대상 교각"]');e.value='P2';e.dispatchEvent(new Event('change'));})()`);
check('another pier retains automatic weight despite material filter', await ev(`document.querySelector('select[aria-label="수량산출 대상 교각"]').value==='P2'&&!document.querySelector('input[data-variable="rebar"]')&&document.querySelector('.takeoff-workspace').textContent.includes('기존 철근재료표')`));
await click(`document.getElementById('nav-drawing')`);
await until(`!!document.querySelector('#view-drawing svg')`);
check('origin drawing screen renders finite geometry', await ev(`!document.getElementById('view-drawing').hidden&&!/NaN|Infinity/.test(document.querySelector('#view-drawing svg').outerHTML)`));
await shot('03-drawing');
await click(`document.getElementById('ga-materials')`);
check('drawing shortcut opens materials rather than takeoff', await ev(`!document.getElementById('view-quantity').hidden&&document.querySelector('[data-takeoff-mode="materials"]').getAttribute('aria-pressed')==='true'`));
await click(`document.getElementById('nav-plan')`);
check('plan remains reachable after drawing', await ev(`!document.getElementById('view-plan').hidden`));
await ev(`(()=>{const e=document.getElementById('edit-copingLengthMm');e.value=Number(e.value)+100;e.dispatchEvent(new Event('change'));})()`);
await click(`document.getElementById('nav-rebar')`);
await click(`document.getElementById('rebar-materials')`);
check('dirty geometry blocks Excel and print with reason', await ev(`document.getElementById('quantity-export').disabled&&document.getElementById('quantity-print').disabled&&document.getElementById('quantity-body').textContent.includes('재생성 필요')`));
for (const width of [1280, 1600, 1920]) {
await send('Emulation.setDeviceMetricsOverride', { width, height: 1000, deviceScaleFactor: 1, mobile: false });
await delay(150);
check(`one-row header preserved at ${width}`, await ev(`document.querySelector('.top').getBoundingClientRect().height<=65&&document.documentElement.scrollWidth<=innerWidth`));
}
check('no runtime exceptions', report.errors.length === 0);
report.result = 'PASS';
})().catch(e => { report.result = 'FAIL'; report.failure = e.stack; process.exitCode = 1; }).finally(() => {
fs.writeFileSync(path.join(out, 'report.json'), JSON.stringify(report, null, 2));
console.log(JSON.stringify(report)); ws?.close();
});
+109
View File
@@ -0,0 +1,109 @@
// Run with Windows Node and an isolated Chrome on CDP 9444; Vite must be on 5173.
const fs = require('node:fs'), path = require('node:path'), assert = require('node:assert/strict');
const out = path.resolve(__dirname, '../.verify/quantity-navigation-20261109');
fs.mkdirSync(out, { recursive: true });
const report = { checks: [], errors: [] };
let ws, send;
const delay = ms => new Promise(r => setTimeout(r, ms));
(async () => {
const target = await (await fetch('http://127.0.0.1:9444/json/new?about:blank', { method: 'PUT' })).json();
ws = new WebSocket(target.webSocketDebuggerUrl);
await new Promise((r, j) => { ws.onopen = r; ws.onerror = j; });
let seq = 0; const pending = new Map();
ws.onmessage = ({ data }) => {
const m = JSON.parse(data), p = pending.get(m.id);
if (p) { clearTimeout(p.timer); pending.delete(m.id); m.error ? p.reject(new Error(JSON.stringify(m.error))) : p.resolve(m.result); }
if (m.method === 'Runtime.exceptionThrown') report.errors.push(m.params.exceptionDetails.exception?.description || m.params.exceptionDetails.text);
};
send = (method, params = {}) => new Promise((resolve, reject) => {
const id = ++seq, timer = setTimeout(() => reject(new Error('Timeout ' + method)), 30000);
pending.set(id, { resolve, reject, timer }); ws.send(JSON.stringify({ id, method, params }));
});
const ev = async expression => {
const r = await send('Runtime.evaluate', { expression, awaitPromise: true, returnByValue: true });
if (r.exceptionDetails) throw new Error(r.exceptionDetails.exception?.description || r.exceptionDetails.text);
return r.result.value;
};
const until = async expression => {
for (let i = 0; i < 120; i++) { if (await ev(expression)) return; await delay(150); }
throw new Error('Wait ' + expression);
};
const check = (name, value) => { assert.ok(value, name); report.checks.push(name); console.log('PASS', name); };
const click = expression => ev(`(()=>{const e=${expression};if(!e||e.disabled)throw new Error('Missing target');e.click();})()`);
const shot = async name => {
const r = await send('Page.captureScreenshot', { format: 'png' });
fs.writeFileSync(path.join(out, name + '.png'), Buffer.from(r.data, 'base64'));
};
await send('Page.enable'); await send('Runtime.enable'); await send('DOM.enable');
await send('Emulation.setDeviceMetricsOverride', { width: 1600, height: 1000, deviceScaleFactor: 1, mobile: false });
await send('Page.navigate', { url: process.env.PIERZAINER_URL || 'http://127.0.0.1:5173/' });
await until(`typeof window.openSampleProject==='function'`);
const fixture = await ev(`(async()=>{
const XLSX=await import('/node_modules/xlsx/xlsx.mjs'),{parseWorkbook}=await import('/src/core/parseWorkbook.ts');
const bytes=await(await fetch('/'+encodeURIComponent('도면추출')+'/samples/'+encodeURIComponent('운곡교P1입력.xlsx'))).arrayBuffer();
const base=parseWorkbook(XLSX.read(bytes)).piers.find(p=>p.name==='P1');
if(!base)throw new Error('Missing sample pier');
const {defaultLevelWidthMm}=await import('/src/core/geometry.ts');
const piers=['직접기초','직접기초','현장타설말뚝'].map((foundationType,i)=>{const input={...base,name:'P'+(i+1),foundationType,type:'T형',columnCount:1,groundEl:30,pileLengthMm:24000,castInPlaceShape:{clayBottomEl:16},columnDiameterMm:2000,footingLengthMm:10400,footingWidthMm:8150,footingHeightMm:2000,blindingMarginMm:100,blindingThicknessMm:100,pileKind:'강관',pileOuterMm:609.6,pileThicknessMm:12,copingH1Mm:1000,copingH2Mm:1500,levelWidthMm:defaultLevelWidthMm('T형',2000)};return{id:input.name,input,defaults:input,draft:input};});
return{schemaVersion:1,project:{name:'병합 검증',bridge:'검증',client:'',author:'',crs:'',memo:''},source:{fileName:'합성 표본',sheets:[]},piers,history:[]};
})()`);
const file = path.join(out, 'fixture.pzr.json'); fs.writeFileSync(file, JSON.stringify(fixture));
await ev('window.openSampleProject()');
await click(`document.querySelector('#file-menu summary')`); await click(`document.getElementById('open-project')`);
const doc = await send('DOM.getDocument');
const { nodeId } = await send('DOM.querySelector', { nodeId: doc.root.nodeId, selector: '#project-file' });
await send('DOM.setFileInputFiles', { nodeId, files: [file] });
await until(`document.querySelectorAll('#rebar-pier-items button').length===3`);
await click(`document.getElementById('nav-rebar')`);
check('local rebar screen renders', await ev(`!document.getElementById('view-rebar').hidden&&!!document.querySelector('#rebar-table table')`));
await click(`[...document.querySelectorAll('#rebar-pier-items button')].find(b=>b.textContent.startsWith('P3 ·'))`);
await click(`[...document.querySelectorAll('#rebar-tabs button')].find(b=>b.textContent.startsWith('기둥'))`);
check('unified cast input and physical rebar preserved', await ev(`!!document.getElementById('cip-apply')&&document.getElementById('rebar-table').textContent.includes('말뚝-P1')`));
await shot('01-cast');
await click(`[...document.querySelectorAll('button')].find(b=>b.textContent==='철근재료표 보기')`);
check('materials is the default result tab', await ev(`!document.getElementById('view-quantity').hidden&&document.querySelector('[data-takeoff-mode="materials"]').getAttribute('aria-pressed')==='true'`));
check('local material table preserved', await ev(`document.getElementById('quantity-body').textContent.includes('말뚝-P1')`));
await shot('02-materials');
check('partial results explicitly export as review only', await ev(`!document.getElementById('quantity-export').disabled&&document.getElementById('quantity-export').textContent.includes('검토용')`));
check('piece details expose count and individual lengths', await ev(`!!document.querySelector('#quantity-body details table')&&document.getElementById('quantity-body').textContent.includes('가공 본수')`));
await click(`[...document.querySelectorAll('#quantity-pier-items button')].find(b=>b.textContent.startsWith('P1 ·'))`);
check('current regular pier permits export', await ev(`!document.getElementById('quantity-export').disabled`));
await click(`document.querySelector('[data-takeoff-mode="takeoff"]')`);
check('selected pier receives automatic rebar weight', await ev(`!document.querySelector('input[data-variable="rebar"]')&&document.querySelector('.takeoff-workspace').textContent.includes('기존 철근재료표')`));
await ev(`(()=>{const e=document.querySelector('select[aria-label="수량산출 대상 교각"]');e.value='P2';e.dispatchEvent(new Event('change'));})()`);
check('another pier retains automatic weight despite material filter', await ev(`document.querySelector('select[aria-label="수량산출 대상 교각"]').value==='P2'&&!document.querySelector('input[data-variable="rebar"]')&&document.querySelector('.takeoff-workspace').textContent.includes('기존 철근재료표')`));
check('takeoff displays input, results and preview panels', await ev(`document.querySelectorAll('.tq-dashboard-grid > section, .tq-dashboard-grid > aside').length===3&&!!document.querySelector('.takeoff-excel-preview')`));
await shot('04-takeoff');
await click(`document.getElementById('nav-drawing')`);
await until(`!!document.querySelector('#view-drawing svg')`);
check('origin drawing screen renders finite geometry', await ev(`!document.getElementById('view-drawing').hidden&&!/NaN|Infinity/.test(document.querySelector('#view-drawing svg').outerHTML)`));
await shot('03-drawing');
await click(`document.getElementById('ga-materials')`);
check('drawing shortcut preserves takeoff tab and selected pier', await ev(`!document.getElementById('view-quantity').hidden&&document.querySelector('[data-takeoff-mode="takeoff"]').getAttribute('aria-pressed')==='true'&&document.querySelector('select[aria-label="수량산출 대상 교각"]').value==='P2'`));
check('quantity header alone is highlighted', await ev(`document.getElementById('nav-quantity').getAttribute('aria-current')==='true'&&document.getElementById('nav-rebar').getAttribute('aria-current')==='false'`));
await click(`document.getElementById('nav-rebar')`);
await click(`[...document.querySelectorAll('#rebar-pier-items button')].find(b=>b.textContent.startsWith('P1 ·'))`);
await click(`document.getElementById('nav-quantity')`);
check('header entry restores previous result pier after another screen changes selection', await ev(`document.querySelector('select[aria-label="수량산출 대상 교각"]').value==='P2'`));
await click(`document.querySelector('[data-takeoff-mode="materials"]')`);
await click(`document.getElementById('nav-rebar')`);
await click(`document.getElementById('rebar-materials')`);
check('rebar shortcut preserves materials tab', await ev(`document.querySelector('[data-takeoff-mode="materials"]').getAttribute('aria-pressed')==='true'`));
await click(`[...document.querySelectorAll('#quantity-pier-items button')].find(b=>b.textContent.startsWith('P1 ·'))`);
await click(`document.getElementById('nav-plan')`);
check('plan remains reachable after drawing', await ev(`!document.getElementById('view-plan').hidden`));
await ev(`(()=>{const e=document.getElementById('edit-copingLengthMm');e.value=Number(e.value)+100;e.dispatchEvent(new Event('change'));})()`);
await click(`document.getElementById('nav-rebar')`);
await click(`document.getElementById('rebar-materials')`);
check('dirty geometry blocks Excel and print with reason', await ev(`document.getElementById('quantity-export').disabled&&document.getElementById('quantity-print').disabled&&document.getElementById('quantity-body').textContent.includes('재생성 필요')`));
for (const width of [1280, 1600, 1920]) {
await send('Emulation.setDeviceMetricsOverride', { width, height: 1000, deviceScaleFactor: 1, mobile: false });
await delay(150);
check(`one-row header preserved at ${width}`, await ev(`document.querySelector('.top').getBoundingClientRect().height<=65&&document.documentElement.scrollWidth<=innerWidth`));
}
check('no runtime exceptions', report.errors.length === 0);
report.result = 'PASS';
})().catch(e => { report.result = 'FAIL'; report.failure = e.stack; process.exitCode = 1; }).finally(() => {
fs.writeFileSync(path.join(out, 'report.json'), JSON.stringify(report, null, 2));
console.log(JSON.stringify(report)); ws?.close();
});
+120
View File
@@ -0,0 +1,120 @@
// Run with Windows Node and an isolated Chrome on CDP 9444; Vite must be on 5173.
const fs = require('node:fs'), path = require('node:path'), assert = require('node:assert/strict');
const out = path.resolve(__dirname, '../.verify/takeoff-alignment-20261106/browser');
fs.mkdirSync(out, { recursive: true });
const report = { checks: [], errors: [] };
let ws, send;
const delay = ms => new Promise(r => setTimeout(r, ms));
(async () => {
const target = await (await fetch('http://127.0.0.1:9444/json/new?about:blank', { method: 'PUT' })).json();
ws = new WebSocket(target.webSocketDebuggerUrl);
await new Promise((r, j) => { ws.onopen = r; ws.onerror = j; });
let seq = 0; const pending = new Map();
ws.onmessage = ({ data }) => {
const m = JSON.parse(data), p = pending.get(m.id);
if (p) { clearTimeout(p.timer); pending.delete(m.id); m.error ? p.reject(new Error(JSON.stringify(m.error))) : p.resolve(m.result); }
if (m.method === 'Runtime.exceptionThrown') report.errors.push(m.params.exceptionDetails.exception?.description || m.params.exceptionDetails.text);
};
send = (method, params = {}) => new Promise((resolve, reject) => {
const id = ++seq, timer = setTimeout(() => reject(new Error('Timeout ' + method)), 30000);
pending.set(id, { resolve, reject, timer }); ws.send(JSON.stringify({ id, method, params }));
});
const ev = async expression => {
const r = await send('Runtime.evaluate', { expression, awaitPromise: true, returnByValue: true });
if (r.exceptionDetails) throw new Error(r.exceptionDetails.exception?.description || r.exceptionDetails.text);
return r.result.value;
};
const until = async expression => {
for (let i = 0; i < 120; i++) { if (await ev(expression)) return; await delay(150); }
throw new Error('Wait ' + expression);
};
const check = (name, value) => { assert.ok(value, name); report.checks.push(name); console.log('PASS', name); };
const click = expression => ev(`(()=>{const e=${expression};if(!e||e.disabled)throw new Error('Missing target');e.click();})()`);
const shot = async name => {
const r = await send('Page.captureScreenshot', { format: 'png' });
fs.writeFileSync(path.join(out, name + '.png'), Buffer.from(r.data, 'base64'));
};
await send('Page.enable'); await send('Runtime.enable'); await send('DOM.enable');
await send('Browser.setDownloadBehavior', {behavior:'deny'}); // Request transport is mocked below; native Excel is verified separately.
await send('Emulation.setDeviceMetricsOverride', { width: 1600, height: 1000, deviceScaleFactor: 1, mobile: false });
await send('Page.navigate', { url: 'http://127.0.0.1:5173/' });
await until(`typeof window.openSampleProject==='function'`);
const fixture = await ev(`(async()=>{
const XLSX=await import('/node_modules/xlsx/xlsx.mjs'),{parseWorkbook}=await import('/src/core/parseWorkbook.ts');
const bytes=await(await fetch('/'+encodeURIComponent('도면추출')+'/samples/'+encodeURIComponent('운곡교P1입력.xlsx'))).arrayBuffer();
const base=parseWorkbook(XLSX.read(bytes)).piers.find(p=>p.name==='P1');
if(!base)throw new Error('Missing sample pier');
const {defaultLevelWidthMm}=await import('/src/core/geometry.ts');
const piers=['직접기초','직접기초','현장타설말뚝'].map((foundationType,i)=>{const input={...base,name:'P'+(i+1),foundationType,type:'T형',columnCount:1,groundEl:30,pileLengthMm:24000,castInPlaceShape:{clayBottomEl:16},columnDiameterMm:2000,footingLengthMm:10400,footingWidthMm:8150,footingHeightMm:2000,blindingMarginMm:100,blindingThicknessMm:100,pileKind:'강관',pileOuterMm:609.6,pileThicknessMm:12,copingH1Mm:1000,copingH2Mm:1500,levelWidthMm:defaultLevelWidthMm('T형',2000)};return{id:input.name,input,defaults:input,draft:input};});
return{schemaVersion:1,project:{name:'병합 검증',bridge:'검증',client:'',author:'',crs:'',memo:''},source:{fileName:'합성 표본',sheets:[]},piers,history:[]};
})()`);
for(const pier of fixture.piers){
const settings={values:{r20:0,r21:2.8,r22:0,r23:0},reviewed:true};
for(const key of ['input','defaults','draft'])pier[key]={...pier[key],quantityTakeoff:settings};
}
const file = path.join(out, 'fixture.pzr.json'); fs.writeFileSync(file, JSON.stringify(fixture));
await ev('window.openSampleProject()');
await click(`document.querySelector('#file-menu summary')`); await click(`document.getElementById('open-project')`);
const doc = await send('DOM.getDocument');
const { nodeId } = await send('DOM.querySelector', { nodeId: doc.root.nodeId, selector: '#project-file' });
await send('DOM.setFileInputFiles', { nodeId, files: [file] });
await until(`document.querySelectorAll('#rebar-pier-items button').length===3`);
await click(`document.getElementById('nav-rebar')`);
check('local rebar screen renders', await ev(`!document.getElementById('view-rebar').hidden&&!!document.querySelector('#rebar-table table')`));
await click(`[...document.querySelectorAll('#rebar-pier-items button')].find(b=>b.textContent.startsWith('P3 ·'))`);
await click(`[...document.querySelectorAll('#rebar-tabs button')].find(b=>b.textContent.startsWith('기둥'))`);
check('unified cast input and physical rebar preserved', await ev(`!!document.getElementById('cip-apply')&&document.getElementById('rebar-table').textContent.includes('말뚝-P1')`));
await shot('01-cast');
await click(`[...document.querySelectorAll('button')].find(b=>b.textContent==='철근재료표 보기')`);
check('materials is the default result tab', await ev(`!document.getElementById('view-quantity').hidden&&document.querySelector('[data-takeoff-mode="materials"]').getAttribute('aria-pressed')==='true'`));
check('local material table preserved', await ev(`document.getElementById('quantity-body').textContent.includes('말뚝-P1')`));
await shot('02-materials');
check('partial results explicitly export as review only', await ev(`!document.getElementById('quantity-export').disabled&&document.getElementById('quantity-export').textContent.includes('검토용')`));
check('piece details expose count and individual lengths', await ev(`!!document.querySelector('#quantity-body details table')&&document.getElementById('quantity-body').textContent.includes('가공 본수')`));
await click(`[...document.querySelectorAll('#quantity-pier-items button')].find(b=>b.textContent.startsWith('P1 ·'))`);
check('current regular pier permits export', await ev(`!document.getElementById('quantity-export').disabled`));
await click(`document.querySelector('[data-takeoff-mode="takeoff"]')`);
check('selected pier receives automatic rebar weight', await ev(`!document.querySelector('input[data-variable="rebar"]')&&document.querySelector('.takeoff-workspace').textContent.includes('기존 철근재료표')`));
check('selected and all Excel exports are separate', await ev(`document.querySelector('[data-takeoff-export="selected"]').textContent==='선택 교각 Excel'&&document.querySelector('[data-takeoff-export="all"]').textContent==='전체 교각 Excel'`));
await click(`document.querySelector('button[aria-label="수량산출 재계산"]')`);
await ev(`(()=>{
const original=window.fetch;window.__takeoffRequests=[];
window.fetch=(url,options)=>String(url).includes('/__pierzainer/quantity/export')?new Promise(resolve=>{window.__takeoffRequests.push(JSON.parse(options.body));window.__finishTakeoff=()=>resolve(new Response(new Uint8Array([80,75]),{status:200}));}):original(url,options);
})()`);
await click(`document.querySelector('[data-takeoff-export="selected"]')`);
await until(`window.__takeoffRequests.length===1`);
check('selected request carries one pier and model solplate mode', await ev(`window.__takeoffRequests[0].piers.length===1&&window.__takeoffRequests[0].piers[0].pier==='P1'&&window.__takeoffRequests[0].piers[0].solplateMode==='model'`));
await ev(`(()=>{const e=document.querySelector('input[data-variable="r21"]');e.value='4';e.dispatchEvent(new Event('change'));})()`);
check('rerender during export keeps both export buttons locked', await ev(`[...document.querySelectorAll('[data-takeoff-export]')].every(b=>b.disabled)`));
await ev(`window.__finishTakeoff()`);
await until(`!document.querySelector('[data-takeoff-export="selected"]').disabled`);
check('async export preserves edited input and displays snapshot warning', await ev(`document.querySelector('input[data-variable="r21"]').value==='4'&&document.querySelector('.takeoff-workspace [role="status"]').textContent.includes('입력은 보존')`));
const requests=await ev(`window.__takeoffRequests.length`);
await click(`document.querySelector('[data-takeoff-export="all"]')`);
await delay(200);
check('all export validates other incomplete piers before requesting server', await ev(`window.__takeoffRequests.length===${requests}&&document.querySelector('.takeoff-workspace [role="status"]').textContent.includes('P3')`));
await ev(`(()=>{const e=document.querySelector('select[aria-label="수량산출 대상 교각"]');e.value='P2';e.dispatchEvent(new Event('change'));})()`);
check('another pier retains automatic weight despite material filter', await ev(`document.querySelector('select[aria-label="수량산출 대상 교각"]').value==='P2'&&!document.querySelector('input[data-variable="rebar"]')&&document.querySelector('.takeoff-workspace').textContent.includes('기존 철근재료표')`));
await click(`document.getElementById('nav-drawing')`);
await until(`!!document.querySelector('#view-drawing svg')`);
check('origin drawing screen renders finite geometry', await ev(`!document.getElementById('view-drawing').hidden&&!/NaN|Infinity/.test(document.querySelector('#view-drawing svg').outerHTML)`));
await shot('03-drawing');
await click(`document.getElementById('ga-materials')`);
check('drawing shortcut opens materials rather than takeoff', await ev(`!document.getElementById('view-quantity').hidden&&document.querySelector('[data-takeoff-mode="materials"]').getAttribute('aria-pressed')==='true'`));
await click(`document.getElementById('nav-plan')`);
check('plan remains reachable after drawing', await ev(`!document.getElementById('view-plan').hidden`));
await ev(`(()=>{const e=document.getElementById('edit-copingLengthMm');e.value=Number(e.value)+100;e.dispatchEvent(new Event('change'));})()`);
await click(`document.getElementById('nav-rebar')`);
await click(`document.getElementById('rebar-materials')`);
check('dirty geometry blocks Excel and print with reason', await ev(`document.getElementById('quantity-export').disabled&&document.getElementById('quantity-print').disabled&&document.getElementById('quantity-body').textContent.includes('재생성 필요')`));
for (const width of [1280, 1600, 1920]) {
await send('Emulation.setDeviceMetricsOverride', { width, height: 1000, deviceScaleFactor: 1, mobile: false });
await delay(150);
check(`one-row header preserved at ${width}`, await ev(`document.querySelector('.top').getBoundingClientRect().height<=65&&document.documentElement.scrollWidth<=innerWidth`));
}
check('no runtime exceptions', report.errors.length === 0);
report.result = 'PASS';
})().catch(e => { report.result = 'FAIL'; report.failure = e.stack; process.exitCode = 1; }).finally(() => {
fs.writeFileSync(path.join(out, 'report.json'), JSON.stringify(report, null, 2));
console.log(JSON.stringify(report)); ws?.close();
});
+49
View File
@@ -0,0 +1,49 @@
// Run with Windows Node against isolated Chrome CDP 9444 and Vite 5173.
const fs=require('node:fs'),path=require('node:path'),assert=require('node:assert/strict');
const root=path.resolve(__dirname,'..'),out=path.join(root,'.verify/ungok-terrain-fixed-20261110/browser');
fs.mkdirSync(out,{recursive:true});
const report={checks:[],errors:[]};let ws;
const delay=ms=>new Promise(r=>setTimeout(r,ms));
(async()=>{
const tab=await(await fetch('http://127.0.0.1:9444/json/new?about:blank',{method:'PUT'})).json();
ws=new WebSocket(tab.webSocketDebuggerUrl);await new Promise((r,j)=>{ws.onopen=r;ws.onerror=j;});
let seq=0;const pending=new Map();
ws.onmessage=({data})=>{const m=JSON.parse(data),p=pending.get(m.id);if(p){clearTimeout(p.timer);pending.delete(m.id);m.error?p.reject(Error(JSON.stringify(m.error))):p.resolve(m.result);}if(m.method==='Runtime.exceptionThrown')report.errors.push(m.params.exceptionDetails.exception?.description||m.params.exceptionDetails.text);};
const send=(method,params={})=>new Promise((resolve,reject)=>{const id=++seq,timer=setTimeout(()=>reject(Error('Timeout '+method)),30000);pending.set(id,{resolve,reject,timer});ws.send(JSON.stringify({id,method,params}));});
const ev=async expression=>{const r=await send('Runtime.evaluate',{expression,awaitPromise:true,returnByValue:true});if(r.exceptionDetails)throw Error(r.exceptionDetails.exception?.description||r.exceptionDetails.text);return r.result.value;};
const until=async expression=>{for(let i=0;i<150;i++){if(await ev(expression))return;await delay(200);}throw Error('Wait '+expression);};
const check=(name,value)=>{assert.ok(value,name);report.checks.push(name);console.log('PASS',name);};
const click=expression=>ev(`(()=>{const e=${expression};if(!e||e.disabled)throw Error('Missing target '+${JSON.stringify(expression)});e.click();})()`);
const file=async(selector,name)=>{const d=await send('DOM.getDocument'),{nodeId}=await send('DOM.querySelector',{nodeId:d.root.nodeId,selector});await send('DOM.setFileInputFiles',{nodeId,files:[name]});};
const shot=async name=>{const s=await send('Page.captureScreenshot',{format:'png'});fs.writeFileSync(path.join(out,name+'.png'),Buffer.from(s.data,'base64'));};
await send('Page.enable');await send('Runtime.enable');await send('DOM.enable');
await send('Emulation.setDeviceMetricsOverride',{width:1600,height:1000,deviceScaleFactor:1,mobile:false});
await send('Page.navigate',{url:'http://127.0.0.1:5173/'});await until(`typeof window.openSampleProject==='function'`);
const settings=JSON.parse(fs.readFileSync(path.join(root,'pierzainer_ground/data/output/ungok_corrected_20261110/generation_settings.json')));
const workbook='\\\\wsl.localhost\\Ubuntu'+settings.piers.replaceAll('/','\\');
await ev('window.openSampleProject()');await file('#file',workbook);
await until(`document.getElementById('file-name').textContent.includes('piers.xlsx')`);
await ev(`(()=>{const e=document.getElementById('project-name');e.value='운곡교 지반 수정 검증';e.dispatchEvent(new Event('input'));document.getElementById('import-dialog').showModal();})()`);
await until(`!document.getElementById('dlg-apply').disabled`);
await click(`document.getElementById('dlg-apply')`);await click(`document.getElementById('nav-shape')`);await click(`document.getElementById('project-ground-open')`);
check('actual workbook lists all 16 structures',await ev(`document.querySelectorAll('[data-ground-pier]').length===16`));
await file('#ground-import',path.join(root,'pierzainer_ground/data/output/ungok_corrected_20261110/ground_model_3d_with_maps.json'));
await until(`document.getElementById('project-ground-status').textContent.includes('16/16')`);
check('all structures have applicable corrected ground',await ev(`[...document.querySelectorAll('[data-ground-pier]')].every(e=>!e.disabled)`));
await click(`document.getElementById('project-ground-select-all')`);
check('all 16 structures are selected for application',await ev(`document.querySelectorAll('[data-ground-pier]:checked').length===16`));
await click(`document.getElementById('project-ground-apply')`);
await click(`document.getElementById('project-ground-regenerate')`);
await until(`document.getElementById('project-ground-regenerate').disabled`);
check('all changes regenerate successfully',await ev(`document.getElementById('project-ground-regenerate').disabled`));
await shot('01-corrected-manager');await click(`document.querySelector('#project-ground-dialog .viewer-head button')`);
await delay(1000);await shot('02-corrected-terrain');
await send('Browser.setDownloadBehavior',{behavior:'deny'});
await ev(`(()=>{window.__ungokSaved=null;const original=URL.createObjectURL;URL.createObjectURL=function(blob){blob.text().then(t=>{try{window.__ungokSaved=JSON.parse(t);}catch{}});return original.call(this,blob);};})()`);
await click(`document.getElementById('save-project')`);await until(`document.getElementById('save-dialog').open`);await click(`document.getElementById('sv-save')`);await until(`!!window.__ungokSaved?.groundModel`);
const saved=await ev(`window.__ungokSaved`);fs.writeFileSync(path.join(out,'verified.pzr.json'),JSON.stringify(saved));
check('saved project retains 16 corrected ground records and two maps',saved.groundModel.piers.length===16&&saved.groundModel.mapLayers.length===2);
const expected=JSON.parse(fs.readFileSync(path.join(root,'pierzainer_ground/data/output/ungok_corrected_20261110/ground_model_3d_with_maps.json')));
check('saved terrain elevations equal generated JSON',JSON.stringify(saved.groundModel.terrainGrid.z)===JSON.stringify(expected.terrain_grid.z));
check('browser runtime has no exceptions',report.errors.length===0);report.result='PASS';
})().catch(e=>{report.result='FAIL';report.failure=e.stack;process.exitCode=1;}).finally(()=>{fs.writeFileSync(path.join(out,'report.json'),JSON.stringify(report,null,2));console.log(JSON.stringify(report));ws?.close();});
+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();
});
+135
View File
@@ -0,0 +1,135 @@
// Run with Windows Node and an isolated Chrome on CDP 9444; Vite must be on 5173.
// ②·③ center workspace: stacked ↔ side-by-side, splitter drag/keyboard, persistence, width fallback.
const fs = require('node:fs'), path = require('node:path'), assert = require('node:assert/strict');
const root = path.resolve(__dirname, '..');
const out = path.resolve(root, '.verify/workspace-layout-20261109');
fs.mkdirSync(out, { recursive: true });
const report = { checks: [], errors: [], metrics: {} };
let ws, send;
const delay = ms => new Promise(r => setTimeout(r, ms));
let target;
(async () => {
target = await (await fetch('http://127.0.0.1:9444/json/new?about:blank', { method: 'PUT' })).json();
ws = new WebSocket(target.webSocketDebuggerUrl);
await new Promise((r, j) => { ws.onopen = r; ws.onerror = j; });
let seq = 0; const pending = new Map();
ws.onmessage = ({ data }) => {
const m = JSON.parse(data), p = pending.get(m.id);
if (p) { clearTimeout(p.timer); pending.delete(m.id); m.error ? p.reject(new Error(JSON.stringify(m.error))) : p.resolve(m.result); }
if (m.method === 'Page.javascriptDialogOpening') ws.send(JSON.stringify({ id: 900000 + (++seq), method: 'Page.handleJavaScriptDialog', params: { accept: true } }));
if (m.method === 'Runtime.exceptionThrown') report.errors.push(m.params.exceptionDetails.exception?.description || m.params.exceptionDetails.text);
};
send = (method, params = {}) => new Promise((resolve, reject) => {
const id = ++seq, timer = setTimeout(() => reject(new Error('Timeout ' + method)), 8000);
pending.set(id, { resolve, reject, timer }); ws.send(JSON.stringify({ id, method, params }));
});
const ev = async expression => {
const r = await send('Runtime.evaluate', { expression, awaitPromise: true, returnByValue: true });
if (r.exceptionDetails) throw new Error(r.exceptionDetails.exception?.description || r.exceptionDetails.text);
return r.result.value;
};
const until = async (expression, tries = 120) => {
for (let i = 0; i < tries; i++) { try { if (await ev(expression)) return; } catch { /* navigating */ } await delay(150); }
throw new Error('Wait ' + expression);
};
const check = (name, value) => { assert.ok(value, name); report.checks.push(name); console.log('PASS', name); };
const click = expression => ev(`(()=>{const e=${expression};if(!e||e.disabled)throw new Error('Missing target');e.click();})()`);
const shot = async name => { await delay(250); const r = await send('Page.captureScreenshot', { format: 'png' }); fs.writeFileSync(path.join(out, name + '.png'), Buffer.from(r.data, 'base64')); };
const viewport = (width, height) => send('Emulation.setDeviceMetricsOverride', { width, height, deviceScaleFactor: 1, mobile: false });
const rects = ws_ => ev(`(()=>{const w=document.getElementById('${ws_}'),r=e=>{const b=e.getBoundingClientRect();return{l:Math.round(b.left),t:Math.round(b.top),r:Math.round(b.right),b:Math.round(b.bottom),w:Math.round(b.width),h:Math.round(b.height)};};const c=document.querySelector('#viewer-block canvas');return{ws:r(w),slot:r(w.querySelector('.viewer-slot')),split:r(w.querySelector('.workspace-splitter')),pane:r(w.querySelector('.workspace-pane')),canvas:c?r(c):null,side:w.classList.contains('layout-side'),note:w.querySelector('.layout-note').textContent,label:w.querySelector('[data-size-label]').textContent,value:w.querySelector('output').textContent};})()`);
const drag = async (sel, dx, dy) => {
const b = await ev(`(()=>{const r=document.querySelector('${sel}').getBoundingClientRect();return{x:r.left+r.width/2,y:r.top+r.height/2};})()`);
await send('Input.dispatchMouseEvent', { type: 'mousePressed', x: b.x, y: b.y, button: 'left', buttons: 1, clickCount: 1 });
for (let i = 1; i <= 5; i++) await send('Input.dispatchMouseEvent', { type: 'mouseMoved', x: b.x + dx * i / 5, y: b.y + dy * i / 5, button: 'left', buttons: 1 });
await send('Input.dispatchMouseEvent', { type: 'mouseReleased', x: b.x + dx, y: b.y + dy, button: 'left', buttons: 0, clickCount: 1 });
await delay(250);
};
const key = async (k, code, vk) => { for (const type of ['keyDown', 'keyUp']) await send('Input.dispatchKeyEvent', { type, key: k, code, windowsVirtualKeyCode: vk }); await delay(120); };
await send('Page.enable'); await send('Runtime.enable'); await send('DOM.enable');
await viewport(1600, 1000);
await send('Page.navigate', { url: process.env.PIERZAINER_URL || 'http://127.0.0.1:5173/' });
await until(`!!document.querySelector('[data-card="excel"] button') && typeof window.loadBytesToApp === 'function'`);
await ev(`Object.keys(localStorage).filter(k=>k.startsWith('pierzainer.workspace-layout')||k==='pierzainer.review-layout').forEach(k=>localStorage.removeItem(k))`);
await send('Page.reload'); await delay(2500);
await until(`!!document.querySelector('[data-card="excel"] button') && typeof window.loadBytesToApp === 'function'`);
await click(`document.querySelector('[data-card="excel"] button')`);
await ev(`document.getElementById('project-name').value='배치 검증';document.getElementById('project-name').dispatchEvent(new Event('input'));`);
const doc = await send('DOM.getDocument');
const node = await send('DOM.querySelector', { nodeId: doc.root.nodeId, selector: '#file' });
await send('DOM.setFileInputFiles', { nodeId: node.nodeId, files: [path.resolve(root, '../../RAG/운곡교P1입력.xlsx')] });
await until(`!document.getElementById('dlg-apply').disabled`);
await click(`document.getElementById('dlg-apply')`);
await delay(800);
// ② 계획
await click(`document.getElementById('nav-plan')`); await delay(600);
let r = await rects('plan-workspace');
check('② default is stacked: model above the input pane', !r.side && r.slot.b <= r.pane.t && r.slot.w === r.pane.w);
check('② layout switch shows 상하 pressed', await ev(`document.querySelector('#plan-workspace [data-layout="stack"]').getAttribute('aria-pressed')==='true'`));
await shot('01-plan-stack');
await click(`document.querySelector('#plan-workspace [data-layout="side"]')`); await delay(500);
r = await rects('plan-workspace'); report.metrics.planSide = r;
check('② 좌우: model left, pane right, same top', r.side && r.slot.r <= r.pane.l && Math.abs(r.slot.t - r.pane.t) <= 1);
check('② 좌우 starts near 50 % and shows width label', Math.abs(r.slot.w / r.ws.w - 0.5) < 0.03 && r.label === '모델 폭' && r.value === '50%');
check('② 3D canvas follows the narrower slot', r.canvas && r.slot.w - r.canvas.w >= 0 && r.slot.w - r.canvas.w <= 40 && r.canvas.h > 300);
check('② pane scrolls independently', await ev(`(()=>{const p=document.querySelector('#plan-workspace .workspace-pane');return getComputedStyle(p).overflowY==='auto';})()`));
await shot('02-plan-side');
await drag('#plan-workspace .workspace-splitter', -Math.round(r.ws.w * 0.1), 0);
r = await rects('plan-workspace');
check('② splitter drag narrows model to about 40 %', r.value === '40%' && Math.abs(r.slot.w / r.ws.w - 0.4) < 0.03);
await ev(`document.querySelector('#plan-workspace .workspace-splitter').focus()`);
await key('ArrowRight', 'ArrowRight', 39);
check('② splitter keyboard step (+2 %)', (await rects('plan-workspace')).value === '42%' && await ev(`document.querySelector('#plan-workspace .workspace-splitter').getAttribute('aria-valuenow')==='42'`));
await send('Input.dispatchMouseEvent', { type: 'mousePressed', x: r.split.l + 6, y: r.split.t + 100, button: 'left', clickCount: 2 });
await send('Input.dispatchMouseEvent', { type: 'mouseReleased', x: r.split.l + 6, y: r.split.t + 100, button: 'left', clickCount: 2 });
await ev(`document.querySelector('#plan-workspace .workspace-splitter').dispatchEvent(new MouseEvent('dblclick',{bubbles:true}))`); await delay(200);
check('② double-click resets to 50 %', (await rects('plan-workspace')).value === '50%');
await click(`document.getElementById('plan-model-toggle')`); await delay(300);
r = await rects('plan-workspace');
check('② collapsed model: pane takes the full width', r.slot.w === 0 && Math.abs(r.pane.w - r.ws.w) <= 2);
await click(`document.getElementById('plan-model-toggle')`); await delay(300);
// ③ 배근 stays independent
await click(`document.getElementById('nav-rebar')`); await delay(700);
r = await rects('review-workspace');
check('③ preference is independent of ② (still stacked, 240 px)', !r.side && r.value === '240 px' && r.slot.b <= r.pane.t);
await drag('#review-workspace .workspace-splitter', 0, 100);
r = await rects('review-workspace');
check('③ stacked splitter drag grows model height', r.value === '340 px' && Math.abs(r.slot.h - 340) <= 2);
await click(`document.querySelector('#review-workspace [data-layout="side"]')`); await delay(500);
r = await rects('review-workspace'); report.metrics.rebarSide = r;
check('③ 좌우: model left, rebar table right', r.side && r.slot.r <= r.pane.l && r.canvas && r.slot.w - r.canvas.w >= 0 && r.slot.w - r.canvas.w <= 40);
await click(`document.getElementById('review-table')`); await delay(300);
check('③ 철근표 shortcut still reveals the table in 좌우', await ev(`(()=>{const t=document.activeElement,p=document.querySelector('#review-workspace .workspace-pane > section').getBoundingClientRect();return t.matches('#rebar-table table.rebar-table')&&t.getBoundingClientRect().top>=p.top-2;})()`));
await shot('03-rebar-side');
// persistence
await send('Page.reload'); await delay(2500); await until(`typeof window.loadBytesToApp==='function'`); await delay(800);
check('preferences persist per stage after reload', await ev(`(()=>{const p=JSON.parse(localStorage.getItem('pierzainer.workspace-layout.plan')),q=JSON.parse(localStorage.getItem('pierzainer.workspace-layout.rebar'));return p.mode==='side'&&p.width===50&&q.mode==='side'&&q.height===340;})()`));
await until(`typeof window.openSampleProject==='function'`); await ev('window.openSampleProject()'); await delay(1500);
await click(`document.getElementById('nav-plan')`); await delay(600);
check('② restored 좌우 when shown after reload', await ev(`document.getElementById('plan-workspace').classList.contains('layout-side')`));
await click(`document.getElementById('nav-rebar')`); await delay(600);
check('③ restored 좌우 and 340 px height after reload', await ev(`document.getElementById('review-workspace').classList.contains('layout-side')&&getComputedStyle(document.getElementById('review-workspace')).getPropertyValue('--review-model-height').trim()==='340px'`));
await click(`document.getElementById('nav-plan')`); await delay(400);
// width fallback
await viewport(1280, 800); await delay(500);
check('1280 px: side request falls back to stacked with a note', await ev(`(()=>{const w=document.getElementById('plan-workspace');return !w.classList.contains('layout-side')&&w.querySelector('.layout-note').textContent.includes('좁아')&&w.querySelector('[data-layout="side"]').getAttribute('aria-pressed')==='true';})()`));
await viewport(1920, 1080); await delay(500);
check('1920 px: side-by-side restored automatically', await ev(`document.getElementById('plan-workspace').classList.contains('layout-side')`));
await viewport(1000, 900); await delay(500);
check('1000 px (mobile frame): stacked, splitter hidden', await ev(`(()=>{const w=document.getElementById('plan-workspace');return !w.classList.contains('layout-side')&&getComputedStyle(w.querySelector('.workspace-splitter')).display==='none';})()`));
for (const width of [1280, 1600, 1920]) {
await viewport(width, 1000); await delay(300);
check(`${width} px: no horizontal page scroll`, await ev(`document.documentElement.scrollWidth<=innerWidth`));
}
check('no runtime exceptions', report.errors.length === 0);
report.result = 'PASS';
})().catch(e => { report.result = 'FAIL'; report.failure = e.stack; process.exitCode = 1; }).finally(() => {
fs.writeFileSync(path.join(out, 'report.json'), JSON.stringify(report, null, 2));
console.log(JSON.stringify({ result: report.result, failure: report.failure, errors: report.errors }));
if (target?.id) fetch('http://127.0.0.1:9444/json/close/' + target.id).catch(() => {});
ws?.close();
});
+194
View File
@@ -0,0 +1,194 @@
/**
* 교대 받침 카드 (2026-11-03, 시안 ② 교량받침·받침블럭·솔플레이트, 교각 카드와 같은 형식). 순수 함수.
*
* - 솔플레이트 (결정 2026-11-06, spec_abut §5·QA-15, src/core/sole-plate.ts): S = 같은 거더의 짝 받침(A1 ↔ P1 전열,
* A2 ↔ Pn)으로 계산, 짝이 없으면 교대 종단경사 i(엑셀 Abut). L = A + 100, W = B + 100 (A × B 기본 700 × 640, 교각 P1
* 전열 값 가정). 받침이 1열이라 T는 교대 전체에 하나: max(22, ⌈22 + L·|S|/2⌉). 거더 하면 고정 → 블럭 상면 = z − (T − T_ref).
* - h3 기본값 = 받침 시트의 받침높이 − T_ref (A1 275 − 30 = 245), 받침마다 그 받침의 값 (2026-11-10): 엑셀 받침높이가 정한 거더 하면을 그대로 둔다
* (시안은 교각 P1의 h3 181을 가정했으나 교대 시트에 받침높이가 있어 그 값을 지킨다).
* - 높이: E = h1 (블럭 콘크리트) + h2 (몰탈패드 50) + h3 + h4 (= T). z = 패드 상면 = EL.X.
*/
import type { BearingPair } from '../bearing-pair';
import { type BearingSole, SOLE_DEFAULTS, structureSoles } from '../sole-plate';
import { ABUT_PAD_MM, type AbutModel } from './geometry';
import type { AbutMembers } from './members';
/** 교대 솔플레이트 입력 (2026-11-06). Every value optional; absent = the default. */
export interface AbutBearingRule {
/** 받침 본체 높이 h3 (mm). Absent = 받침높이 − T_ref. */
h3Mm?: number;
tRefMm: number;
tMinMm: number;
/** 솔플 여유 L − A = W − B (mm). */
marginMm: number;
/** 받침 평면 A (거더 방향) × B (mm), every bearing. */
aMm: number;
bMm: number;
/** T typed (mm). Absent = 자동. */
tMm?: number;
/** S typed for every bearing (%). Absent = 짝 받침 계산. */
slopePct?: number;
}
export const ABUT_BEARING_RULE_DEFAULT: AbutBearingRule = { tRefMm: SOLE_DEFAULTS.tRefMm, tMinMm: SOLE_DEFAULTS.tMinMm, marginMm: SOLE_DEFAULTS.marginMm, aMm: SOLE_DEFAULTS.aMm, bMm: SOLE_DEFAULTS.bMm };
/** 받침블럭 평면 (mm, 정사각) and 몰탈패드 평면 (mm), spec_abut §4.2. */
export const ABUT_BLOCK_MM = 1200;
export const ABUT_PAD_PLAN_MM = 1000;
/** I01: 블럭 콘크리트 높이가 피복 50 + 철근 지름 13보다 작으면 블럭 철근을 교좌면 아래로 묻는다 (spec_abut §6.6). */
const I01_MIN_MM = 50 + 13;
const CIRCLED = ['①', '②', '③', '④', '⑤', '⑥', '⑦', '⑧', '⑨', '⑩'];
/** Keys the 2026-11-06 rule reads; values stored by the C안 (lengthMm, widthMm, kind) are ignored. */
const RULE_KEYS = ['h3Mm', 'tRefMm', 'tMinMm', 'marginMm', 'aMm', 'bMm', 'tMm', 'slopePct'] as const;
export function resolveBearingRule(changes?: Partial<AbutBearingRule> & Record<string, unknown>): AbutBearingRule {
const own = Object.fromEntries(Object.entries(changes ?? {}).filter(([k]) => (RULE_KEYS as readonly string[]).includes(k)));
return { ...ABUT_BEARING_RULE_DEFAULT, ...own };
}
/** Bridge context of the abutment card: the girder pairs and the 교대 종단경사 i (decimal). */
export interface AbutSoleContext {
pairs?: BearingPair[];
slopeI?: number;
/** 엑셀 방위각 per bearing number (deg model 진북), when an applied 계산 방위각 is in the model. */
inputAzimuthDeg?: Record<string, number>;
}
/** Solplate of every bearing of the abutment (one row). */
export function abutSoles(model: AbutModel, rule: AbutBearingRule, heightMm: number, ctx: AbutSoleContext = {}): BearingSole[] {
const plates = Object.fromEntries(model.bearings.map((b) => [String(b.no), { aMm: rule.aMm, bMm: rule.bMm }]));
return structureSoles(
model.name,
model.bearings.map((b) => ({ no: b.no, row: '1열', azimuthDeg: b.azimuthDeg ?? 0, inputAzimuthDeg: ctx.inputAzimuthDeg?.[String(b.no)] })),
{
// A typed h3 is for every bearing; otherwise h3 = 그 받침의 받침높이 (없으면 교대 값) − T_ref.
h3Mm: rule.h3Mm, tRefMm: rule.tRefMm,
h3ByNo: Object.fromEntries(model.bearings.map((b) => [String(b.no), (b.heightMm ?? heightMm) - rule.tRefMm])), tMinMm: rule.tMinMm, marginMm: rule.marginMm, plates,
tByRow: rule.tMm === undefined ? undefined : { '1열': rule.tMm },
slopePct: rule.slopePct === undefined ? undefined : Object.fromEntries(model.bearings.map((b) => [String(b.no), rule.slopePct!])),
},
ctx.pairs ?? [],
ctx.slopeI === undefined ? undefined : ctx.slopeI * 100,
);
}
export interface AbutElevationRow {
no: number;
/** IP3(① 쪽)에서 잰 위치 (m). */
sFromIp3M: number;
seg: string;
seatEl: number;
h1: number;
h2: number;
/** 패드 상면 = EL.X (m). */
z: number;
h3: number;
h4: number;
E: number;
/** 솔플레이트 상면 EL (m) = 교좌면 + E. */
solplateTopEl: number;
/** 블럭 끝에서 가장 가까운 계단 경계까지 (m). 경계가 없으면 null. */
stepGapM: number | null;
/** 받침 방위각 (deg): CAD (엑셀 값, 동 = 0° 반시계) / 진북 (북에서 시계). 입력이 없으면 null. */
azimuthCadDeg: number | null;
azimuthNorthDeg: number | null;
/** 블럭 평면 회전 (deg), 교대축 직각 기준. */
rotDeg: number;
}
export function abutElevationRows(model: AbutModel, rule: AbutBearingRule, heightMm: number, ctx: AbutSoleContext = {}): AbutElevationRow[] {
const half = ABUT_BLOCK_MM / 2000;
const soles = new Map(abutSoles(model, rule, heightMm, ctx).map((x) => [x.no, x]));
return model.bearings.map((b) => {
const plan = soles.get(b.no)!;
// 거더 하면 고정: the block concrete loses T − T_ref (2026-11-06, Q-SH7).
const h1 = b.blockHeightMm - ABUT_PAD_MM - plan.dropMm;
const E = h1 + ABUT_PAD_MM + plan.h3 + plan.T;
const gaps = model.stepBounds.map((x) => Math.abs(x - b.s) - half);
return {
no: b.no,
sFromIp3M: model.length - b.s,
seg: CIRCLED[b.no - 1] ?? String(b.no),
seatEl: b.seatEl,
h1,
h2: ABUT_PAD_MM,
z: b.z - plan.dropMm / 1000,
h3: plan.h3,
h4: plan.T,
E,
solplateTopEl: b.seatEl + E / 1000,
stepGapM: gaps.length ? Math.min(...gaps) : null,
azimuthCadDeg: b.azimuthDeg === undefined ? null : (((90 - b.azimuthDeg) % 360) + 360) % 360,
azimuthNorthDeg: b.azimuthDeg === undefined ? null : ((b.azimuthDeg % 360) + 360) % 360,
rotDeg: b.rotDeg,
};
});
}
export interface AbutSolplateRow {
no: number;
/** 기울기 (%). */
S: number;
/** S 근거 (계산 / 입력 / 가정 / 수정됨 / 없음) and the 짝 받침, e.g. "P1-①". */
source: string;
pair?: string;
/** 방위각 검사 (″): input azimuth against the pair direction; absent without a pair. */
azimuthSec?: number;
pairKind?: 'girder' | 'chord';
/** 방위각 입력 (엑셀) and 계산 (짝 받침), deg model 진북; 계산 absent without a pair. */
azimuthInputDeg: number;
azimuthCalcDeg?: number;
L: number;
W: number;
T: number;
T1: number;
T2: number;
/** 검산 문구 "T1 − T2 ≈ L·|S|/100" (W08, 1 mm 이내). */
check: string;
warnings: string[];
}
export function abutSolplateRows(model: AbutModel, rule: AbutBearingRule, heightMm: number, ctx: AbutSoleContext = {}): AbutSolplateRow[] {
return abutSoles(model, rule, heightMm, ctx).map((p) => {
const rhs = (p.L * Math.abs(p.slopePct)) / 100;
return {
no: p.no, S: p.slopePct, source: p.slopeSource, azimuthSec: p.azimuthCheckSec, azimuthInputDeg: p.azimuthInputDeg, azimuthCalcDeg: p.azimuthCalcDeg,
pair: p.pair ? `${p.pair.structure}-${CIRCLED[p.pair.no - 1] ?? p.pair.no}${p.pairKind === 'chord' ? ' (현)' : ''}` : undefined, pairKind: p.pairKind,
L: p.L, W: p.W, T: p.T, T1: p.T1, T2: p.T2,
check: `${p.T1 - p.T2} ≈ ${rhs.toFixed(1)} ${Math.abs(p.T1 - p.T2 - rhs) <= 1 + 1e-9 ? '일치' : '불일치 (W08)'}`,
warnings: p.warnings,
};
});
}
/** Input problems of the 교대 솔플레이트 입력. Empty when it can build a solplate. */
export function abutBearingRuleIssues(rule: AbutBearingRule, heightMm: number): string[] {
const out: string[] = [];
for (const k of ['tRefMm', 'tMinMm', 'aMm', 'bMm'] as const) if (!(Number.isFinite(rule[k]) && rule[k] > 0)) out.push(`${k}는 0보다 커야 합니다.`);
if (!(Number.isFinite(rule.marginMm) && rule.marginMm >= 0)) out.push('솔플 여유는 0 이상이어야 합니다.');
if (rule.tMm !== undefined && !(Number.isFinite(rule.tMm) && rule.tMm > 0)) out.push('T는 0보다 커야 합니다.');
if (rule.slopePct !== undefined && !Number.isFinite(rule.slopePct)) out.push('S는 숫자여야 합니다.');
const h3 = rule.h3Mm ?? heightMm - rule.tRefMm;
if (!(Number.isFinite(h3) && h3 > 0)) out.push(`받침 높이 h3 (${h3})는 0보다 커야 합니다. 받침높이 ${heightMm}와 T_ref를 확인하세요.`);
return out;
}
/** 검사 줄 (시안): W02 블럭 콘크리트, W04 블럭이 교좌면 안, W03 블럭 끝 ~ 계단 경계, I01 블럭 철근. 문제 없으면 "없음". */
export function abutBearingChecks(model: AbutModel, members: AbutMembers, rule: AbutBearingRule, heightMm: number, ctx: AbutSoleContext = {}): string[] {
const rows = abutElevationRows(model, rule, heightMm, ctx);
const minH1 = Math.min(...rows.map((r) => r.h1));
const half = ABUT_BLOCK_MM / 2000;
// Block plan across the wall: centre t = frontSide × lateral, between the 흉벽 front (t 0) and the 벽체 front.
const tCentres = model.bearings.map((b) => model.frontSide * b.lateral);
const backGap = Math.min(...tCentres.map((t) => t - half));
const frontGap = Math.min(...tCentres.map((t) => members.shape.wallFrontM - (t + half)));
const stepGaps = rows.map((r) => r.stepGapM).filter((g): g is number => g !== null);
const minStep = stepGaps.length ? Math.min(...stepGaps) : null;
return [
`블럭 콘크리트 ${minH1} > 0 (W02 ${minH1 > 0 ? '없음' : '있음'})`,
`블럭이 교좌면 안 (흉벽 ${Math.round(backGap * 1000)} · 전면 ${Math.round(frontGap * 1000)}, W04 ${backGap >= 0 && frontGap >= 0 ? '없음' : '있음'})`,
minStep === null ? '블럭 끝 ~ 계단 경계: 경계 없음 (W03 없음)' : `블럭 끝 ~ 계단 경계 최소 ${minStep.toFixed(3)} m (W03 ${minStep >= 0 ? '없음' : '있음'})`,
`${minH1} ≥ ${I01_MIN_MM} → I01 ${minH1 >= I01_MIN_MM ? '없음' : '있음 (블럭 철근을 교좌면 아래로)'}`,
];
}
+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 };
}
+215
View File
@@ -0,0 +1,215 @@
/**
* 교대 형상 · AA1 (2026-11-03, spec_abut §1·§4.1–4.2): 교대축, 전면 쪽, 교좌면 위의 받침, 교좌면 EL과 블럭 높이를 계산한다.
* 순수 함수. 좌표는 (E, N) m 단위, EL은 m 단위, 길이는 별도 표기가 없으면 mm 단위다.
*
* - 축: IP1 → IP3 선(교대중심선), 길이 L. u = IP1 → IP3 단위벡터, nLeft = u를 반시계 90° 돌린 벡터.
* - 받침 s: IP1에서 u 방향으로 잰 거리(m). 축에서 벗어난 거리(lateral): nLeft 방향 거리(m).
* - z = 패드 상면(EL.X), 교각과 같다(2026-11-03 결정 1). 교좌면에서 패드 상면까지 블럭 높이는 125 mm
* (콘크리트 75 + 패드 50). 교좌면 EL은 콘크리트 하면이다.
* - 교좌면: 교대 전체에 교좌면 EL 하나를 입력받는다(첫 받침 ①). 블럭 높이 h = min(z) − 교좌면으로 모든 받침이 같고,
* 받침 i의 교좌면 = z_i − h. 받침이 자기 교좌면 EL(seatEl)을 직접 주면 그 값을 쓰고, 블럭 높이는 z_i − seatEl이 된다
* (2026-11-03 결정 3).
* - 전면: 받침이 있는 쪽. A1은 전면이 1 → n 방향의 왼쪽, 흉벽은 오른쪽에 있다. A2는 반대다(spec_abut §1).
* 맞지 않으면 AW03 경고 대상이다.
*/
export type AbutType = 'A1' | 'A2';
export interface AbutPoint {
e: number;
n: number;
}
export interface AbutBearing {
no: number;
/** 위치 (E, N), m. */
pos: AbutPoint;
/** 패드 상면 EL (z), m. */
z: number;
/** 이 받침의 교좌면 EL (m). 없으면 z에서 교대 블럭 높이를 뺀 값을 쓴다. */
seatEl?: number;
/** 받침 방위각 (deg, model-xy: 북에서 시계방향, 교각과 같은 변환 swapXYAzimuth). 없으면 블럭은 교대축에 맞춘다. */
azimuthDeg?: number;
/** 이 받침의 받침높이 H (mm, 2026-11-10). 없으면 교대 받침높이를 쓴다. */
heightMm?: number;
}
/** 날개벽 형식, 측별(spec_abut §4.7): 일반형 / 단축형 / 없음. */
export type WingForm = 'general' | 'short' | 'none';
export interface AbutInput {
name: string;
type: AbutType;
/** 첫 받침(구간 ①)의 교좌면 EL (m). 블럭 높이 h = min(z) − seatEl. */
seatEl: number;
ip1: AbutPoint;
ip2: AbutPoint;
ip3: AbutPoint;
bearings: AbutBearing[];
/** skew (deg): 받침축을 시계방향으로 돌린 선이 날개벽 선이다(spec_abut §1). 없으면 날개벽 검사를 하지 않는다. */
skewDeg?: number;
/** 종단경사 i (소수, 교량진행방향 기준, (−) = 낮아짐). 날개벽 경사는 A1이 −i, A2가 +i다. */
slopeI?: number;
/** AW01용 거더 방향 CAD 각 (deg). 없으면 AW01을 검사하지 않는다. */
girderAzimuthDeg?: number;
/** 측별 날개벽 형식: 'left' = 받침 ① 쪽 끝, 'right' = 받침 n 쪽 끝(spec §4.7). 기본은 양측 일반형. */
wings?: { left: WingForm; right: WingForm };
/** 측별 날개벽 두께 (mm). 없으면 450. 접속슬래브 폭에 쓴다. */
wingThicknessMm?: { left?: number; right?: number };
/** 접속슬래브와 날개벽 안쪽면 사이 간격 (mm, ② 접속슬래브 카드). 없으면 20. */
slabWingGapMm?: number;
/** 기초 하단 EL (m), 말뚝길이 (mm), 기초 형식 (AA3, 저판·말뚝). 없으면 저판·말뚝을 만들지 않는다. */
foundationBottomEl?: number;
pileLengthMm?: number;
foundationType?: string;
}
export interface AbutWingModel {
/** 날개벽 선, CAD 각 (deg), 배면 쪽(예제: A1 ≈ 255.32, A2 ≈ 75.32). */
lineDeg: number;
/** 날개벽 선과 거더 방향의 사잇각, 0–90°로 접음(AW01 허용 1°). */
girderDiffDeg: number;
aw01Ok: boolean;
/** 날개벽 경사 (%), (+) = 벽체에서 멀어질수록 높아짐. */
slopePct: number;
/** 날개벽 사이 접속슬래브 폭 (mm) = L − Σ(두께 / |sin Δ| + 날개벽과 간격), 있는 날개벽마다. */
slabWidthMm: number;
wings: { left: WingForm; right: WingForm };
}
export interface AbutBearingModel {
no: number;
/** IP1에서 u 방향 거리 (m). */
s: number;
/** nLeft 방향으로 잰 축에서의 부호 있는 거리 (m). */
lateral: number;
z: number;
seatEl: number;
/** 교좌면에서 패드 상면까지 블럭 높이 (mm), 패드 포함. */
blockHeightMm: number;
/** 받침 방위각 (deg, model-xy). 입력이 없으면 undefined. */
azimuthDeg?: number;
/** 이 받침의 받침높이 H (mm). 입력이 없으면 undefined. */
heightMm?: number;
/** 블럭 평면 회전 (deg, −90 … 90): 받침 방위각과 교대축 직각(전면) 방향 사이의 각. 장면에서는 rotateY(이 값). 입력이 없으면 0. */
rotDeg: number;
}
export interface AbutModel {
name: string;
type: AbutType;
/** IP1 (축의 시작점, s = 0). */
ip1: AbutPoint;
/** 축 길이 IP1 → IP3 (m). */
length: number;
u: AbutPoint;
nLeft: AbutPoint;
/** 받침이 축의 nLeft 쪽이면 +1, 반대쪽이면 −1. */
frontSide: 1 | -1;
/** 받침이 1 → n 방향의 왼쪽이면 true (A1 배치). */
frontLeftOfBearingDir: boolean;
/** 입력 순서의 받침. */
bearings: AbutBearingModel[];
/** 축 위 계단 경계(IP1에서 m), 이웃 받침 사이의 중간. */
stepBounds: number[];
/** AW03: 배치가 형식과 맞으면 true. */
layoutMatchesType: boolean;
/** 날개벽 선, 경사, AW01, 접속슬래브 폭. 입력에 skew가 없으면 없다. */
wing?: AbutWingModel;
}
const WING_THICKNESS_MM = 450;
const SLAB_CLEARANCE_MM = 20;
const AW01_TOLERANCE_DEG = 1;
/** 공통 규칙의 패드 두께 (mm). 블럭 높이에 포함된다(패드 포함). */
export const ABUT_PAD_MM = 50;
const sub = (a: AbutPoint, b: AbutPoint): AbutPoint => ({ e: a.e - b.e, n: a.n - b.n });
/**
* 블럭 평면 회전 (deg): the bearing azimuth direction d = (sin az, cos az) seen in the axis frame (s along u, t along the front),
* folded to −90 … 90 because the block is symmetric. 0 when the azimuth is missing.
*/
export function bearingRotDeg(azimuthDeg: number | undefined, u: AbutPoint, front: AbutPoint): number {
if (azimuthDeg === undefined || !Number.isFinite(azimuthDeg)) return 0;
const r = (azimuthDeg * Math.PI) / 180;
const d = { e: Math.sin(r), n: Math.cos(r) };
let phi = (Math.atan2(d.e * u.e + d.n * u.n, d.e * front.e + d.n * front.n) * 180) / Math.PI;
if (phi > 90) phi -= 180;
if (phi < -90) phi += 180;
return phi;
}
const dot = (a: AbutPoint, b: AbutPoint) => a.e * b.e + a.n * b.n;
const cross = (a: AbutPoint, b: AbutPoint) => a.e * b.n - a.n * b.e;
export function buildAbut(input: AbutInput): AbutModel {
if (input.bearings.length < 2) throw new Error(`교대 ${input.name}: 받침이 2개 이상 필요합니다.`);
const axisVec = sub(input.ip3, input.ip1);
const length = Math.hypot(axisVec.e, axisVec.n);
if (!(length > 0)) throw new Error(`교대 ${input.name}: IP1과 IP3이 같은 점입니다.`);
const u = { e: axisVec.e / length, n: axisVec.n / length };
const nLeft = { e: -u.n, n: u.e };
const lateral = input.bearings.map((b) => dot(sub(b.pos, input.ip1), nLeft));
const meanLateral = lateral.reduce((a, x) => a + x, 0) / lateral.length;
const frontSide: 1 | -1 = meanLateral >= 0 ? 1 : -1;
// 1 → n 방향: 첫 받침(①)에서 마지막 받침까지. 전면 = 받침 쪽. 형식과 맞는지 확인한다.
const first = input.bearings[0];
const last = input.bearings[input.bearings.length - 1];
const dir = sub(last.pos, first.pos);
const frontVec = { e: nLeft.e * frontSide, n: nLeft.n * frontSide };
const frontLeftOfBearingDir = cross(dir, frontVec) > 0;
const layoutMatchesType = input.type === 'A1' ? frontLeftOfBearingDir : !frontLeftOfBearingDir;
const minZ = Math.min(...input.bearings.map((b) => b.z));
const bearings: AbutBearingModel[] = input.bearings.map((b, i) => {
const s = dot(sub(b.pos, input.ip1), u);
const seatEl = b.seatEl ?? input.seatEl + (b.z - minZ);
const blockHeightMm = Math.round((b.z - seatEl) * 1000);
return { no: b.no, s, lateral: lateral[i], z: b.z, seatEl, blockHeightMm, azimuthDeg: b.azimuthDeg, heightMm: b.heightMm, rotDeg: bearingRotDeg(b.azimuthDeg, u, frontVec) };
});
const sorted = [...bearings].sort((a, b) => a.s - b.s);
const stepBounds = sorted.slice(1).map((b, i) => (sorted[i].s + b.s) / 2);
const wing = input.skewDeg === undefined ? undefined : buildWing(input, u, nLeft, frontSide, dir, length);
return { name: input.name, type: input.type, ip1: input.ip1, length, u, nLeft, frontSide, frontLeftOfBearingDir, bearings, stepBounds, layoutMatchesType, wing };
}
const deg = (rad: number) => (rad * 180) / Math.PI;
const norm360 = (x: number) => ((x % 360) + 360) % 360;
/** 두 선 방향의 차이(deg), 0–90°로 접는다. 선에는 방향이 없다. */
const lineDiff = (a: number, b: number) => {
const d = norm360(a - b) % 180;
return Math.min(d, 180 - d);
};
function buildWing(input: AbutInput, u: AbutPoint, nLeft: AbutPoint, frontSide: 1 | -1, dir: AbutPoint, length: number): AbutWingModel {
const skew = input.skewDeg!;
const bearingCad = norm360(deg(Math.atan2(dir.n, dir.e)));
// skew만큼 시계방향으로 돌린 선이 날개벽 선이다. 배면 쪽(받침 반대쪽)이 방향을 정한다.
const line = norm360(bearingCad - skew);
const back = { e: -nLeft.e * frontSide, n: -nLeft.n * frontSide };
const radLine = (line * Math.PI) / 180;
const pick = Math.cos(radLine) * back.e + Math.sin(radLine) * back.n > 0 ? line : norm360(line + 180);
const axisCad = norm360(deg(Math.atan2(u.n, u.e)));
const sinDelta = Math.abs(Math.sin(((pick - axisCad) * Math.PI) / 180));
const wings = input.wings ?? { left: 'general' as WingForm, right: 'general' as WingForm };
// 날개벽 두께는 측별 입력 (2026-11-04, 없으면 450): 축 방향 폭 = 두께 / |sin Δ|.
const thick = { left: input.wingThicknessMm?.left ?? WING_THICKNESS_MM, right: input.wingThicknessMm?.right ?? WING_THICKNESS_MM };
const gap = input.slabWingGapMm ?? SLAB_CLEARANCE_MM;
const present = (f: WingForm, side: 'left' | 'right') => (f === 'none' ? 0 : thick[side] / sinDelta + gap);
const slabWidthMm = Math.round(length * 1000 - present(wings.left, 'left') - present(wings.right, 'right'));
const girderDiffDeg = input.girderAzimuthDeg === undefined ? NaN : lineDiff(pick, input.girderAzimuthDeg);
const slopeI = input.slopeI ?? 0;
return {
lineDeg: pick,
girderDiffDeg,
aw01Ok: Number.isNaN(girderDiffDeg) ? true : girderDiffDeg <= AW01_TOLERANCE_DEG,
slopePct: (input.type === 'A1' ? -slopeI : slopeI) * 100,
slabWidthMm,
wings,
};
}
+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 })),
];
}
+250
View File
@@ -0,0 +1,250 @@
/**
* 교대 입력 읽기 (2026-11-03, spec_abut §2): 입력 엑셀의 교대 행을 읽는다. 교대 표는 교각 표와 같은 시트에 있으며,
* 첫 두 헤더 칸(교대명 + 형식 / IP점 / 받침번호)으로 구분한다. 워크북 객체에 대해 순수하다. 좌표: 시트 x_m = 북, y_m = 동 → 내부 (E, N) = (y_m, x_m).
*
* 받침높이 (2026-11-03 결정 1): 교각과 같은 정의 — 패드 상면(z)에서 솔플레이트 상면까지.
* 받침 행의 값을 그대로 읽는다(A1 시트 값 275). 여기서 계산하지 않는다.
*/
import * as XLSX from 'xlsx';
import { parseDms } from '../dms';
import { swapXYAzimuth } from '../coordinate-frame';
import type { Issue } from '../types';
import { abutNameError } from './name';
import type { AbutInput, AbutPoint, AbutType, WingForm } from './geometry';
import type { AbutShape } from './shape';
import type { AbutBearingRule } from './bearing-table';
import type { WingDims } from './wings';
import type { AbutSlab } from './slab';
import type { AbutGround } from './ground';
import type { AbutFootingRebarSettings } from './rebar/footing';
import type { AbutWallRebarSettings } from './rebar/wall';
import type { AbutParapetRebarSettings } from './rebar/parapet';
import type { AbutWingRebarSettings } from './rebar/wing';
import type { AbutSlabRebarSettings } from './rebar/slab';
import type { AbutBlockSettings } from './rebar/block';
type Cell = string | number | null | undefined;
type Grid = Cell[][];
export interface AbutRecordBearing {
no: number;
/** Position (E, N), m: E = y_m, N = x_m. */
pos: AbutPoint;
z: number;
/** 교좌면_EL of this bearing (m). Absent = the block rule (z − 블럭 높이). */
seatEl?: number;
/** 이 받침에 적힌 받침높이 (mm). */
bearingHeightMm: number;
/** 받침 방위각 (deg, model-xy, 교각과 같은 swapXYAzimuth 변환). 비었거나 읽을 수 없으면 없음. */
azimuthDeg?: number;
row: string;
}
export interface AbutRecord {
name: string;
sheet: string;
type: AbutType;
/** 첫 받침(구간 ①)의 교좌면 EL (m). */
seatEl: number;
/** 기초 하단 EL (m), 지반고 EL (m), 기초 형식, 말뚝길이 (mm). */
foundationBottomEl: number;
groundEl: number;
foundation: string;
pileLengthMm: number;
/** skew (deg) from the DMS text, and 종단경사 i (decimal). */
skewDeg: number;
slopeI: number;
ip1: AbutPoint;
ip2: AbutPoint;
ip3: AbutPoint;
bearings: AbutRecordBearing[];
/** 첫 받침의 받침높이 (mm): 받침별 값이 없을 때의 기본값. 받침마다 다르면 받침별 값을 쓴다 (2026-11-10, AP07 폐지). */
bearingHeightMm: number;
/** 측별 날개벽 형식 (편집 카드에서 정함). Absent = 양측 일반형. */
wings?: { left: WingForm; right: WingForm };
/** 형상 입력 (벽체·흉벽, 확대기초·말뚝, mm). Only the changed values; the rest are the defaults. Absent = all defaults. */
shape?: Partial<AbutShape>;
/** 받침 규칙 (② 교량받침 카드: h3, 솔플레이트 T·여유, 받침 종류). Only the changed values. Absent = 교각 P1 가정값 (QA-15). */
bearingRule?: Partial<AbutBearingRule>;
/** 계산 방위각을 적용한 받침: 받침번호 → 방위각 (deg, model 진북). Absent = 엑셀 입력값 (2026-11-06). */
azimuthApplied?: Record<string, number>;
/** 날개벽 치수 (② 날개벽 카드, 측별, mm). Only the changed values. */
wingDims?: { left?: Partial<WingDims>; right?: Partial<WingDims> };
/** 접속슬래브 입력 (② 접속슬래브 카드: 형식, 경사, 치수 mm). Only the changed values. Absent = 매립형 · 하향 4 % 기본값. */
slab?: Partial<AbutSlab>;
/** 지반·지층 입력 (② 지반·지층 카드, 교각과 같은 필드). Absent = 육상, 지하수위 없음, 지층 행 없음. */
ground?: AbutGround;
/** ③ 배근 입력 (부재별, 바꾼 값만). Absent = 엑셀 Abut 시트 기본값. */
rebar?: { footing?: Partial<AbutFootingRebarSettings>; wall?: Partial<AbutWallRebarSettings>; parapet?: Partial<AbutParapetRebarSettings>; wing?: Partial<AbutWingRebarSettings>; slab?: Partial<AbutSlabRebarSettings>; block?: Partial<AbutBlockSettings> };
/** 예전 "좌우 같게" 값 (2026-11-04 이전 파일). 지금은 읽기만 하고 쓰지 않는다. */
wingsLinked?: boolean;
}
export interface AbutParseResult {
abutments: AbutRecord[];
issues: Issue[];
}
const num = (c: Cell) => (c === null || c === undefined || c === '' ? NaN : Number(c));
const isBlank = (c: Cell) => c === null || c === undefined || c === '';
/** 교대 표의 헤더 행 (첫 두 칸). */
const isAbutInfoHeader = (r: Cell[]) => r[0] === '교대명' && r[1] === '형식';
const isAbutIpHeader = (r: Cell[]) => r[0] === '교대명' && r[1] === 'IP점';
const isAbutBearingHeader = (r: Cell[]) => r[0] === '교대명' && r[1] === '받침번호';
/** 헤더 아래 데이터 행, 둘째 칸이 빌 때까지. 교대명 칸이 비면 위 행을 이어받는다. */
function readBlock(grid: Grid, header: number): { cells: Cell[]; row: number }[] {
const out: { cells: Cell[]; row: number }[] = [];
let last: Cell = null;
for (let i = header + 1; i < grid.length; i++) {
const r = grid[i] ?? [];
if (isBlank(r[1])) break;
const name: Cell = isBlank(r[0]) ? last : r[0];
last = name;
out.push({ cells: [name, ...r.slice(1)], row: i + 1 });
}
return out;
}
export function parseAbutments(wb: XLSX.WorkBook, reservedNames: readonly string[] = []): AbutParseResult {
const issues: Issue[] = [];
const info: { cells: Cell[]; row: number; sheet: string }[] = [];
const ips: { cells: Cell[]; row: number; sheet: string }[] = [];
const bearings: { cells: Cell[]; row: number; sheet: string }[] = [];
for (const sheet of wb.SheetNames) {
const grid = XLSX.utils.sheet_to_json<Cell[]>(wb.Sheets[sheet], { header: 1, raw: true, defval: null }) as Grid;
grid.forEach((r, i) => {
const pick = (match: (r: Cell[]) => boolean, into: typeof info) => {
if (!match(r)) return;
for (const rec of readBlock(grid, i)) into.push({ ...rec, sheet });
};
pick(isAbutInfoHeader, info);
pick(isAbutIpHeader, ips);
pick(isAbutBearingHeader, bearings);
});
}
const abutments: AbutRecord[] = [];
const counts = new Map<string, number>();
for (const rec of info) {
const name = String(rec.cells[0] ?? '').trim();
counts.set(name, (counts.get(name) ?? 0) + 1);
}
const reserved = new Set(reservedNames.map((name) => name.trim()));
for (const rec of info) {
const c = rec.cells;
const rawName = String(c[0] ?? '');
const name = rawName.trim();
const where = `${rec.sheet} ${rec.row}행`;
const nameError = abutNameError(rawName);
if (nameError) {
issues.push({ code: 'AP01', level: 'E', pier: name, sheet: rec.sheet, message: `${where}: ${nameError}` });
continue;
}
if ((counts.get(name) ?? 0) > 1 || reserved.has(name)) {
issues.push({ code: 'AP09', level: 'E', pier: name, sheet: rec.sheet, message: `${where}: 구조물명 "${name}"이 중복되었습니다. 교각·교대마다 서로 다른 이름을 사용하세요.` });
continue;
}
const type = String(c[1] ?? '').trim();
if (type !== 'A1' && type !== 'A2') {
issues.push({ code: 'AP02', level: 'E', pier: name, sheet: rec.sheet, message: `${where}: 교대 ${name}의 형식 "${type}"은 A1 또는 A2여야 합니다.` });
continue;
}
const skewDeg = parseDms(String(c[7] ?? ''));
if (skewDeg === null) {
issues.push({ code: 'AP03', level: 'E', pier: name, sheet: rec.sheet, message: `${where}: 교대 ${name}의 skew "${String(c[7] ?? '')}"를 읽지 못했습니다.` });
continue;
}
abutments.push({
name,
sheet: rec.sheet,
type,
seatEl: num(c[2]),
foundationBottomEl: num(c[3]),
groundEl: num(c[4]),
foundation: String(c[5] ?? ''),
pileLengthMm: num(c[6]),
skewDeg,
slopeI: num(c[8]),
ip1: { e: NaN, n: NaN },
ip2: { e: NaN, n: NaN },
ip3: { e: NaN, n: NaN },
bearings: [],
bearingHeightMm: NaN,
});
}
for (const rec of ips) {
const name = String(rec.cells[0] ?? '').trim();
const ab = abutments.find((a) => a.name === name);
if (!ab) {
issues.push({ code: 'AP04', level: 'E', pier: name, sheet: rec.sheet, message: `${rec.sheet} ${rec.row}행: 교대명 ${name}의 교대 정보 행이 없습니다.` });
continue;
}
const point = { e: num(rec.cells[3]), n: num(rec.cells[2]) };
const ipName = String(rec.cells[1] ?? '');
if (ipName === 'IP1') ab.ip1 = point;
else if (ipName === 'IP2') ab.ip2 = point;
else if (ipName === 'IP3') ab.ip3 = point;
}
for (const rec of bearings) {
const c = rec.cells;
const name = String(c[0] ?? '').trim();
const ab = abutments.find((a) => a.name === name);
if (!ab) {
issues.push({ code: 'AP04', level: 'E', pier: name, sheet: rec.sheet, message: `${rec.sheet} ${rec.row}행: 교대명 ${name}의 교대 정보 행이 없습니다.` });
continue;
}
const h = num(c[8]);
if (!isBlank(c[5]) && parseDms(String(c[5])) === null) {
issues.push({ code: 'AP08', level: 'W', pier: ab.name, sheet: rec.sheet, message: `${rec.sheet} ${rec.row}행: 받침 방위각을 읽을 수 없습니다. 예: 75°56'55.9" 형식. 블럭은 교대축에 맞춥니다.` });
}
ab.bearings.push({
no: num(c[1]),
pos: { e: num(c[3]), n: num(c[2]) },
z: num(c[4]),
seatEl: isBlank(c[7]) ? undefined : num(c[7]),
bearingHeightMm: h,
azimuthDeg: (() => {
const deg = isBlank(c[5]) ? null : parseDms(String(c[5]));
return deg === null ? undefined : swapXYAzimuth(deg);
})(),
row: `${rec.sheet} ${rec.row}행`,
});
}
for (const ab of abutments) {
const missing = [ab.ip1, ab.ip2, ab.ip3].some((p) => !Number.isFinite(p.e) || !Number.isFinite(p.n));
if (missing) issues.push({ code: 'AP05', level: 'E', pier: ab.name, sheet: ab.sheet, message: `교대 ${ab.name}: IP1·IP2·IP3 중 빠진 점이 있습니다.` });
if (ab.bearings.length < 2) issues.push({ code: 'AP06', level: 'E', pier: ab.name, sheet: ab.sheet, message: `교대 ${ab.name}: 받침이 2개 이상 필요합니다.` });
// 받침높이가 받침마다 달라도 받침별 값을 쓴다 (2026-11-10). 교대 값은 첫 받침의 값.
ab.bearingHeightMm = ab.bearings[0]?.bearingHeightMm ?? NaN;
}
return { abutments: abutments.filter((a) => Number.isFinite(a.ip1.e) && a.bearings.length >= 2), issues };
}
/** The layout input of AA1–AA2 from a parsed 교대 record. */
export function toAbutInput(rec: AbutRecord): AbutInput {
return {
name: rec.name,
type: rec.type,
seatEl: rec.seatEl,
ip1: rec.ip1,
ip2: rec.ip2,
ip3: rec.ip3,
// 계산 방위각을 적용한 받침 (2026-11-06) use that value; the record keeps the workbook value.
bearings: rec.bearings.map((b) => ({ no: b.no, pos: b.pos, z: b.z, seatEl: b.seatEl, azimuthDeg: rec.azimuthApplied?.[String(b.no)] ?? b.azimuthDeg, heightMm: Number.isFinite(b.bearingHeightMm) ? b.bearingHeightMm : undefined })),
skewDeg: rec.skewDeg,
slopeI: rec.slopeI,
wings: rec.wings,
wingThicknessMm: { left: rec.wingDims?.left?.thicknessMm, right: rec.wingDims?.right?.thicknessMm },
slabWingGapMm: rec.slab?.wingGapMm,
foundationBottomEl: rec.foundationBottomEl,
pileLengthMm: rec.pileLengthMm,
foundationType: rec.foundation,
};
}
+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; ] as const;
/** Inputs tracked for overrides and pending changes: the numeric cards plus 열별 본수 (an array, compared by value). */ /** 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]; export type EditableKey = (typeof TRACKED_KEYS)[number];
@@ -59,6 +59,8 @@ export const FIELD_LABELS: Record<EditableKey, string> = {
columnRebar: '기둥 배근 입력', columnRebar: '기둥 배근 입력',
footingRebar: '확대기초 배근 입력', footingRebar: '확대기초 배근 입력',
strataRows: '지층 행', strataRows: '지층 행',
castInPlaceRebar: '현장타설 기둥·말뚝 배근 입력',
castInPlaceShape: '현장타설 암반·고정점·희생강관 입력',
}; };
/** Keys whose values differ between two inputs. Arrays are compared by value. */ /** 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 * 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: * 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 taper band (Level underside … end underside, height h2);
* C13 rows lie in the end band (end underside … top, height h1). * 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). * 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 } { 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 };
}
+165 -46
View File
@@ -1,12 +1,20 @@
import { buildAxis, copingSpan, fromAxis, kinkArc, nearKink, toAxis } from './axis'; import { buildAxis, copingSpan, fromAxis, kinkArc, nearKink, toAxis } from './axis';
import { buildIntegratedBlocks, type IntegratedBlock } from './integrated-block'; import { buildIntegratedBlocks, type IntegratedBlock } from './integrated-block';
import { type BlockRebarSettings, DEFAULT_BLOCK_REBAR } from './rebar/bearing-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 ColumnRebarSettings, DEFAULT_COLUMN_REBAR } from './rebar/column-settings';
import { type FootingRebarSettings, DEFAULT_FOOTING_REBAR } from './rebar/footing-settings'; import { type FootingRebarSettings, DEFAULT_FOOTING_REBAR } from './rebar/footing-settings';
import { layoutText, resolveRowCounts, rowLayout, type PileGeometry } from './piles'; import { layoutText, resolveRowCounts, rowLayout, type PileGeometry } from './piles';
import type { PierInput, Row } from './types'; import type { PierInput, Row, SolplatePlan } from './types';
import type { BearingPair } from './bearing-pair';
import { type BearingSole, SOLE_DEFAULTS, structureSoles } from './sole-plate';
import { type CopingGeom, P1_COPING_GEOM } from './coping-geom'; import { 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. */ /** Dimensions in m. Sources: intent.PR sheet (P1 rules) and spec.md §3 defaults. */
export const DIM = { export const DIM = {
@@ -14,11 +22,19 @@ export const DIM = {
copingH1: 1.0, copingH1: 1.0,
copingH2: 1.5, copingH2: 1.5,
levelWidth: 2.1, 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, padThickness: 0.05,
padSize: 1.0, padSize: 1.0,
blockSize: 1.2, blockSize: 1.2,
/** W11 (제안 2026-11-10): 받침블럭 콘크리트(패드 제외) 최소 높이 = 피복 50 + D13 = 63 mm (교대 I01과 같은 기준). */
blockMinHeight: 0.063,
footingL: 10.4, footingL: 10.4,
footingW: 8.15, footingW: 8.15,
footingH: 2.0, footingH: 2.0,
@@ -74,8 +90,10 @@ export interface BlockModel {
padTop: number; padTop: number;
/** Skew (deg, −90…90] of the bearing row against the perpendicular of the stretch axis; the block sides follow the row. */ /** Skew (deg, −90…90] of the bearing row against the perpendicular of the stretch axis; the block sides follow the row. */
rowSkewDeg: number; rowSkewDeg: number;
/** Solplate plan of this bearing (A, B, S, T, margin, h3), when the input gives it. */ /** Solplate of this bearing (2026-11-06): S from the girder pair, T per row, T1 on the 오르막 쪽. */
plan?: { A: number; B: number; S: number; T: number; margin: number; h3: number }; 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}). */ /** Skew of a bearing row (deg) from the bearing azimuth and the stretch axis direction u (see {@link BlockModel.rowSkewDeg}). */
@@ -110,6 +128,11 @@ export interface FootingModel {
bottom: number; bottom: number;
length: number; length: number;
width: number; width: number;
/**
* One footing across the kink (2026-11-10): this record is one stretch's part, reaching past the mitre (the mitre plane
* trims it), and gFrom … gTo is the whole footing in unfolded g. Rebar and quantities take the pair as one footing.
*/
bent?: { gFrom: number; gTo: number; part: 0 | 1 };
} }
export interface BlindingModel { export interface BlindingModel {
@@ -204,6 +227,10 @@ export interface PierModel {
blockHeightBack: number; blockHeightBack: number;
frontBackDiffSpread: number; frontBackDiffSpread: number;
}; };
/** Dedicated rebar source; never substitute footing-anchored column inputs. */
castInput?: PierInput;
/** Solplate of every bearing with its S source, pair and warnings (2026-11-06), for the ② card and checks. */
soles: BearingSole[];
} }
/** /**
@@ -228,7 +255,10 @@ export function solveIp4(ip1: Vec2, ip2: Vec2, ip3: Vec2, angleDeg: number): Vec
* Each object carries its stretch k and a local s measured from that stretch's base (k = 0: BP1, k = 1: IP4). * 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. * 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 ip = (name: string) => {
const p = input.ips.find((q) => q.name === name); const p = input.ips.find((q) => q.name === name);
if (!p) throw new Error(`IP 누락: ${name}`); if (!p) throw new Error(`IP 누락: ${name}`);
@@ -245,9 +275,9 @@ export function buildPier(input: PierInput): PierModel {
// Coping heights and Level width (m): inputs, with the P1 values when absent (1단계). // Coping heights and Level width (m): inputs, with the P1 values when absent (1단계).
const copingH1 = (input.copingH1Mm ?? P1_COPING_GEOM.h1) / 1000; const copingH1 = (input.copingH1Mm ?? P1_COPING_GEOM.h1) / 1000;
const copingH2 = (input.copingH2Mm ?? P1_COPING_GEOM.h2) / 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). // Column diameter (m): the input, with 2.0 m when absent (승인 2026-10-23).
const colD = (input.columnDiameterMm ?? DIM.columnDiameter * 1000) / 1000; const colD = (input.columnDiameterMm ?? DIM.columnDiameter * 1000) / 1000;
const levelW = (input.levelWidthMm ?? defaultLevelWidthMm(input.type, colD * 1000)) / 1000;
const planHalfB = B / 2; const planHalfB = B / 2;
const ip1v: Vec2 = { e: ip1.x, n: ip1.y }; const ip1v: Vec2 = { e: ip1.x, n: ip1.y };
const ip2v: Vec2 = { e: ip2.x, n: ip2.y }; const ip2v: Vec2 = { e: ip2.x, n: ip2.y };
@@ -306,28 +336,44 @@ export function buildPier(input: PierInput): PierModel {
}; };
const frontPlaced = front.map((b) => ({ no: b.no, row: '전열' as Row, z: b.z, ...place(b) })); const frontPlaced = front.map((b) => ({ no: b.no, row: '전열' as Row, z: b.z, ...place(b) }));
const backPlaced = back.map((b) => ({ no: b.no, row: '후열' as Row, z: b.z, ...place(b) })); const backPlaced = back.map((b) => ({ no: b.no, row: '후열' as Row, z: b.z, ...place(b) }));
if (unequalRows) { frontPlaced.sort((a,b)=>a.g-b.g); backPlaced.sort((a,b)=>a.g-b.g); } // Rows run along the coping (2026-11-10): bearing numbers may run against IP1 → IP3 (산외교 P9 ① at g 12.1, ③ at 1.3),
// so the steps are always built in g order, not number order.
frontPlaced.sort((a,b)=>a.g-b.g); backPlaced.sort((a,b)=>a.g-b.g);
// Step boundaries sit midway between consecutive front bearings in coping coordinates (spec §4.3). // Step boundaries sit midway between consecutive front bearings in coping coordinates (spec §4.3).
// A step boundary less than half a block from the kink moves onto it (2026-11-10), so the step face is the mitre
// plane and the two stretches meet without a wedge; only when both bearings stay half a block away from the kink.
const halfBlock = DIM.blockSize / 2;
const toKink = (bound: number, gLeft: number, gRight: number) =>
kinkG !== null && Math.abs(bound - kinkG) < halfBlock && kinkG - gLeft >= halfBlock && gRight - kinkG >= halfBlock ? kinkG : bound;
const bounds = [0]; const bounds = [0];
for (let k = 0; k < frontPlaced.length - 1; k++) { for (let k = 0; k < frontPlaced.length - 1; k++) {
bounds.push((frontPlaced[k].g + frontPlaced[k + 1].g) / 2); bounds.push(toKink((frontPlaced[k].g + frontPlaced[k + 1].g) / 2, frontPlaced[k].g, frontPlaced[k + 1].g));
} }
bounds.push(L); bounds.push(L);
const segments: Segment[] = frontPlaced.map((b, k) => ({ s0: bounds[k], s1: bounds[k + 1], top: b.z - h1 })); // 교좌면 EL per bearing (2026-11-10): when given, it is the coping top of that bearing's step; otherwise z − h1 (z − h2 behind).
const seatOf = new Map(input.bearings.map((r) => [r.no, r.seatEl]));
const segments: Segment[] = frontPlaced.map((b, k) => ({ s0: bounds[k], s1: bounds[k + 1], top: seatOf.get(b.no) ?? b.z - h1 }));
// Lateral step (spec §4.3, Q1·Q2 추천안): front and back tops differ by 5 mm or more in some segment. // Lateral step (spec §4.3, Q1·Q2 추천안): front and back tops differ by 5 mm or more in some segment.
// The back steps break midway between consecutive back bearings (2026-11-10), as the front ones do: back bearings need
// not sit opposite the front ones (산외교 P11 ⑥ ↔ ⑦). With equal counts, a back bound less than half a block from the
// front bound snaps to it, so slightly staggered rows (P1, ≈ 0.09 m) keep one boundary and no sliver step.
const backBound = (k: number) => {
const own = (backPlaced[k-1].g + backPlaced[k].g) / 2;
return !unequalRows && Math.abs(own - bounds[k]) < halfBlock ? bounds[k] : toKink(own, backPlaced[k-1].g, backPlaced[k].g);
};
const backSegments: Segment[] = backPlaced.map((b,k) => ({ const backSegments: Segment[] = backPlaced.map((b,k) => ({
s0: unequalRows ? (k === 0 ? 0 : (backPlaced[k-1].g+b.g)/2) : segments[k].s0, s0: k === 0 ? 0 : backBound(k),
s1: unequalRows ? (k === backPlaced.length-1 ? L : (b.g+backPlaced[k+1].g)/2) : segments[k].s1, s1: k === backPlaced.length-1 ? L : backBound(k + 1),
top: b.z-h2, top: seatOf.get(b.no) ?? b.z-h2,
})); }));
const stepBounds = [...new Set([0,L,...segments.slice(1).map(s=>s.s0),...backSegments.slice(1).map(s=>s.s0)])].sort((a,b)=>a-b); const stepBounds = [...new Set([0,L,...segments.slice(1).map(s=>s.s0),...backSegments.slice(1).map(s=>s.s0)])].sort((a,b)=>a-b);
const stepSamples = stepBounds.slice(1).map((end,k)=>(stepBounds[k]+end)/2); const stepSamples = stepBounds.slice(1).map((end,k)=>(stepBounds[k]+end)/2);
const lateralStep = stepSamples.some(s => Math.abs(segmentTop(segments,s)-segmentTop(backSegments,s)) >= DIM.lateralTolerance); 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. // 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) { if (kinkG !== null) {
const near = nearKink(axis, colG.map((g) => sStart + g), levelW / 2); const near = nearKink(axis, colG.map((g) => sStart + g), levelW / 2);
if (near.length) throw new Error('미실시: 꺾임점 근처(Level 반폭 이내)에 기둥이 있습니다. 기둥 위치를 확인하세요.'); if (near.length) throw new Error('미실시: 꺾임점 근처(Level 반폭 이내)에 기둥이 있습니다. 기둥 위치를 확인하세요.');
@@ -340,7 +386,8 @@ export function buildPier(input: PierInput): PierModel {
const levelEnd = Math.max(...colG) + levelW / 2; const levelEnd = Math.max(...colG) + levelW / 2;
if (levelStart < 0 || levelEnd > L) throw new Error('코핑 길이가 Level 구간보다 짧습니다.'); 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 underside: [number, number][] = [[L, endBottom]];
const levelZones = [...colG].sort((a, b) => b - a).map((s) => [s - levelW / 2, s + levelW / 2] as const); const levelZones = [...colG].sort((a, b) => b - a).map((s) => [s - levelW / 2, s + levelW / 2] as const);
levelZones.forEach(([zoneStart, zoneEnd], k) => { levelZones.forEach(([zoneStart, zoneEnd], k) => {
@@ -348,9 +395,12 @@ export function buildPier(input: PierInput): PierModel {
const next = levelZones[k + 1]; const next = levelZones[k + 1];
if (!next) return; if (!next) return;
const gap = zoneStart - next[1]; const gap = zoneStart - next[1];
if (gap <= 2 * DIM.haunch) throw new Error('기둥 사이 간격이 헌치(300 × 2)보다 좁아 코핑 하면을 만들 수 없습니다.'); // RB-M01: no flat part left between the two haunches.
const lowerEl = levelBottom - DIM.haunch; if (gap <= 2 * DIM.concaveHaunch + 1e-9) {
underside.push([zoneStart - DIM.haunch, lowerEl], [next[1] + DIM.haunch, lowerEl], [next[1], levelBottom]); 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. // Elevation outline in coping coordinates for one row of tops, closed by the shared underside.
@@ -362,36 +412,70 @@ export function buildPier(input: PierInput): PierModel {
}; };
// Each stretch uses the same outline shifted to its own base; the mitre plane trims the overlap at the kink. // Each stretch uses the same outline shifted to its own base; the mitre plane trims the overlap at the kink.
const shifted = (pts: [number, number][], b: number): [number, number][] => pts.map(([s, z]) => [s - b, z]); const shifted = (pts: [number, number][], b: number): [number, number][] => pts.map(([s, z]) => [s - b, z]);
// A step at the kink (2026-11-10): each stretch's step face is square to its own axis, the mitre is not, so the face
// moves past the mitre spread (B/2)·tan(θ/2) into the trimmed side: stretch 0 keeps its left top up to the mitre and
// stretch 1 its right top back to it, and the visible step is the mitre plane itself (no sliver on either half).
const mitreSpread = (B / 2) * Math.tan((Math.abs(ip4.angleDeg) * Math.PI) / 360) + 0.01;
const atKink = (pts: [number, number][], k: number): [number, number][] =>
kinkG === null || mitreSpread >= halfBlock ? pts
: pts.map(([s, z]) => [Math.abs(s - kinkG) < 1e-9 ? kinkG + (k === 0 ? mitreSpread : -mitreSpread) : s, z]);
const meanN = (rows: { n: number }[]) => rows.reduce((sum, r) => sum + r.n, 0) / rows.length; const meanN = (rows: { n: number }[]) => rows.reduce((sum, r) => sum + r.n, 0) / rows.length;
const copingParts: CopingPart[] = []; const copingParts: CopingPart[] = [];
const stretchCount = kinkG === null ? 1 : 2; const stretchCount = kinkG === null ? 1 : 2;
// One front/back line for the whole pier (2026-11-10), so the parts of the two stretches meet at the kink without a
// sideways jog; per stretch only when some bearing would be closer than half a block to that line.
const nAll = (meanN(frontPlaced) + meanN(backPlaced)) / 2;
const oneLine = [...frontPlaced, ...backPlaced].every((b) => Math.abs(b.n - nAll) >= halfBlock);
for (let k = 0; k < stretchCount; k++) { for (let k = 0; k < stretchCount; k++) {
const fRows = frontPlaced.filter((b) => b.k === k); const fRows = oneLine ? frontPlaced : frontPlaced.filter((b) => b.k === k);
const bRows = backPlaced.filter((b) => b.k === k); const bRows = oneLine ? backPlaced : backPlaced.filter((b) => b.k === k);
const nStep = fRows.length && bRows.length const nStep = fRows.length && bRows.length
? (meanN(fRows) + meanN(bRows)) / 2 ? (meanN(fRows) + meanN(bRows)) / 2
: (meanN(frontPlaced) + meanN(backPlaced)) / 2; : nAll;
if (lateralStep) { if (lateralStep) {
// The front row may lie on either side of the axis (it depends on the IP1 → IP3 direction), so each part takes the
// side of its own row; the order stays [front, back].
const frontLow = (fRows.length && bRows.length ? meanN(fRows) - meanN(bRows) : meanN(frontPlaced) - meanN(backPlaced)) <= 0;
copingParts.push( copingParts.push(
{ k, nFrom: -planHalfB, nTo: nStep, profile: shifted(outline(segments), base[k]) }, { k, nFrom: frontLow ? -planHalfB : nStep, nTo: frontLow ? nStep : planHalfB, profile: shifted(atKink(outline(segments), k), base[k]) },
{ k, nFrom: nStep, nTo: planHalfB, profile: shifted(outline(backSegments), base[k]) }, { k, nFrom: frontLow ? nStep : -planHalfB, nTo: frontLow ? planHalfB : nStep, profile: shifted(atKink(outline(backSegments), k), base[k]) },
); );
} else { } else {
copingParts.push({ k, nFrom: -planHalfB, nTo: planHalfB, profile: shifted(outline(segments), base[k]) }); copingParts.push({ k, nFrom: -planHalfB, nTo: planHalfB, profile: shifted(atKink(outline(segments), k), base[k]) });
} }
} }
const fd = footingDims(input); const fd = footingDims(input);
// Footings are grouped per stretch so that a 일체기초 never crosses the kink; each group is aligned with its stretch. // Footings are grouped per stretch so that a 일체기초 never crosses the kink; each group is aligned with its stretch.
const footingSets: { s: number; k: number; length: number }[] = []; const footingSets: { s: number; k: number; length: number; bent?: FootingModel['bent'] }[] = [];
for (let k = 0; k < stretchCount; k++) { for (let k = 0; k < stretchCount; k++) {
for (const g of footingGroups(colG.filter((_, i) => colK[i] === k), fd.L)) { for (const g of footingGroups(colG.filter((_, i) => colK[i] === k), fd.L)) {
footingSets.push({ s: g.s - base[k], k, length: g.length }); footingSets.push({ s: g.s - base[k], k, length: g.length });
} }
} }
// Footings that meet or overlap across the kink become one bent footing (2026-11-10, 산외교 P11: 2.75 m overlap).
// Each part reaches past the mitre spread (W/2)·tan(θ/2) so the trimmed concrete joins without a wedge gap.
if (kinkG !== null) {
const last0 = footingSets.filter((f) => f.k === 0).sort((a, b) => b.s - a.s)[0];
const first1 = footingSets.filter((f) => f.k === 1).sort((a, b) => a.s - b.s)[0];
if (last0 && first1) {
const spreadF = (fd.W / 2) * Math.tan((Math.abs(ip4.angleDeg) * Math.PI) / 360);
const g0s = last0.s - last0.length / 2, g0e = last0.s + last0.length / 2;
const g1s = kinkG + first1.s - first1.length / 2, g1e = kinkG + first1.s + first1.length / 2;
if (g0e >= g1s - 2 * spreadF) {
const ext = spreadF + 0.01;
const p0e = Math.max(g0e, kinkG + ext), p1s = Math.min(g1s, kinkG - ext) - kinkG;
const bent = { gFrom: g0s, gTo: g1e };
Object.assign(last0, { s: (g0s + p0e) / 2, length: p0e - g0s, bent: { ...bent, part: 0 as const } });
Object.assign(first1, { s: (p1s + g1e - kinkG) / 2, length: g1e - kinkG - p1s, bent: { ...bent, part: 1 as const } });
}
}
}
const footingTop = input.foundationType === '현장타설말뚝' ? input.footingBottomEl : input.footingBottomEl + fd.H; const cast = castShape(input);
if (input.foundationType === '현장타설말뚝' && (!Number.isFinite(cast.pileTopEl) || cast.pileTopEl >= levelBottom || !Number.isFinite(input.pileLengthMm) || input.pileLengthMm <= 0)) throw new Error('현장타설말뚝은 지반고가 코핑 Level 하면보다 낮고 말뚝 길이가 양수여야 합니다.');
const footingTop = input.foundationType === '현장타설말뚝' ? cast.pileTopEl : input.footingBottomEl + fd.H;
const columns: ColumnModel[] = colG.map((g, i) => ({ const columns: ColumnModel[] = colG.map((g, i) => ({
s: g - base[colK[i]], s: g - base[colK[i]],
k: colK[i], k: colK[i],
@@ -404,15 +488,15 @@ export function buildPier(input: PierInput): PierModel {
const blindings: BlindingModel[] = []; const blindings: BlindingModel[] = [];
const piles: PileModel[] = []; const piles: PileModel[] = [];
const pileLength = input.pileLengthMm / 1000; const pileLength = input.pileLengthMm / 1000;
const pileTip = input.footingBottomEl - pileLength; const pileTip = input.foundationType === '현장타설말뚝' ? cast.pileBottomEl : input.footingBottomEl - pileLength;
if (input.foundationType === '현장타설말뚝') { if (input.foundationType === '현장타설말뚝') {
columns.forEach((c) => { 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 { } else {
for (const f of footingSets) { for (const f of footingSets) {
footings.push({ s: f.s, k: f.k, top: footingTop, bottom: input.footingBottomEl, length: f.length, width: fd.W }); footings.push({ s: f.s, k: f.k, top: footingTop, bottom: input.footingBottomEl, length: f.length, width: fd.W, ...(f.bent ? { bent: f.bent } : {}) });
blindings.push({ blindings.push({
s: f.s, s: f.s,
k: f.k, k: f.k,
@@ -421,30 +505,53 @@ export function buildPier(input: PierInput): PierModel {
width: fd.W + fd.blM, width: fd.W + fd.blM,
thickness: fd.blT, thickness: fd.blT,
}); });
if (input.foundationType === '말뚝기초') { if (input.foundationType === '말뚝기초' && f.bent?.part !== 1) {
const g = pileGeometry(f.length, fd.W); // A bent footing lays its piles once over the unfolded length; each pile goes to the stretch of its side of the kink.
const len = f.bent ? f.bent.gTo - f.bent.gFrom : f.length;
const g = pileGeometry(len, fd.W);
const counts = resolveRowCounts(input.pileRows, g); const counts = resolveRowCounts(input.pileRows, g);
piles.push(...pilesFor(f.s, input.footingBottomEl, pileLength, g, counts, fd.pileD).map((p) => ({ ...p, k: f.k }))); const centre = f.bent ? (f.bent.gFrom + f.bent.gTo) / 2 : f.s;
piles.push(...pilesFor(centre, input.footingBottomEl, pileLength, g, counts, fd.pileD).map((p) =>
f.bent && kinkG !== null && p.s > kinkG ? { ...p, s: p.s - kinkG, k: 1 } : { ...p, k: f.k }));
} }
} }
} }
const blocks: BlockModel[] = []; const 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); 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. // Solplate of every bearing (결정 2026-11-06): S from the girder pair (ctx.pairs over the whole bridge), else the
const rules = input.solplate ?? { T: 30, margin: 100, h3: 181 }; // 종단경사 input, else the other row; T per row; the girder underside stays, so the block top drops by T − T_ref.
const planOf = (no: number) => { // 받침높이 per bearing (2026-11-10): h3 = H − T_ref keeps the girder underside at z + H; a typed h3 still wins.
const p = input.bearingPlans?.find((q) => q.no === no); const tRef = input.sole?.tRefMm ?? SOLE_DEFAULTS.tRefMm;
return p ? { A: p.A, B: p.B, S: p.S, T: rules.T, margin: rules.margin, h3: rules.h3 } : undefined; const h3ByNo = Object.fromEntries(input.bearings.filter((b) => b.heightMm !== undefined).map((b) => [String(b.no), b.heightMm! - tRef]));
const soles = new Map(structureSoles(input.name, input.bearings.map((b) => ({ no: b.no, row: b.row, azimuthDeg: azimuthOf(b), inputAzimuthDeg: b.azimuthDeg })),
{ ...input.sole, h3ByNo }, ctx.pairs ?? [], input.longitudinalSlope === undefined ? undefined : input.longitudinalSlope * 100).map((s) => [s.no, s]));
const planOf = (no: number, k: number, skewDeg: number): SolplatePlan => {
const s = soles.get(no)!;
// The block's c axis in (E, N): (−sin d)·u + (cos d)·nrm; T1 goes to the end of c that points uphill.
const { u, nrm } = axis.segments[k];
const d = (skewDeg * Math.PI) / 180;
const c = { e: -Math.sin(d) * u.e + Math.cos(d) * nrm.e, n: -Math.sin(d) * u.n + Math.cos(d) * nrm.n };
const t1Side = s.uphill && c.e * s.uphill.e + c.n * s.uphill.n > 0 ? 1 : -1;
return { L: s.L, W: s.W, slopePct: s.slopePct, T: s.T, T1: s.T1, T2: s.T2, h3: s.h3, t1Side };
}; };
const dropOf = (no: number) => soles.get(no)!.dropMm / 1000;
// 교좌면 EL (2026-11-03): when the input gives it, the block bottom is that EL; otherwise the 두부보 상면 slope.
// seatCalc stays the slope value (z − h1, z − h2) as the W10 reference.
frontPlaced.forEach((b) => { frontPlaced.forEach((b) => {
const bottom = segmentTop(segments, b.g); const seatCalc = b.z - h1;
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 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) => { backPlaced.forEach((b) => {
const bottom = b.z - h2; const seatCalc = 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 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; const blockHeightFront = blocks.find((b) => b.row === '전열')!.top - blocks.find((b) => b.row === '전열')!.bottom;
@@ -484,7 +591,7 @@ export function buildPier(input: PierInput): PierModel {
blocks, blocks,
integratedBlocks: buildIntegratedBlocks(blocks, input.bearingLayout === '일체'), integratedBlocks: buildIntegratedBlocks(blocks, input.bearingLayout === '일체'),
blockRebar: { ...DEFAULT_BLOCK_REBAR, ...input.blockRebar }, blockRebar: { ...DEFAULT_BLOCK_REBAR, ...input.blockRebar },
copingRebar: { ...DEFAULT_COPING_REBAR, ...input.copingRebar }, copingRebar: { ...copingDefaults(columns.length), ...input.copingRebar },
topSplice: { ...DEFAULT_TOP_SPLICE, ...input.topSplice }, topSplice: { ...DEFAULT_TOP_SPLICE, ...input.topSplice },
columnRebar: { ...DEFAULT_COLUMN_REBAR, ...input.columnRebar }, columnRebar: { ...DEFAULT_COLUMN_REBAR, ...input.columnRebar },
footingRebar: { ...DEFAULT_FOOTING_REBAR, ...input.footingRebar }, footingRebar: { ...DEFAULT_FOOTING_REBAR, ...input.footingRebar },
@@ -495,7 +602,7 @@ export function buildPier(input: PierInput): PierModel {
groundEl: input.groundEl, groundEl: input.groundEl,
derived: { derived: {
columnHeight: levelBottom - columns[0].bottom, columnHeight: levelBottom - columns[0].bottom,
soilCover: input.groundEl - (input.foundationType === '현장타설말뚝' ? input.footingBottomEl : footingTop), soilCover: input.foundationType === '현장타설말뚝' ? 0 : input.groundEl - footingTop,
pileCount: piles.length, pileCount: piles.length,
pileLayout: input.foundationType === '말뚝기초' ? layoutText(resolveRowCounts(input.pileRows, pileGeometry(footings[0].length, fd.W)), pileGeometry(footings[0].length, fd.W)) : null, 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, pileTipEl: piles.length ? pileTip : null,
@@ -503,11 +610,23 @@ export function buildPier(input: PierInput): PierModel {
blockHeightBack, blockHeightBack,
frontBackDiffSpread, 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]; 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]; if (type === '2주형') return [L / 5, L - L / 5];
return [L / 5, L / 2, L - L / 5]; return [L / 5, L / 2, L - L / 5];
} }
+13
View File
@@ -72,8 +72,21 @@ function numOrNull(v: unknown): number | null {
export function checkGroundModel(raw: unknown): GroundModelFile { export function checkGroundModel(raw: unknown): GroundModelFile {
const obj = raw as Partial<GroundModelFile> | null; const obj = raw as Partial<GroundModelFile> | null;
if (!obj || typeof obj !== 'object') throw new Error('지반 모델 파일 형식이 아닙니다.'); if (!obj || typeof obj !== 'object') throw new Error('지반 모델 파일 형식이 아닙니다.');
if (!('schema' in obj) && 'schemaVersion' in obj) {
const project = obj as { schemaVersion?: unknown; groundModel?: unknown };
if (project.schemaVersion !== 1) throw new Error(`프로젝트 형식 번호 ${String(project.schemaVersion)}에서 지반모델을 가져올 수 없습니다.`);
if (project.groundModel == null) throw new Error('이 프로젝트 파일에는 지반모델이 없습니다. 프로젝트 전체는 [불러오기 → 프로젝트 열기]에서 열 수 있습니다. 지반 결과는 ground_model.json을 선택하세요.');
return checkStoredGroundModel(project.groundModel);
}
const knownSchema = schemaOrLegacy(obj); const knownSchema = schemaOrLegacy(obj);
if (knownSchema === undefined) { if (knownSchema === undefined) {
const data = obj as Record<string, unknown>;
if ('drawing' in data && 'output' in data && typeof data.piers === 'string') {
throw new Error('지반 생성 설정 파일입니다. 계산 결과인 ground_model.json 또는 ground_model_3d_with_maps.json을 선택하세요.');
}
if (!('schema' in data)) {
throw new Error('계산된 지반모델을 찾을 수 없습니다. ground_model.json 또는 ground_model_3d_with_maps.json을 선택하세요. 설정·진단·결과 요약 JSON은 적용할 수 없습니다.');
}
throw new Error(`지반 모델 형식 번호 ${String((obj as { schema?: unknown }).schema)}을(를) 읽을 수 없습니다 (지원: ${READABLE_SCHEMAS.join(', ')}).`); throw new Error(`지반 모델 형식 번호 ${String((obj as { schema?: unknown }).schema)}을(를) 읽을 수 없습니다 (지원: ${READABLE_SCHEMAS.join(', ')}).`);
} }
if (!READABLE_SCHEMAS.includes(knownSchema as number)) { if (!READABLE_SCHEMAS.includes(knownSchema as number)) {
+14 -6
View File
@@ -1,6 +1,14 @@
import type { PierModel } from './geometry';
import type { Issue, PierInput } from './types'; 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). */ /** 위치 choices of the 지반·지층 card (시안 2026-10-26). */
export const GROUND_POSITIONS = ['육상', '하상', '해상'] as const; export const GROUND_POSITIONS = ['육상', '하상', '해상'] as const;
@@ -28,7 +36,7 @@ export const CORROSION_ALLOWANCE_MM = 1;
* G03: 층 이름은 지층 목록에 있어야 함. 하상: G07 DHWL 없음, G08 DHWL이 지반고보다 낮음, G09 세굴선 확인. * G03: 층 이름은 지층 목록에 있어야 함. 하상: G07 DHWL 없음, G08 DHWL이 지반고보다 낮음, G09 세굴선 확인.
* Ground values are inputs only; they do not change the 3D model. * 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 ?? []; const rows = input.strataRows ?? [];
let upper = input.groundEl; let upper = input.groundEl;
rows.forEach((row, i) => { 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. * 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). * 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; if (model.piles.length === 0) return;
const rows = input.strataRows ?? []; const rows = input.strataRows ?? [];
const k = rows.findIndex((r) => r.name === SUPPORT_STRATUM); 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. * 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. * 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) => { (input.strataRows ?? []).forEach((row, i) => {
if (parseNValue(row.nValue).kind === 'invalid') { if (parseNValue(row.nValue).kind === 'invalid') {
issues.push({ 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 (하상·해상). */ /** 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; const scour = input.scourDepthM;
if (scour === undefined) return; if (scour === undefined) return;
if (!(scour >= 0)) { if (!(scour >= 0)) {
@@ -152,7 +160,7 @@ function validateScour(input: PierInput, issues: Issue[]): void {
* 저조위 is below 고조위. G13 missing; G10 해저면이 고조위보다 낮지 않음; G11 저조위가 고조위보다 낮지 않음; * 저조위 is below 고조위. G13 missing; G10 해저면이 고조위보다 낮지 않음; G11 저조위가 고조위보다 낮지 않음;
* G14 강관 두께가 부식 여유 기준 이하. 파랑과 선박 하중은 이번 범위에서 제외. * G14 강관 두께가 부식 여유 기준 이하. 파랑과 선박 하중은 이번 범위에서 제외.
*/ */
function validateSea(input: PierInput, issues: Issue[]): void { function validateSea(input: GroundCheckInput, issues: Issue[]): void {
const hw = input.highWaterEl; const hw = input.highWaterEl;
const lw = input.lowWaterEl; const lw = input.lowWaterEl;
if (hw === undefined || lw === undefined || !Number.isFinite(hw) || !Number.isFinite(lw)) { if (hw === undefined || lw === undefined || !Number.isFinite(hw) || !Number.isFinite(lw)) {
+4
View File
@@ -186,5 +186,9 @@ function toBearing(c: Cell[]): Bearing {
z: Number(c[4]), z: Number(c[4]),
azimuthDeg: swapXYAzimuth(parseDms(String(c[5] ?? '')) ?? NaN), azimuthDeg: swapXYAzimuth(parseDms(String(c[5] ?? '')) ?? NaN),
row: String(c[6]) as Row, row: String(c[6]) as Row,
// 교좌면_EL (m), optional: blank = not given, so the model keeps the 두부보 상면 slope.
seatEl: c[7] === undefined || c[7] === null || c[7] === '' ? undefined : Number(c[7]),
// 받침높이_mm, optional (2026-11-10): blank = the pier h3 setting.
heightMm: c[8] === undefined || c[8] === null || c[8] === '' ? undefined : Number(c[8]),
}; };
} }
+34
View File
@@ -0,0 +1,34 @@
import type { GroundModelFile, TerrainGrid } from './ground-model';
import type { AbutRecord } from './abut/parse';
import type { PierInput } from './types';
export function applyProjectGround(draft: PierInput, model: GroundModelFile, options: { ground: boolean; layers: boolean }): PierInput {
const entry = model.piers.find(p => p.id === draft.name);
if (!entry) return draft;
return { ...draft,
...(options.ground && entry.centerEl !== null && Number.isFinite(entry.centerEl) ? { groundEl: entry.centerEl } : {}),
...(options.layers && entry.layers?.length ? { strataRows: entry.layers.map(row => ({ ...row })) } : {}),
};
}
export function groundInputChanged(input: PierInput, draft: PierInput): boolean {
return input.groundEl !== draft.groundEl || JSON.stringify(input.strataRows ?? []) !== JSON.stringify(draft.strataRows ?? []);
}
/** No extrapolation or filling null nodes; coordinates are survey north/east. */
export function sampleGroundGrid(grid: TerrainGrid, point: { e: number; n: number }): number | null {
const x = (point.n - grid.origin[0]) / grid.step, y = (point.e - grid.origin[1]) / grid.step;
if (!Number.isFinite(x) || !Number.isFinite(y) || x < 0 || y < 0 || x > grid.nx - 1 || y > grid.ny - 1) return null;
const i = Math.min(Math.floor(x), grid.nx - 2), j = Math.min(Math.floor(y), grid.ny - 2);
const h = [grid.z[j * grid.nx + i], grid.z[j * grid.nx + i + 1], grid.z[(j + 1) * grid.nx + i], grid.z[(j + 1) * grid.nx + i + 1]];
if (h.some(v => v == null || !Number.isFinite(v))) return null;
const u = x - i, v = y - j;
return h[0]! * (1-u) * (1-v) + h[1]! * u * (1-v) + h[2]! * (1-u) * v + h[3]! * u * v;
}
export function abutGroundFromModel(model: GroundModelFile | null, abut: Pick<AbutRecord, 'name' | 'ip2'>) {
const explicit = model?.piers.find(p => p.id === abut.name);
if (explicit) return { ...explicit, source: 'JSON 개별값' };
const centerEl = model?.terrainGrid ? sampleGroundGrid(model.terrainGrid, abut.ip2) : null;
return { id: abut.name, centerEl, layers: undefined, source: centerEl === null ? 'IP2 격자 자료 없음' : 'IP2 격자 보간' };
}
+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 type { FckByMember, Material } from './rebar/common';
import { checkStoredGroundModel, type GroundModelFile } from './ground-model'; import { checkStoredGroundModel, type GroundModelFile } from './ground-model';
import type { PierInput } from './types'; import type { PierInput } from './types';
import type { AbutRecord } from './abut/parse';
import { abutNameError } from './abut/name';
import { normalizeBearingAzimuths } from './coordinate-frame'; 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. */ /** Project file schema (spec §8). Bump only with a migration. */
export const SCHEMA_VERSION = 1; export const SCHEMA_VERSION = 1;
@@ -33,14 +37,14 @@ export interface ProjectFile {
history: { version: string; note: string; at: string }[]; history: { version: string; note: string; at: string }[];
/** ③ 공통 설정 (fck · fy). Absent in older files: the defaults are used. */ /** ③ 공통 설정 (fck · fy). Absent in older files: the defaults are used. */
common?: Material; 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. */ /** ② 부재별 fck (MPa). Absent in older files: every member uses the 공통 fck. */
fckByMember?: FckByMember; fckByMember?: FckByMember;
/** 지반 모델 (ground_model.json)의 교각별 지반고. Absent = 불러오지 않음. Applied to a pier only by the user. */ /** 지반 모델 (ground_model.json)의 교각별 지반고. Absent = 불러오지 않음. Applied to a pier only by the user. */
groundModel?: GroundModelFile; 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. */ /** 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 (!Array.isArray(obj.piers)) throw new Error('piers 목록이 없습니다.');
if (obj.fckByMember !== undefined) { if (obj.fckByMember !== undefined) {
for (const [member, value] of Object.entries(obj.fckByMember)) { 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)}).`); throw new Error(`부재별 fck 값이 맞지 않습니다 (${member}: ${String(value)}).`);
} }
} }
} }
if (obj.groundModel !== undefined) checkStoredGroundModel(obj.groundModel); 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) => { obj.piers.forEach((p, i) => {
if (!p || typeof p.id !== 'string' || !p.input) throw new Error(`교각 ${i + 1}: id 또는 input이 없습니다.`); if (!p || typeof p.id !== 'string' || !p.input) throw new Error(`교각 ${i + 1}: id 또는 input이 없습니다.`);
const input = p.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]) { 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') { if (values?.bearingAzimuthFrame !== undefined && values.bearingAzimuthFrame !== 'model-xy') {
throw new Error(`교각 ${input.name}: 받침 방위각 좌표계 표기가 맞지 않습니다.`); throw new Error(`교각 ${input.name}: 받침 방위각 좌표계 표기가 맞지 않습니다.`);
} }
@@ -103,3 +114,100 @@ export function parseProject(text: string): ProjectFile {
}); });
return obj as 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)}의 값이 맞지 않습니다.`);
}
}
});
}
+56
View File
@@ -0,0 +1,56 @@
import * as XLSX from 'xlsx';
import { quantityRows, type Quantity } from './rebar/quantity';
export interface QuantityRecord {
name: string;
quantity: Quantity | null;
dirty: boolean;
}
/** Export checks use the same records as the visible result, including ungenerated piers. */
export function quantityExportIssues(records: QuantityRecord[]): string[] {
return records.flatMap(r => r.dirty ? [`${r.name}: 형상 재생성 필요`] : !r.quantity ? [`${r.name}: 형상 없음`] : []);
}
export function quantityPieceRows(records: QuantityRecord[]): (string | number)[][] {
const rows: (string | number)[][] = [['교각', '부재', '강종', '번호', '규격', '조각', '길이 (mm)', '가공 본수', '총길이 (m)', '총중량 (kg)']];
for (const r of records) for (const g of r.quantity?.groups ?? []) for (const d of g.diameters) for (const line of d.lines) {
line.pieces.forEach((p, i) => {
const totalM = p.length * p.count / 1000;
rows.push([r.name, line.member, `SD${g.grade}`, line.name, line.size, i + 1, p.length, p.count, totalM, totalM * line.kgPerM]);
});
}
return rows;
}
/** Generated values remain numbers; only the display format rounds them. */
export function createQuantityWorkbook(records: QuantityRecord[], meta: { project: string; bridge: string; generatedAt: string; abutmentCount: number }): XLSX.WorkBook {
const issues = quantityExportIssues(records);
if (!records.length || issues.length) throw new Error(issues.join('\n') || '출력할 교각이 없습니다.');
const wb = XLSX.utils.book_new(), used = new Set(['합계', '가공 상세', '커플러', '검토 항목', '생성 정보']);
const append = (name: string, rows: (string | number)[][]) => XLSX.utils.book_append_sheet(wb, XLSX.utils.aoa_to_sheet(rows), name);
const sheetName = (name: string) => {
const base = name.replace(/[[\]:*?/\\\x00-\x1f]/g, '_').replace(/^'+|'+$/g, '') || '교각';
let candidate = base.slice(0, 31), n = 1;
while (used.has(candidate.toLowerCase())) { const suffix = `_${n++}`; candidate = base.slice(0, 31 - suffix.length) + suffix; }
used.add(candidate.toLowerCase()); return candidate;
};
const summary: (string | number)[][] = [['교각', '합계 (kg)', '할증 포함 (kg)', 'ton', '계산 상태']];
const couplers: (string | number)[][] = [['교각', '번호', '개수']];
const review: (string | number)[][] = [['교각', '미계산·검토 항목']];
let kg = 0, kgWithWaste = 0;
for (const r of records) {
const q = r.quantity!;
const status = q.missing.length ? '일부 계산 · 검토용' : '계산 완료';
append(sheetName(r.name), [['교각', r.name], ['계산 상태', status], ['할증 (%)', q.wastePercent], [], ...quantityRows(q)]);
summary.push([r.name, q.kg, q.kgWithWaste, q.ton, status]);
kg += q.kg; kgWithWaste += q.kgWithWaste;
q.couplers.forEach(c => couplers.push([r.name, c.name, c.count]));
q.missing.forEach(m => review.push([r.name, m]));
}
const partial = records.some(r => r.quantity!.missing.length);
summary.push([partial ? '계산된 부재 소계 · 전체 수량 미완료' : '합계', kg, kgWithWaste, kgWithWaste / 1000, partial ? '검토용' : '계산 완료']);
append('합계', summary); append('가공 상세', quantityPieceRows(records)); append('커플러', couplers); append('검토 항목', review);
append('생성 정보', [['프로젝트', meta.project], ['교량', meta.bridge], ['생성 시각', meta.generatedAt], ['대상', records.map(r => r.name).join(', ')], ['출력 상태', partial ? '검토용 · 전체 수량 미완료' : '계산 완료'], ['범위', `교각 ${records.length}기 · 교대 ${meta.abutmentCount}기 제외`], ['수량 기준', '할증 전 실제 길이 · 가공 상세 길이×본수로 검산'], ['원표 수량', '철근표 행 수량이며 가공 조각 본수와 다를 수 있음']]);
return wb;
}
+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;
}
Loaded 100 of 260 files, more files were not shown because too many files have changed in this diff. Show more